diff --git a/go.mod b/go.mod index b7518c2..9f83398 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/yuin/goldmark v1.7.7 - go.mau.fi/util v0.8.2-0.20241027163518-38d54fc87ee3 + go.mau.fi/util v0.8.2-0.20241030110711-b3e597e16b74 go.mau.fi/zeroconfig v0.1.3 golang.org/x/crypto v0.28.0 golang.org/x/image v0.21.0 @@ -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.20241026115159-a59d4d78677f + maunium.net/go/mautrix v0.21.2-0.20241101162620-f606129e732f mvdan.cc/xurls/v2 v2.5.0 ) diff --git a/go.sum b/go.sum index 782d667..efcbede 100644 --- a/go.sum +++ b/go.sum @@ -61,8 +61,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/yuin/goldmark v1.7.7 h1:5m9rrB1sW3JUMToKFQfb+FGt1U7r57IHu5GrYrG2nqU= github.com/yuin/goldmark v1.7.7/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= -go.mau.fi/util v0.8.2-0.20241027163518-38d54fc87ee3 h1:9dDTNcVc3y9oU9bYvjpc3xsCupwGzfyYhrppaLy6l9k= -go.mau.fi/util v0.8.2-0.20241027163518-38d54fc87ee3/go.mod h1:T1u/rD2rzidVrBLyaUdPpZiJdP/rsyi+aTzn0D+Q6wc= +go.mau.fi/util v0.8.2-0.20241030110711-b3e597e16b74 h1:hzVVXFEIQWefBlokVlQ2nr7EzRnMdMLF+K+kqWsm6OE= +go.mau.fi/util v0.8.2-0.20241030110711-b3e597e16b74/go.mod h1:T1u/rD2rzidVrBLyaUdPpZiJdP/rsyi+aTzn0D+Q6wc= go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM= go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70= golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= @@ -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.20241026115159-a59d4d78677f h1:fZL9ASp9m4KaC0QUEDkv5ptPwVvRjigy9uPI6NYZAD0= -maunium.net/go/mautrix v0.21.2-0.20241026115159-a59d4d78677f/go.mod h1:sjCZR1R/3NET/WjkcXPL6WpAHlWKku9HjRsdOkbM8Qw= +maunium.net/go/mautrix v0.21.2-0.20241101162620-f606129e732f h1:4iO+tXpXS8BNZuJP17BpaPZAURknrufVgkVrFkTHv7Y= +maunium.net/go/mautrix v0.21.2-0.20241101162620-f606129e732f/go.mod h1:sjCZR1R/3NET/WjkcXPL6WpAHlWKku9HjRsdOkbM8Qw= 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 6618dc3..f0935d7 100644 --- a/pkg/hicli/database/event.go +++ b/pkg/hicli/database/event.go @@ -292,6 +292,7 @@ type LocalContent struct { HTMLVersion int `json:"html_version,omitempty"` WasPlaintext bool `json:"was_plaintext,omitempty"` BigEmoji bool `json:"big_emoji,omitempty"` + HasMath bool `json:"has_math,omitempty"` } type Event struct { diff --git a/pkg/hicli/html.go b/pkg/hicli/html.go index ccd9b50..38cc878 100644 --- a/pkg/hicli/html.go +++ b/pkg/hicli/html.go @@ -79,6 +79,15 @@ func calculateMediaSize(widthInt, heightInt int) (width, height float64, ok bool return } +func getAttribute(attrs []html.Attribute, key string) (string, bool) { + for _, attr := range attrs { + if attr.Key == key { + return attr.Val, true + } + } + return "", false +} + func parseImgAttributes(attrs []html.Attribute) (src, alt, title string, isCustomEmoji bool, width, height int) { for _, attr := range attrs { switch attr.Key { @@ -99,7 +108,7 @@ func parseImgAttributes(attrs []html.Attribute) (src, alt, title string, isCusto return } -func parseSpanAttributes(attrs []html.Attribute) (bgColor, textColor, spoiler, maths string, isSpoiler bool) { +func parseSpanAttributes(attrs []html.Attribute) (bgColor, textColor, spoiler string, isSpoiler bool) { for _, attr := range attrs { switch attr.Key { case "data-mx-bg-color": @@ -113,8 +122,6 @@ func parseSpanAttributes(attrs []html.Attribute) (bgColor, textColor, spoiler, m case "data-mx-spoiler": spoiler = attr.Val isSpoiler = true - case "data-mx-maths": - maths = attr.Val } } return @@ -430,7 +437,7 @@ func writeImg(w *strings.Builder, attr []html.Attribute) id.ContentURI { } func writeSpan(w *strings.Builder, attr []html.Attribute) { - bgColor, textColor, spoiler, _, isSpoiler := parseSpanAttributes(attr) + bgColor, textColor, spoiler, isSpoiler := parseSpanAttributes(attr) if isSpoiler && spoiler != "" { w.WriteString(``) w.WriteString(spoiler) @@ -521,10 +528,6 @@ Loop: if !tagIsAllowed(token.DataAtom) { continue } - tagIsSelfClosing := isSelfClosing(token.DataAtom) - if token.Type == html.SelfClosingTagToken && !tagIsSelfClosing { - continue - } switch token.DataAtom { case atom.Pre: codeBlock = &strings.Builder{} @@ -539,8 +542,22 @@ Loop: if !mxc.IsEmpty() { inlineImages = append(inlineImages, mxc) } + case atom.Div: + math, ok := getAttribute(token.Attr, "data-mx-maths") + if ok { + built.WriteString(`') - if !tagIsSelfClosing { + if !isSelfClosing(token.DataAtom) && token.Type != html.SelfClosingTagToken { ts.push(token.DataAtom) } case html.EndTagToken: tagName, _ := tz.TagName() tag := atom.Lookup(tagName) + if !tagIsAllowed(tag) { + continue + } if tag == atom.Pre && codeBlock != nil { writeCodeBlock(&built, codeBlockLanguage, codeBlock) codeBlockLanguage = "" codeBlock = nil - } else if tagIsAllowed(tag) && ts.pop(tag) { + } else if ts.pop(tag) { // TODO instead of only popping when the last tag in the stack matches, this should go through the stack // and close all tags until it finds the matching tag if tag == atom.Font { @@ -571,6 +594,8 @@ Loop: built.Write(tagName) built.WriteByte('>') } + } else if (tag == atom.Span || tag == atom.Div) && ts.pop(atom.Math) { + built.WriteString("") } case html.TextToken: if codeBlock != nil { diff --git a/pkg/hicli/sync.go b/pkg/hicli/sync.go index b8cf259..5bc593f 100644 --- a/pkg/hicli/sync.go +++ b/pkg/hicli/sync.go @@ -367,7 +367,7 @@ func (h *HiClient) calculateLocalContent(ctx context.Context, dbEvt *database.Ev } if content != nil { var sanitizedHTML string - var wasPlaintext, bigEmoji bool + var wasPlaintext, hasMath, bigEmoji bool var inlineImages []id.ContentURI if content.Format == event.FormatHTML && content.FormattedBody != "" { var err error @@ -377,6 +377,7 @@ func (h *HiClient) calculateLocalContent(ctx context.Context, dbEvt *database.Ev Stringer("event_id", dbEvt.ID). Msg("Failed to sanitize HTML") } + hasMath = strings.Contains(sanitizedHTML, " 0 && dbEvt.RowID != 0 { for _, uri := range inlineImages { h.addMediaCache(ctx, dbEvt.RowID, uri.CUString(), nil, nil, "") @@ -406,12 +407,13 @@ func (h *HiClient) calculateLocalContent(ctx context.Context, dbEvt *database.Ev HTMLVersion: CurrentHTMLSanitizerVersion, WasPlaintext: wasPlaintext, BigEmoji: bigEmoji, + HasMath: hasMath, }, inlineImages } return nil, nil } -const CurrentHTMLSanitizerVersion = 4 +const CurrentHTMLSanitizerVersion = 6 func (h *HiClient) ReprocessExistingEvent(ctx context.Context, evt *database.Event) { if (evt.Type != event.EventMessage.Type && evt.DecryptedType != event.EventMessage.Type) || diff --git a/web/package-lock.json b/web/package-lock.json index 28a347e..2b8015b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@types/react": "npm:types-react@rc", "@types/react-dom": "npm:types-react-dom@rc", + "katex": "^0.16.11", "react": "^19.0.0-rc-0751fac7-20241002", "react-dom": "^19.0.0-rc-0751fac7-20241002", "react-spinners": "^0.14.1", @@ -18,6 +19,7 @@ }, "devDependencies": { "@eslint/js": "^9.11.1", + "@types/katex": "^0.16.7", "@types/sanitize-html": "^2.13.0", "@vitejs/plugin-react-swc": "^3.5.0", "eslint": "^9.11.1", @@ -1678,6 +1680,13 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/katex": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.7.tgz", + "integrity": "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "name": "types-react", "version": "19.0.0-rc.1", @@ -2285,6 +2294,15 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3782,6 +3800,22 @@ "json5": "lib/cli.js" } }, + "node_modules/katex": { + "version": "0.16.11", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.11.tgz", + "integrity": "sha512-RQrI8rlHY92OLf3rho/Ts8i/XvjgguEjOkO1BEXcU3N8BqPpSzBNwV/G0Ukr+P/l3ivvJUE/Fa/CwbS6HesGNQ==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", diff --git a/web/package.json b/web/package.json index d4af94d..7991a34 100644 --- a/web/package.json +++ b/web/package.json @@ -13,6 +13,7 @@ "dependencies": { "@types/react": "npm:types-react@rc", "@types/react-dom": "npm:types-react-dom@rc", + "katex": "^0.16.11", "react": "^19.0.0-rc-0751fac7-20241002", "react-dom": "^19.0.0-rc-0751fac7-20241002", "react-spinners": "^0.14.1", @@ -20,6 +21,7 @@ }, "devDependencies": { "@eslint/js": "^9.11.1", + "@types/katex": "^0.16.7", "@types/sanitize-html": "^2.13.0", "@vitejs/plugin-react-swc": "^3.5.0", "eslint": "^9.11.1", diff --git a/web/src/api/types/hitypes.ts b/web/src/api/types/hitypes.ts index b90bd89..fab9136 100644 --- a/web/src/api/types/hitypes.ts +++ b/web/src/api/types/hitypes.ts @@ -84,6 +84,7 @@ export interface LocalContent { html_version?: number was_plaintext?: boolean big_emoji?: boolean + has_math?: boolean } export interface BaseDBEvent { diff --git a/web/src/ui/timeline/content/TextMessageBody.tsx b/web/src/ui/timeline/content/TextMessageBody.tsx index 91a3c2f..9b4c2db 100644 --- a/web/src/ui/timeline/content/TextMessageBody.tsx +++ b/web/src/ui/timeline/content/TextMessageBody.tsx @@ -32,6 +32,19 @@ const onClickHTML = (evt: React.MouseEvent) => { } } +let mathImported = false + +function importMath() { + if (mathImported) { + return + } + mathImported = true + import("./math.ts").then( + () => console.info("Imported math"), + err => console.error("Failed to import math", err), + ) +} + const TextMessageBody = ({ event, sender }: EventContentProps) => { const content = event.content as MessageEventContent const classNames = ["message-text"] @@ -48,6 +61,10 @@ const TextMessageBody = ({ event, sender }: EventContentProps) => { if (event.local_content?.was_plaintext) { classNames.push("plaintext-body") } + if (event.local_content?.has_math) { + classNames.push("math-body") + importMath() + } if (event.local_content?.sanitized_html) { classNames.push("html-body") return
. +import katex from "katex" +import katexCSS from "katex/dist/katex.min.css?inline" + +const sheet = new CSSStyleSheet() +sheet.replaceSync(katexCSS) + +class HicliMath extends HTMLElement { + static observedAttributes = ["displaymode", "latex"] + #root?: HTMLElement + #latex?: string + #displayMode?: boolean + + constructor() { + super() + } + + connectedCallback() { + const root = this.attachShadow({ mode: "open" }) + root.adoptedStyleSheets = [sheet] + // This seems to work fine + this.#root = root as unknown as HTMLElement + this.#render() + } + + attributeChangedCallback(name: string, _oldValue: string, newValue: string) { + if (name === "latex") { + this.#latex = newValue + } else if (name === "displaymode") { + this.#displayMode = newValue === "block" + } + this.#render() + } + + #render() { + if (!this.#root || !this.#latex) { + return + } + try { + katex.render(this.#latex, this.#root, { + output: "htmlAndMathml", + maxSize: 10, + displayMode: this.#displayMode, + }) + } catch (err) { + console.error("Failed to render math", this.#latex, err) + } + } +} + +customElements.define("hicli-math", HicliMath)