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.
This commit is contained in:
Tulir Asokan 2024-11-02 13:52:49 +02:00
parent 2ff3f9120c
commit 5832a935cf
8 changed files with 75 additions and 10 deletions

2
go.mod
View file

@ -24,7 +24,7 @@ require (
golang.org/x/text v0.19.0 golang.org/x/text v0.19.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mauflag v1.0.0 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 mvdan.cc/xurls/v2 v2.5.0
) )

4
go.sum
View file

@ -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= 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 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= 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.20241102114451-83e60efa1558 h1:GWrBpixB+7hm2vk5Z6JvMoUgAea6ma8Mg4QaO53csYs=
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/go.mod h1:b91JuKxF/JkOf0ra/YmhV44Sa/EtOjxkLJANzBJ1frg=
mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8= mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8=
mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE= mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE=

View file

@ -293,6 +293,7 @@ type LocalContent struct {
WasPlaintext bool `json:"was_plaintext,omitempty"` WasPlaintext bool `json:"was_plaintext,omitempty"`
BigEmoji bool `json:"big_emoji,omitempty"` BigEmoji bool `json:"big_emoji,omitempty"`
HasMath bool `json:"has_math,omitempty"` HasMath bool `json:"has_math,omitempty"`
EditSource string `json:"edit_source,omitempty"`
} }
type Event struct { type Event struct {

View file

@ -30,10 +30,38 @@ import (
) )
var ( var (
rainbowWithHTML = goldmark.New(format.Extensions, goldmark.WithExtensions(mdext.Math), format.HTMLOptions, goldmark.WithExtensions(rainbow.Extension)) 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), format.HTMLOptions) 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( func (h *HiClient) SendMessage(
ctx context.Context, ctx context.Context,
roomID id.RoomID, roomID id.RoomID,
@ -44,6 +72,7 @@ func (h *HiClient) SendMessage(
) (*database.Event, error) { ) (*database.Event, error) {
var content event.MessageEventContent var content event.MessageEventContent
msgType := event.MsgText msgType := event.MsgText
origText := text
if strings.HasPrefix(text, "/me ") { if strings.HasPrefix(text, "/me ") {
msgType = event.MsgEmote msgType = event.MsgEmote
text = strings.TrimPrefix(text, "/me ") text = strings.TrimPrefix(text, "/me ")
@ -102,7 +131,7 @@ func (h *HiClient) SendMessage(
content.RelatesTo = relatesTo 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 { 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, roomID id.RoomID,
evtType event.Type, evtType event.Type,
content any, 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) { ) (*database.Event, error) {
room, err := h.DB.Room.Get(ctx, roomID) room, err := h.DB.Room.Get(ctx, roomID)
if err != nil { if err != nil {
@ -193,6 +232,9 @@ func (h *HiClient) Send(
var inlineImages []id.ContentURI var inlineImages []id.ContentURI
mautrixEvt := dbEvt.AsRawMautrix() mautrixEvt := dbEvt.AsRawMautrix()
dbEvt.LocalContent, inlineImages = h.calculateLocalContent(ctx, dbEvt, mautrixEvt) dbEvt.LocalContent, inlineImages = h.calculateLocalContent(ctx, dbEvt, mautrixEvt)
if overrideEditSource != "" {
dbEvt.LocalContent.EditSource = overrideEditSource
}
_, err = h.DB.Event.Insert(ctx, dbEvt) _, err = h.DB.Event.Insert(ctx, dbEvt)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to insert event into database: %w", err) return nil, fmt.Errorf("failed to insert event into database: %w", err)

View file

@ -25,6 +25,7 @@ import (
"maunium.net/go/mautrix/crypto" "maunium.net/go/mautrix/crypto"
"maunium.net/go/mautrix/crypto/olm" "maunium.net/go/mautrix/crypto/olm"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/pushrules" "maunium.net/go/mautrix/pushrules"
@ -366,7 +367,7 @@ func (h *HiClient) calculateLocalContent(ctx context.Context, dbEvt *database.Ev
content = content.NewContent content = content.NewContent
} }
if content != nil { if content != nil {
var sanitizedHTML string var sanitizedHTML, editSource string
var wasPlaintext, hasMath, bigEmoji bool var wasPlaintext, hasMath, bigEmoji bool
var inlineImages []id.ContentURI var inlineImages []id.ContentURI
if content.Format == event.FormatHTML && content.FormattedBody != "" { if content.Format == event.FormatHTML && content.FormattedBody != "" {
@ -384,6 +385,16 @@ func (h *HiClient) calculateLocalContent(ctx context.Context, dbEvt *database.Ev
} }
inlineImages = nil 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 { } else {
hasSpecialCharacters := false hasSpecialCharacters := false
for _, char := range content.Body { 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) { } else if len(content.Body) < 100 && emojirunes.IsOnlyEmojis(content.Body) {
bigEmoji = true bigEmoji = true
} }
if content.MsgType == event.MsgEmote {
editSource = "/me " + content.Body
} else if content.MsgType == event.MsgNotice {
editSource = "/notice " + content.Body
}
wasPlaintext = true wasPlaintext = true
} }
return &database.LocalContent{ return &database.LocalContent{
@ -408,6 +424,7 @@ func (h *HiClient) calculateLocalContent(ctx context.Context, dbEvt *database.Ev
WasPlaintext: wasPlaintext, WasPlaintext: wasPlaintext,
BigEmoji: bigEmoji, BigEmoji: bigEmoji,
HasMath: hasMath, HasMath: hasMath,
EditSource: editSource,
}, inlineImages }, inlineImages
} }
return nil, nil return nil, nil
@ -597,7 +614,7 @@ func (h *HiClient) processStateAndTimeline(
} }
processNewEvent := func(evt *event.Event, isTimeline, isUnread bool) (database.EventRowID, error) { processNewEvent := func(evt *event.Event, isTimeline, isUnread bool) (database.EventRowID, error) {
evt.RoomID = room.ID 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 { if err != nil {
return -1, err return -1, err
} }

View file

@ -81,6 +81,7 @@ export enum UnreadType {
export interface LocalContent { export interface LocalContent {
sanitized_html?: TrustedHTML sanitized_html?: TrustedHTML
edit_source?: string
html_version?: number html_version?: number
was_plaintext?: boolean was_plaintext?: boolean
big_emoji?: boolean big_emoji?: boolean

View file

@ -107,7 +107,9 @@ const MessageComposer = () => {
rawSetEditing(evt) rawSetEditing(evt)
setState({ setState({
media: isMedia ? evtContent as MediaMessageEventContent : null, 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, replyTo: null,
}) })
textInput.current?.focus() textInput.current?.focus()

View file

@ -105,7 +105,9 @@ function filterAndSort(
export function emojiToMarkdown(emoji: PartialEmoji): string { export function emojiToMarkdown(emoji: PartialEmoji): string {
if (emoji.u.startsWith("mxc://")) { if (emoji.u.startsWith("mxc://")) {
const title = emoji.t && emoji.t !== emoji.n ? emoji.t : `:${emoji.n}:` const title = emoji.t && emoji.t !== emoji.n ? emoji.t : `:${emoji.n}:`
return `<img data-mx-emoticon height="32" src="${emoji.u}" alt=":${emoji.n}:" title="${title}"/>` const escapedTitle = title.replaceAll(`\\`, `\\\\`).replaceAll(`"`, `\\"`)
return `![:${emoji.n}:](${emoji.u} "Emoji: ${escapedTitle}")`
//return `<img data-mx-emoticon height="32" src="${emoji.u}" alt=":${emoji.n}:" title="${title}"/>`
} }
return emoji.u return emoji.u
} }