From 5832a935cfd913f191285770eb1d3156a0306e38 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 2 Nov 2024 13:52:49 +0200 Subject: [PATCH] all: use markdown for custom emojis, improve editing Edits will now use a different HTML -> markdown converter than what is used to generate the body. This allows the plaintext body to have a plain shortcode for custom emojis, while still having the raw data for edits. Additionally, for sent events, the raw input is saved locally, which allows preserving commands and other such things. A future extension may store the raw input in a custom field in the Matrix event to allow lossless edits of messages sent from other clients. --- go.mod | 2 +- go.sum | 4 +-- pkg/hicli/database/event.go | 1 + pkg/hicli/send.go | 48 +++++++++++++++++++++++-- pkg/hicli/sync.go | 21 +++++++++-- web/src/api/types/hitypes.ts | 1 + web/src/ui/composer/MessageComposer.tsx | 4 ++- web/src/util/emoji/index.ts | 4 ++- 8 files changed, 75 insertions(+), 10 deletions(-) diff --git a/go.mod b/go.mod index 4f5fb92..51aba19 100644 --- a/go.mod +++ b/go.mod @@ -24,7 +24,7 @@ require ( golang.org/x/text v0.19.0 gopkg.in/yaml.v3 v3.0.1 maunium.net/go/mauflag v1.0.0 - maunium.net/go/mautrix v0.21.2-0.20241102103750-013afd06d341 + maunium.net/go/mautrix v0.21.2-0.20241102114451-83e60efa1558 mvdan.cc/xurls/v2 v2.5.0 ) diff --git a/go.sum b/go.sum index 69ab61e..f259946 100644 --- a/go.sum +++ b/go.sum @@ -89,7 +89,7 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M= maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= -maunium.net/go/mautrix v0.21.2-0.20241102103750-013afd06d341 h1:5oQl6MlXHTlN+MVxmnJbhaQM1+1Lt/o3w4g8CfovQjM= -maunium.net/go/mautrix v0.21.2-0.20241102103750-013afd06d341/go.mod h1:sjCZR1R/3NET/WjkcXPL6WpAHlWKku9HjRsdOkbM8Qw= +maunium.net/go/mautrix v0.21.2-0.20241102114451-83e60efa1558 h1:GWrBpixB+7hm2vk5Z6JvMoUgAea6ma8Mg4QaO53csYs= +maunium.net/go/mautrix v0.21.2-0.20241102114451-83e60efa1558/go.mod h1:b91JuKxF/JkOf0ra/YmhV44Sa/EtOjxkLJANzBJ1frg= mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8= mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE= diff --git a/pkg/hicli/database/event.go b/pkg/hicli/database/event.go index f0935d7..8a5b09b 100644 --- a/pkg/hicli/database/event.go +++ b/pkg/hicli/database/event.go @@ -293,6 +293,7 @@ type LocalContent struct { WasPlaintext bool `json:"was_plaintext,omitempty"` BigEmoji bool `json:"big_emoji,omitempty"` HasMath bool `json:"has_math,omitempty"` + EditSource string `json:"edit_source,omitempty"` } type Event struct { diff --git a/pkg/hicli/send.go b/pkg/hicli/send.go index 28fc252..daedbde 100644 --- a/pkg/hicli/send.go +++ b/pkg/hicli/send.go @@ -30,10 +30,38 @@ import ( ) var ( - rainbowWithHTML = goldmark.New(format.Extensions, goldmark.WithExtensions(mdext.Math), format.HTMLOptions, goldmark.WithExtensions(rainbow.Extension)) - defaultWithHTML = goldmark.New(format.Extensions, goldmark.WithExtensions(mdext.Math), format.HTMLOptions) + rainbowWithHTML = goldmark.New(format.Extensions, goldmark.WithExtensions(mdext.Math, mdext.CustomEmoji), format.HTMLOptions, goldmark.WithExtensions(rainbow.Extension)) + defaultWithHTML = goldmark.New(format.Extensions, goldmark.WithExtensions(mdext.Math, mdext.CustomEmoji), format.HTMLOptions) ) +var htmlToMarkdownForInput = ptr.Clone(format.MarkdownHTMLParser) + +func init() { + htmlToMarkdownForInput.PillConverter = func(displayname, mxid, eventID string, ctx format.Context) string { + switch { + case len(mxid) == 0, mxid[0] == '@': + return fmt.Sprintf("[%s](%s)", displayname, id.UserID(mxid).URI().MatrixToURL()) + case len(eventID) > 0: + return fmt.Sprintf("[%s](%s)", displayname, id.RoomID(mxid).EventURI(id.EventID(eventID)).MatrixToURL()) + case mxid[0] == '!' && displayname == mxid: + return fmt.Sprintf("[%s](%s)", displayname, id.RoomID(mxid).URI().MatrixToURL()) + case mxid[0] == '#': + return fmt.Sprintf("[%s](%s)", displayname, id.RoomAlias(mxid).URI().MatrixToURL()) + default: + return htmlToMarkdownForInput.LinkConverter(displayname, "https://matrix.to/#/"+mxid, ctx) + } + } + htmlToMarkdownForInput.ImageConverter = func(src, alt, title, width, height string, isEmoji bool) string { + if isEmoji { + return fmt.Sprintf(`![%s](%s "Emoji: %q")`, alt, src, title) + } else if title != "" { + return fmt.Sprintf(`![%s](%s "%s")`, alt, src, title) + } else { + return fmt.Sprintf(`![%s](%s)`, alt, src) + } + } +} + func (h *HiClient) SendMessage( ctx context.Context, roomID id.RoomID, @@ -44,6 +72,7 @@ func (h *HiClient) SendMessage( ) (*database.Event, error) { var content event.MessageEventContent msgType := event.MsgText + origText := text if strings.HasPrefix(text, "/me ") { msgType = event.MsgEmote text = strings.TrimPrefix(text, "/me ") @@ -102,7 +131,7 @@ func (h *HiClient) SendMessage( content.RelatesTo = relatesTo } } - return h.Send(ctx, roomID, event.EventMessage, &content) + return h.send(ctx, roomID, event.EventMessage, &content, origText) } func (h *HiClient) MarkRead(ctx context.Context, roomID id.RoomID, eventID id.EventID, receiptType event.ReceiptType) error { @@ -153,6 +182,16 @@ func (h *HiClient) Send( roomID id.RoomID, evtType event.Type, content any, +) (*database.Event, error) { + return h.send(ctx, roomID, evtType, content, "") +} + +func (h *HiClient) send( + ctx context.Context, + roomID id.RoomID, + evtType event.Type, + content any, + overrideEditSource string, ) (*database.Event, error) { room, err := h.DB.Room.Get(ctx, roomID) if err != nil { @@ -193,6 +232,9 @@ func (h *HiClient) Send( var inlineImages []id.ContentURI mautrixEvt := dbEvt.AsRawMautrix() dbEvt.LocalContent, inlineImages = h.calculateLocalContent(ctx, dbEvt, mautrixEvt) + if overrideEditSource != "" { + dbEvt.LocalContent.EditSource = overrideEditSource + } _, err = h.DB.Event.Insert(ctx, dbEvt) if err != nil { return nil, fmt.Errorf("failed to insert event into database: %w", err) diff --git a/pkg/hicli/sync.go b/pkg/hicli/sync.go index 5bc593f..e02071f 100644 --- a/pkg/hicli/sync.go +++ b/pkg/hicli/sync.go @@ -25,6 +25,7 @@ import ( "maunium.net/go/mautrix/crypto" "maunium.net/go/mautrix/crypto/olm" "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/format" "maunium.net/go/mautrix/id" "maunium.net/go/mautrix/pushrules" @@ -366,7 +367,7 @@ func (h *HiClient) calculateLocalContent(ctx context.Context, dbEvt *database.Ev content = content.NewContent } if content != nil { - var sanitizedHTML string + var sanitizedHTML, editSource string var wasPlaintext, hasMath, bigEmoji bool var inlineImages []id.ContentURI if content.Format == event.FormatHTML && content.FormattedBody != "" { @@ -384,6 +385,16 @@ func (h *HiClient) calculateLocalContent(ctx context.Context, dbEvt *database.Ev } inlineImages = nil } + if dbEvt.LocalContent != nil && dbEvt.LocalContent.EditSource != "" { + editSource = dbEvt.LocalContent.EditSource + } else if evt.Sender == h.Account.UserID { + editSource, _ = format.HTMLToMarkdownFull(htmlToMarkdownForInput, content.FormattedBody) + if content.MsgType == event.MsgEmote { + editSource = "/me " + editSource + } else if content.MsgType == event.MsgNotice { + editSource = "/notice " + editSource + } + } } else { hasSpecialCharacters := false for _, char := range content.Body { @@ -400,6 +411,11 @@ func (h *HiClient) calculateLocalContent(ctx context.Context, dbEvt *database.Ev } else if len(content.Body) < 100 && emojirunes.IsOnlyEmojis(content.Body) { bigEmoji = true } + if content.MsgType == event.MsgEmote { + editSource = "/me " + content.Body + } else if content.MsgType == event.MsgNotice { + editSource = "/notice " + content.Body + } wasPlaintext = true } return &database.LocalContent{ @@ -408,6 +424,7 @@ func (h *HiClient) calculateLocalContent(ctx context.Context, dbEvt *database.Ev WasPlaintext: wasPlaintext, BigEmoji: bigEmoji, HasMath: hasMath, + EditSource: editSource, }, inlineImages } return nil, nil @@ -597,7 +614,7 @@ func (h *HiClient) processStateAndTimeline( } processNewEvent := func(evt *event.Event, isTimeline, isUnread bool) (database.EventRowID, error) { evt.RoomID = room.ID - dbEvt, err := h.processEvent(ctx, evt, summary, decryptionQueue, false) + dbEvt, err := h.processEvent(ctx, evt, summary, decryptionQueue, evt.Unsigned.TransactionID != "") if err != nil { return -1, err } diff --git a/web/src/api/types/hitypes.ts b/web/src/api/types/hitypes.ts index fab9136..1bce825 100644 --- a/web/src/api/types/hitypes.ts +++ b/web/src/api/types/hitypes.ts @@ -81,6 +81,7 @@ export enum UnreadType { export interface LocalContent { sanitized_html?: TrustedHTML + edit_source?: string html_version?: number was_plaintext?: boolean big_emoji?: boolean diff --git a/web/src/ui/composer/MessageComposer.tsx b/web/src/ui/composer/MessageComposer.tsx index c408b64..0a27b3e 100644 --- a/web/src/ui/composer/MessageComposer.tsx +++ b/web/src/ui/composer/MessageComposer.tsx @@ -107,7 +107,9 @@ const MessageComposer = () => { rawSetEditing(evt) setState({ media: isMedia ? evtContent as MediaMessageEventContent : null, - text: (!evt.content.filename || evt.content.filename !== evt.content.body) ? (evtContent.body ?? "") : "", + text: (!evt.content.filename || evt.content.filename !== evt.content.body) + ? (evt.local_content?.edit_source ?? evtContent.body ?? "") + : "", replyTo: null, }) textInput.current?.focus() diff --git a/web/src/util/emoji/index.ts b/web/src/util/emoji/index.ts index 048e924..94abedf 100644 --- a/web/src/util/emoji/index.ts +++ b/web/src/util/emoji/index.ts @@ -105,7 +105,9 @@ function filterAndSort( export function emojiToMarkdown(emoji: PartialEmoji): string { if (emoji.u.startsWith("mxc://")) { const title = emoji.t && emoji.t !== emoji.n ? emoji.t : `:${emoji.n}:` - return `:${emoji.n}:` + const escapedTitle = title.replaceAll(`\\`, `\\\\`).replaceAll(`"`, `\\"`) + return `![:${emoji.n}:](${emoji.u} "Emoji: ${escapedTitle}")` + //return `:${emoji.n}:` } return emoji.u }