From ab709c1e13af690758423fba1c8952826c9aeef0 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 7 May 2026 12:20:26 -0500 Subject: [PATCH 01/11] Readd brackets to suppressed previews on edits --- .../features/room/message/MessageEditor.tsx | 48 +++++++++---------- .../room/message/hiddenLinkPreviews.test.ts | 24 ++++++++++ .../room/message/hiddenLinkPreviews.ts | 31 ++++++++++++ 3 files changed, 79 insertions(+), 24 deletions(-) create mode 100644 src/app/features/room/message/hiddenLinkPreviews.test.ts create mode 100644 src/app/features/room/message/hiddenLinkPreviews.ts diff --git a/src/app/features/room/message/MessageEditor.tsx b/src/app/features/room/message/MessageEditor.tsx index a00ece576..dc156111e 100644 --- a/src/app/features/room/message/MessageEditor.tsx +++ b/src/app/features/room/message/MessageEditor.tsx @@ -60,6 +60,7 @@ import type { Opts as LinkifyOpts } from 'linkifyjs'; import type { GetContentCallback } from '$types/matrix/room'; import { sanitizeText } from '$utils/sanitize'; import type { BundleContent } from '$components/message'; +import { readdAngleBracketsForHiddenPreviews } from './hiddenLinkPreviews'; type MessageEditorProps = { roomId: string; @@ -120,6 +121,9 @@ export const MessageEditor = as<'div', MessageEditorProps>( const bundleContent = content['com.beeper.linkpreviews'] as BundleContent[]; const markHiddenLinks = (original: string, isHTML?: boolean) => { if (!bundleContent) return original; + if (!isHTML) { + return readdAngleBracketsForHiddenPreviews(original, bundleContent); + } /* Split according to the following fule: - if its not HTML just break it by spaces, newLines, and parans - if it is HTML @@ -129,31 +133,27 @@ export const MessageEditor = as<'div', MessageEditorProps>( - then for every non portion find regular links as though it is plaintext * this is not recursive but needs flattening */ - let splitBody = original.split( - isHTML ? /(?=^.+<)|(?=)|(?=)/gi : /(?=[ \n()])/gi - ); - if (isHTML) - splitBody = splitBody - .map((item) => (item.startsWith(' acc.concat(current), []); + const splitBody = original.split(/(?=^.+<)|(?=)|(?=)/gi); let newBody = ''; - splitBody.map((s) => { - // the length is from the fact that a link is necessarily longer than 6 - if (s.length < 6 || s.startsWith('')) { - newBody += s; - return; - } - // since the way that the match works the key is at the start of the string, - // it needs to be separated such that it can be reintroduced before the < in case of regular text - // or after it in case that it is matching a tag - const strippedS = s.substring(1); - const isHidden = - (bundleContent?.length === 0 || - bundleContent.filter((b) => s.includes(b.matched_url)).length === 0) && - strippedS.match(LINKINPUTREGEX) !== null && - strippedS.startsWith('https://matrix.to/'); - newBody += `${isHidden ? (isHTML && ((s.startsWith('' : ''}`; - }); + splitBody + .map((item) => (item.startsWith(' acc.concat(current), []) + .map((s) => { + // the length is from the fact that a link is necessarily longer than 6 + if (s.length < 6 || s.startsWith('')) { + newBody += s; + return; + } + // since the way that the match works the key is at the start of the string, + // it needs to be separated such that it can be reintroduced before the < in case of regular text + // or after it in case that it is matching a tag + const strippedS = s.substring(1); + const isHidden = + (bundleContent?.length === 0 || + bundleContent.filter((b) => s.includes(b.matched_url)).length === 0) && + strippedS.match(LINKINPUTREGEX) !== null; + newBody += `${isHidden ? (isHTML && ((s.startsWith('' : ''}`; + }); return newBody; }; diff --git a/src/app/features/room/message/hiddenLinkPreviews.test.ts b/src/app/features/room/message/hiddenLinkPreviews.test.ts new file mode 100644 index 000000000..1fbe769f8 --- /dev/null +++ b/src/app/features/room/message/hiddenLinkPreviews.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { readdAngleBracketsForHiddenPreviews } from './hiddenLinkPreviews'; + +describe('readdAngleBracketsForHiddenPreviews', () => { + it('wraps URLs in angle brackets when they are not previewed', () => { + expect(readdAngleBracketsForHiddenPreviews('see https://example.org/ thanks', [])).toBe( + 'see thanks' + ); + }); + + it('does not wrap URLs that are present in link previews', () => { + expect( + readdAngleBracketsForHiddenPreviews('see https://example.org/ thanks', [ + { matched_url: 'https://example.org/' } as never, + ]) + ).toBe('see https://example.org/ thanks'); + }); + + it('does not double-wrap already bracketed URLs', () => { + expect(readdAngleBracketsForHiddenPreviews('see ', [])).toBe( + 'see ' + ); + }); +}); diff --git a/src/app/features/room/message/hiddenLinkPreviews.ts b/src/app/features/room/message/hiddenLinkPreviews.ts new file mode 100644 index 000000000..4ccae00f3 --- /dev/null +++ b/src/app/features/room/message/hiddenLinkPreviews.ts @@ -0,0 +1,31 @@ +import type { BundleContent } from '$components/message'; + +const LINK_URL = `(https?:\\/\\/.[A-Za-z0-9-._~:/?#[\\]()@!$&'*+,;%=]+)`; +const LINKINPUTREGEX = new RegExp(`\\(?(${LINK_URL})\\)?`, 'g'); + +export function readdAngleBracketsForHiddenPreviews( + body: string, + linkPreviews: BundleContent[] | undefined +): string { + if (!linkPreviews) return body; + + const previewed = new Set(linkPreviews.map((b) => b.matched_url)); + + LINKINPUTREGEX.lastIndex = 0; + return body.replace(LINKINPUTREGEX, (full, url: string, offset: number) => { + if (!url || previewed.has(url)) return full; + + // If the URL is already wrapped as , leave it alone. + const urlIndex = body.indexOf(url, offset); + if (urlIndex !== -1 && body.slice(urlIndex - 1, urlIndex + url.length + 1) === `<${url}>`) { + return full; + } + + // Keep any surrounding parens emitted by LINKINPUTREGEX. + if (full.startsWith('(') && full.endsWith(')')) return `(<${url}>)`; + if (full.startsWith('(')) return `(<${url}`; + if (full.endsWith(')')) return `<${url}>)`; + + return `<${url}>`; + }); +} From bd4b2137b19d49a37bedf955b7198b12c78d358a Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 7 May 2026 12:48:58 -0500 Subject: [PATCH 02/11] fix inline image height and add settings for capping incoming height --- .changeset/fix-inline-images-behaviors.md | 5 + .changeset/fix-markdown-link-preview.md | 5 + src/app/components/editor/output.test.ts | 30 +++++ src/app/components/message/Reply.tsx | 12 ++ .../upload-card/UploadCardRenderer.tsx | 20 +++- .../message-search/SearchResultGroup.tsx | 11 ++ src/app/features/room/RoomTimeline.tsx | 9 ++ .../features/room/message/MessageEditor.tsx | 21 +++- .../features/settings/cosmetics/Themes.tsx | 105 ++++++++++++++++++ .../features/settings/settingsLink.test.ts | 21 ++++ src/app/features/settings/settingsLink.ts | 2 + .../plugins/markdown/markdownToHtml.test.ts | 1 + src/app/plugins/markdown/markdownToHtml.ts | 21 +++- .../plugins/react-custom-html-parser.test.tsx | 33 ++++++ src/app/plugins/react-custom-html-parser.tsx | 23 ++++ src/app/state/settings.ts | 4 + 16 files changed, 316 insertions(+), 7 deletions(-) create mode 100644 .changeset/fix-inline-images-behaviors.md create mode 100644 .changeset/fix-markdown-link-preview.md create mode 100644 src/app/components/editor/output.test.ts diff --git a/.changeset/fix-inline-images-behaviors.md b/.changeset/fix-inline-images-behaviors.md new file mode 100644 index 000000000..fe17224de --- /dev/null +++ b/.changeset/fix-inline-images-behaviors.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Added a couple new settings for max incoming inline image height and default height for unspecified. diff --git a/.changeset/fix-markdown-link-preview.md b/.changeset/fix-markdown-link-preview.md new file mode 100644 index 000000000..410ee3b03 --- /dev/null +++ b/.changeset/fix-markdown-link-preview.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fixed links with suppressed previews not having the arrow brackets readded when editing a message. diff --git a/src/app/components/editor/output.test.ts b/src/app/components/editor/output.test.ts new file mode 100644 index 000000000..72b5ac033 --- /dev/null +++ b/src/app/components/editor/output.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { toMatrixCustomHTML, trimCustomHtml } from '$components/editor/output'; +import { BlockType } from '$components/editor/types'; + +describe('toMatrixCustomHTML emoticons', () => { + it('always serializes custom emoji images with height=32', () => { + const html = trimCustomHtml( + toMatrixCustomHTML( + [ + { + type: BlockType.Paragraph, + children: [ + { + type: BlockType.Emoticon, + key: 'mxc://example.org/emote', + shortcode: 'blobcat', + children: [{ text: '' }], + } as never, + ], + } as never, + ], + {} + ) + ); + + expect(html).toContain('data-mx-emoticon'); + expect(html).toContain('mxc://example.org/emote'); + expect(html).toContain('height="32"'); + }); +}); diff --git a/src/app/components/message/Reply.tsx b/src/app/components/message/Reply.tsx index 1c2fd8ddb..22366f6a6 100644 --- a/src/app/components/message/Reply.tsx +++ b/src/app/components/message/Reply.tsx @@ -26,6 +26,8 @@ import { useIgnoredUsers } from '$hooks/useIgnoredUsers'; import { nicknamesAtom } from '$state/nicknames'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { useMemberEventParser } from '$hooks/useMemberEventParser'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; import { useMentionClickHandler } from '$hooks/useMentionClickHandler'; import { useTranslation } from 'react-i18next'; @@ -139,6 +141,14 @@ export const Reply = as<'div', ReplyProps>( const nicknames = useAtomValue(nicknamesAtom); const useAuthentication = useMediaAuthentication(); const settingsLinkBaseUrl = useSettingsLinkBaseUrl(); + const [incomingInlineImagesDefaultHeight] = useSetting( + settingsAtom, + 'incomingInlineImagesDefaultHeight' + ); + const [incomingInlineImagesMaxHeight] = useSetting( + settingsAtom, + 'incomingInlineImagesMaxHeight' + ); const fallbackBody = isRedacted ? : ; @@ -190,6 +200,8 @@ export const Reply = as<'div', ReplyProps>( useAuthentication, nicknames, handleMentionClick: mentionClickHandler, + incomingInlineImagesDefaultHeight, + incomingInlineImagesMaxHeight, }); bodyJSX = parse(sanitizedHtml, parserOpts) as JSX.Element; } else if (hasPlainTextReply) { diff --git a/src/app/components/upload-card/UploadCardRenderer.tsx b/src/app/components/upload-card/UploadCardRenderer.tsx index 24166507e..633babdaa 100644 --- a/src/app/components/upload-card/UploadCardRenderer.tsx +++ b/src/app/components/upload-card/UploadCardRenderer.tsx @@ -31,6 +31,8 @@ import { roomUploadAtomFamily } from '$state/room/roomInputDrafts'; import { useObjectURL } from '$hooks/useObjectURL'; import { useMediaConfig } from '$hooks/useMediaConfig'; import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard'; import * as css from './UploadCard.css'; import { DescriptionEditor } from './UploadDescriptionEditor'; @@ -397,6 +399,11 @@ export function UploadCardRenderer({ const spoilerClickHandler = useSpoilerClickHandler(); const useAuthentication = useMediaAuthentication(); const settingsLinkBaseUrl = useSettingsLinkBaseUrl(); + const [incomingInlineImagesDefaultHeight] = useSetting( + settingsAtom, + 'incomingInlineImagesDefaultHeight' + ); + const [incomingInlineImagesMaxHeight] = useSetting(settingsAtom, 'incomingInlineImagesMaxHeight'); const htmlReactParserOptions = useMemo( () => getReactCustomHtmlParser(mx, roomId, { @@ -404,8 +411,19 @@ export function UploadCardRenderer({ linkifyOpts, useAuthentication, handleSpoilerClick: spoilerClickHandler, + incomingInlineImagesDefaultHeight, + incomingInlineImagesMaxHeight, }), - [linkifyOpts, mx, roomId, settingsLinkBaseUrl, spoilerClickHandler, useAuthentication] + [ + linkifyOpts, + mx, + roomId, + settingsLinkBaseUrl, + spoilerClickHandler, + useAuthentication, + incomingInlineImagesDefaultHeight, + incomingInlineImagesMaxHeight, + ] ); return ( ( const settingsLinkBaseUrl = useSettingsLinkBaseUrl(); const linkifyOpts = useMemo(() => ({ ...LINKIFY_OPTS }), []); const spoilerClickHandler = useSpoilerClickHandler(); + const [incomingInlineImagesDefaultHeight] = useSetting( + settingsAtom, + 'incomingInlineImagesDefaultHeight' + ); + const [incomingInlineImagesMaxHeight] = useSetting( + settingsAtom, + 'incomingInlineImagesMaxHeight' + ); const htmlReactParserOptions = useMemo( () => getReactCustomHtmlParser(mx, mEvent.getRoomId(), { @@ -380,8 +388,19 @@ export const MessageEditor = as<'div', MessageEditorProps>( linkifyOpts, useAuthentication, handleSpoilerClick: spoilerClickHandler, + incomingInlineImagesDefaultHeight, + incomingInlineImagesMaxHeight, }), - [linkifyOpts, mEvent, mx, settingsLinkBaseUrl, spoilerClickHandler, useAuthentication] + [ + linkifyOpts, + mEvent, + mx, + settingsLinkBaseUrl, + spoilerClickHandler, + useAuthentication, + incomingInlineImagesDefaultHeight, + incomingInlineImagesMaxHeight, + ] ); const getContent = (() => mEvent.getContent()) as GetContentCallback; const msgType = mEvent.getContent().msgtype; diff --git a/src/app/features/settings/cosmetics/Themes.tsx b/src/app/features/settings/cosmetics/Themes.tsx index 0fe2d716f..4ad4ea621 100644 --- a/src/app/features/settings/cosmetics/Themes.tsx +++ b/src/app/features/settings/cosmetics/Themes.tsx @@ -18,6 +18,8 @@ import { settingsAtom } from '$state/settings'; import { SequenceCardStyle } from '$features/settings/styles.css'; import { ThemeAppearanceSection } from './ThemeAppearanceSection'; +const clampIncomingInlineImageHeight = (n: number) => Math.max(1, Math.min(4096, n)); + function makeArboriumThemeOptions(kind?: 'light' | 'dark') { const themes = kind ? getArboriumThemeOptions(kind) @@ -193,6 +195,48 @@ function ThemeVisualPreferences() { const [autoplayGifs, setAutoplayGifs] = useSetting(settingsAtom, 'autoplayGifs'); const [autoplayStickers, setAutoplayStickers] = useSetting(settingsAtom, 'autoplayStickers'); const [autoplayEmojis, setAutoplayEmojis] = useSetting(settingsAtom, 'autoplayEmojis'); + const [incomingInlineImagesDefaultHeight, setIncomingInlineImagesDefaultHeight] = useSetting( + settingsAtom, + 'incomingInlineImagesDefaultHeight' + ); + const [incomingInlineImagesMaxHeight, setIncomingInlineImagesMaxHeight] = useSetting( + settingsAtom, + 'incomingInlineImagesMaxHeight' + ); + const [incomingDefaultHeightInput, setIncomingDefaultHeightInput] = useState( + incomingInlineImagesDefaultHeight.toString() + ); + const [incomingMaxHeightInput, setIncomingMaxHeightInput] = useState( + incomingInlineImagesMaxHeight.toString() + ); + + const handleIncomingDefaultHeightChange: ChangeEventHandler = (evt) => { + const val = evt.target.value; + setIncomingDefaultHeightInput(val); + const parsed = Number.parseInt(val, 10); + if (!Number.isNaN(parsed)) + setIncomingInlineImagesDefaultHeight(clampIncomingInlineImageHeight(parsed)); + }; + const handleIncomingMaxHeightChange: ChangeEventHandler = (evt) => { + const val = evt.target.value; + setIncomingMaxHeightInput(val); + const parsed = Number.parseInt(val, 10); + if (!Number.isNaN(parsed)) + setIncomingInlineImagesMaxHeight(clampIncomingInlineImageHeight(parsed)); + }; + + const onNumberInputKeyDown = + (reset: () => void): KeyboardEventHandler => + (evt) => { + if (isKeyHotkey('escape', evt)) { + evt.stopPropagation(); + reset(); + (evt.target as HTMLInputElement).blur(); + } + if (isKeyHotkey('enter', evt)) { + (evt.target as HTMLInputElement).blur(); + } + }; return ( @@ -266,6 +310,67 @@ function ThemeVisualPreferences() { after={} /> + + + + setIncomingDefaultHeightInput(incomingInlineImagesDefaultHeight.toString()) + )} + after={px} + outlined + /> + } + /> + + + + + setIncomingMaxHeightInput(incomingInlineImagesMaxHeight.toString()) + )} + after={px} + outlined + /> + } + /> + ); } diff --git a/src/app/features/settings/settingsLink.test.ts b/src/app/features/settings/settingsLink.test.ts index 37b1e9921..2b2998d98 100644 --- a/src/app/features/settings/settingsLink.test.ts +++ b/src/app/features/settings/settingsLink.test.ts @@ -44,6 +44,27 @@ describe('settingsLink', () => { expect(parseSettingsLink('https://app.example', 'https://app.example/home/')).toBeUndefined(); }); + it('accepts the incoming inline image height focus ids', () => { + expect( + parseSettingsLink( + 'https://app.example', + 'https://app.example/settings/appearance?focus=incoming-inline-images-default-height' + ) + ).toEqual({ + section: 'appearance', + focus: 'incoming-inline-images-default-height', + }); + expect( + parseSettingsLink( + 'https://app.example', + 'https://app.example/settings/appearance?focus=incoming-inline-images-max-height' + ) + ).toEqual({ + section: 'appearance', + focus: 'incoming-inline-images-max-height', + }); + }); + it('parses cross-base settings links only when the explicit action marker is present', () => { expect( parseSettingsLink( diff --git a/src/app/features/settings/settingsLink.ts b/src/app/features/settings/settingsLink.ts index 3ff9bae41..2e4cd0ab2 100644 --- a/src/app/features/settings/settingsLink.ts +++ b/src/app/features/settings/settingsLink.ts @@ -106,6 +106,8 @@ const settingsLinkFocusIdsBySection: Record { const result = markdownToHtml(html); expect(result).toContain('mxc://example.org/emote'); expect(result).toContain('data-mx-emoticon'); + expect(result).toContain('height="32"'); }); it('rejects img tags with non-mxc protocols', () => { diff --git a/src/app/plugins/markdown/markdownToHtml.ts b/src/app/plugins/markdown/markdownToHtml.ts index f0b1397fe..78356271f 100644 --- a/src/app/plugins/markdown/markdownToHtml.ts +++ b/src/app/plugins/markdown/markdownToHtml.ts @@ -139,8 +139,9 @@ export function markdownToHtml(markdown: string): string { 'type', 'open', ], - // Allow safe rel attributes for links - ADD_ATTR: ['target', 'rel'], + // Ensure these safe attrs survive sanitization even when the input HTML + // originates from markdown-embedded tags (e.g. custom emoji ). + ADD_ATTR: ['target', 'rel', 'height', 'width'], // Force all links to have safe rel attribute FORCE_BODY: false, ALLOWED_URI_REGEXP: /^(?:https?|ftp|mailto|magnet|mxc):/i, @@ -148,8 +149,18 @@ export function markdownToHtml(markdown: string): string { DOMPurify.removeHook('afterSanitizeAttributes'); - return unmaskMathCodeDollarPlaceholders(sanitized).replace( - /
  • (

    <\/p>)?<\/li>/gi, - '


  • ' + const unmasked = unmaskMathCodeDollarPlaceholders(sanitized); + + // DOMPurify's Node/JSdom build can drop size attributes even when allowlisted. + // For Matrix custom emojis, always emit a stable height so outgoing messages have + // consistent layout across clients. + const restoredMxEmoticonHeight = unmasked.replace( + /]*\bdata-mx-emoticon\b[^>]*)>/gi, + (full, attrs: string) => { + if (/\bheight\s*=/i.test(attrs)) return full; + return ``; + } ); + + return restoredMxEmoticonHeight.replace(/
  • (

    <\/p>)?<\/li>/gi, '


  • '); } diff --git a/src/app/plugins/react-custom-html-parser.test.tsx b/src/app/plugins/react-custom-html-parser.test.tsx index 0ff56dacc..13e8c3088 100644 --- a/src/app/plugins/react-custom-html-parser.test.tsx +++ b/src/app/plugins/react-custom-html-parser.test.tsx @@ -128,6 +128,39 @@ describe('getReactCustomHtmlParser code blocks', () => { }); describe('react custom html parser', () => { + it('defaults custom emoji img height to 32 when missing', () => { + const { container } = renderParsedHtml( + 'blobcat', + { + sanitize: false, + mx: createMatrixClient({ + mxcUrlToHttp: () => 'https://cdn.example/emote.png', + }), + } + ); + + const img = container.querySelector('img'); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute('height', '32'); + }); + + it('clamps incoming inline image height to the configured max', () => { + const { container } = renderParsedHtml( + 'blobcat', + { + sanitize: false, + mx: createMatrixClient({ + mxcUrlToHttp: () => 'https://cdn.example/emote.png', + }), + } + ); + + const img = container.querySelector('img'); + expect(img).toBeInTheDocument(); + // Default max is 64 unless overridden by settings. + expect(img).toHaveAttribute('height', '64'); + }); + it('renders same-origin raw settings links as mention-style chips through the factory link render path', () => { const renderLink = factoryRenderLinkifyWithMention( settingsLinkBaseUrl, diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx index d07c7173d..951f9d033 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -516,6 +516,8 @@ export const getReactCustomHtmlParser = ( useAuthentication?: boolean; nicknames?: Nicknames; autoplayEmojis?: boolean; + incomingInlineImagesDefaultHeight?: number; + incomingInlineImagesMaxHeight?: number; replaceTextNode?: ( text: string, renderText: (text: string, key?: string) => JSX.Element @@ -524,6 +526,20 @@ export const getReactCustomHtmlParser = ( ): HTMLReactParserOptions => { const { replaceTextNode } = params; + const defaultIncomingImgHeight = params.incomingInlineImagesDefaultHeight ?? 32; + const maxIncomingImgHeight = params.incomingInlineImagesMaxHeight ?? 64; + + const normalizeIncomingImgHeight = (raw: unknown): number => { + const parsed = + typeof raw === 'number' ? raw : typeof raw === 'string' ? Number.parseInt(raw, 10) : NaN; + const fallback = defaultIncomingImgHeight; + const safe = Number.isFinite(parsed) ? parsed : fallback; + // Clamp to sane bounds first, then apply the user max. + const bounded = Math.max(1, Math.min(4096, Math.round(safe))); + const max = Math.max(1, Math.min(4096, Math.round(maxIncomingImgHeight))); + return Math.min(bounded, max); + }; + const decorateText = (text: string) => { let jsx = scaleSystemEmoji(text); @@ -807,6 +823,8 @@ export const getReactCustomHtmlParser = ( ); } + const height = normalizeIncomingImgHeight(props.height); + const siblingCount = domNode.parent?.children.length ?? 0; // seperate style for bundled emojis @@ -821,6 +839,7 @@ export const getReactCustomHtmlParser = ( {...props} src={htmlSrc} className={css.EmoticonImg} + height={height} style={{ verticalAlign: 'middle' }} fallback={ @@ -834,6 +853,7 @@ export const getReactCustomHtmlParser = ( {...props} src={htmlSrc} className={css.EmoticonImg} + height={height} style={{ verticalAlign: 'middle' }} fallback={ @@ -857,6 +877,7 @@ export const getReactCustomHtmlParser = ( {...props} src={htmlSrc} className={css.EmoticonImg} + height={height} fallback={ {props.alt || props.title || '?'} @@ -869,6 +890,7 @@ export const getReactCustomHtmlParser = ( {...props} src={htmlSrc} className={css.EmoticonImg} + height={height} fallback={ {props.alt || props.title || '?'} } @@ -893,6 +915,7 @@ export const getReactCustomHtmlParser = ( {...props} className={css.Img} src={htmlSrc} + height={normalizeIncomingImgHeight(props.height)} fallback={ {props.alt || '[media]'} diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 26985c66c..808fd36c6 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -139,6 +139,8 @@ export interface Settings { autoplayGifs: boolean; autoplayStickers: boolean; autoplayEmojis: boolean; + incomingInlineImagesDefaultHeight: number; + incomingInlineImagesMaxHeight: number; saveStickerEmojiBandwidth: boolean; subspaceHierarchyLimit: number; alwaysShowCallButton: boolean; @@ -258,6 +260,8 @@ export const defaultSettings: Settings = { autoplayGifs: true, autoplayStickers: true, autoplayEmojis: true, + incomingInlineImagesDefaultHeight: 32, + incomingInlineImagesMaxHeight: 64, saveStickerEmojiBandwidth: false, subspaceHierarchyLimit: 3, alwaysShowCallButton: false, From df440a1ac5b39e4f06b4bfa78c0822480644577d Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 7 May 2026 12:54:23 -0500 Subject: [PATCH 03/11] fix escape characters for arrow brackets --- .../features/room/message/MessageEditor.tsx | 11 ++++++-- .../room/message/hiddenLinkPreviews.test.ts | 26 +++++++++++++++++++ .../room/message/hiddenLinkPreviews.ts | 18 +++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/app/features/room/message/MessageEditor.tsx b/src/app/features/room/message/MessageEditor.tsx index 5888154b2..0dd5f13e5 100644 --- a/src/app/features/room/message/MessageEditor.tsx +++ b/src/app/features/room/message/MessageEditor.tsx @@ -60,7 +60,10 @@ import type { Opts as LinkifyOpts } from 'linkifyjs'; import type { GetContentCallback } from '$types/matrix/room'; import { sanitizeText } from '$utils/sanitize'; import type { BundleContent } from '$components/message'; -import { readdAngleBracketsForHiddenPreviews } from './hiddenLinkPreviews'; +import { + readdAngleBracketsForHiddenPreviews, + stripMarkdownEscapesForHiddenPreviews, +} from './hiddenLinkPreviews'; type MessageEditorProps = { roomId: string; @@ -351,7 +354,11 @@ export const MessageEditor = as<'div', MessageEditorProps>( const [body, customHtml] = getPrevBodyAndFormattedBody(); const initialValue = plainToEditorInput( - customHtml ? htmlToMarkdown(customHtml) : typeof body === 'string' ? body : '' + customHtml + ? stripMarkdownEscapesForHiddenPreviews(htmlToMarkdown(customHtml)) + : typeof body === 'string' + ? body + : '' ); Transforms.select(editor, { diff --git a/src/app/features/room/message/hiddenLinkPreviews.test.ts b/src/app/features/room/message/hiddenLinkPreviews.test.ts index 1fbe769f8..b5cfd8167 100644 --- a/src/app/features/room/message/hiddenLinkPreviews.test.ts +++ b/src/app/features/room/message/hiddenLinkPreviews.test.ts @@ -1,3 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { stripMarkdownEscapesForHiddenPreviews } from './hiddenLinkPreviews'; + +describe('stripMarkdownEscapesForHiddenPreviews', () => { + it('removes backslashes around suppressor wrappers', () => { + expect( + stripMarkdownEscapesForHiddenPreviews(String.raw`hello \ world`) + ).toBe('hello world'); + }); + + it('handles paren-adjacent variants produced by link matching', () => { + expect(stripMarkdownEscapesForHiddenPreviews(String.raw`(\)`)).toBe( + '()' + ); + expect(stripMarkdownEscapesForHiddenPreviews(String.raw`(\) and more`)).toBe( + '() and more' + ); + }); + + it('does not touch unrelated markdown escapes', () => { + expect(stripMarkdownEscapesForHiddenPreviews(String.raw`keep \*this\* and \`)).toBe( + String.raw`keep \*this\* and \` + ); + }); +}); + import { describe, expect, it } from 'vitest'; import { readdAngleBracketsForHiddenPreviews } from './hiddenLinkPreviews'; diff --git a/src/app/features/room/message/hiddenLinkPreviews.ts b/src/app/features/room/message/hiddenLinkPreviews.ts index 4ccae00f3..7ca3111dc 100644 --- a/src/app/features/room/message/hiddenLinkPreviews.ts +++ b/src/app/features/room/message/hiddenLinkPreviews.ts @@ -3,6 +3,24 @@ import type { BundleContent } from '$components/message'; const LINK_URL = `(https?:\\/\\/.[A-Za-z0-9-._~:/?#[\\]()@!$&'*+,;%=]+)`; const LINKINPUTREGEX = new RegExp(`\\(?(${LINK_URL})\\)?`, 'g'); +/** + * `htmlToMarkdown()` escapes `<` and `>` into `\<` and `\>` in text nodes. + * + * We deliberately inject angle brackets around URLs to suppress link previews. Those backslashes + * are correct internally for markdown escaping, but should not be shown to the user when editing. + * + * This helper removes *only* the backslashes that wrap our `` suppressor pattern. + */ +export function stripMarkdownEscapesForHiddenPreviews(markdown: string): string { + // Handle: \ + // Also handle common surrounding parens: (\), (\) + const WRAPPED = new RegExp(String.raw`\\<(${LINK_URL})\\>`, 'g'); + const OPEN_ONLY = new RegExp(String.raw`\\<(${LINK_URL})`, 'g'); + const CLOSE_ONLY = new RegExp(String.raw`(${LINK_URL})\\>`, 'g'); + + return markdown.replace(WRAPPED, '<$1>').replace(OPEN_ONLY, '<$1').replace(CLOSE_ONLY, '$1>'); +} + export function readdAngleBracketsForHiddenPreviews( body: string, linkPreviews: BundleContent[] | undefined From 48a9c5c7d0ed74b2f1449ef9affb230bad301e8f Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 7 May 2026 13:08:32 -0500 Subject: [PATCH 04/11] angle bracket edge cases, hopefully --- .../features/settings/cosmetics/Themes.tsx | 4 +- .../plugins/markdown/htmlToMarkdown.test.ts | 5 ++ src/app/plugins/markdown/htmlToMarkdown.ts | 46 +++++++++++++++++-- 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/app/features/settings/cosmetics/Themes.tsx b/src/app/features/settings/cosmetics/Themes.tsx index 4ad4ea621..fd732b029 100644 --- a/src/app/features/settings/cosmetics/Themes.tsx +++ b/src/app/features/settings/cosmetics/Themes.tsx @@ -315,7 +315,7 @@ function ThemeVisualPreferences() { { ); }); + it('converts hidden-preview wrapped links to markdown with ', () => { + const html = '

    <https://example.org/>

    '; + expect(htmlToMarkdown(html)).toBe('[https://example.org/]()'); + }); + it('converts spoiler spans', () => { expect(htmlToMarkdown('hidden')).toContain('||hidden||'); }); diff --git a/src/app/plugins/markdown/htmlToMarkdown.ts b/src/app/plugins/markdown/htmlToMarkdown.ts index 94c7381bf..b43811452 100644 --- a/src/app/plugins/markdown/htmlToMarkdown.ts +++ b/src/app/plugins/markdown/htmlToMarkdown.ts @@ -176,7 +176,45 @@ function processInlineElements( listDepth: number = 0, insideCode: boolean = false ): string { - return node.children.map((c) => processNode(c, listDepth, insideCode)).join(''); + return processChildren(node.children, listDepth, insideCode); +} + +function processChildren( + children: ChildNode[], + listDepth: number = 0, + insideCode: boolean = false +): string { + const out: string[] = []; + + for (let i = 0; i < children.length; i += 1) { + const cur = children[i]; + const next = children[i + 1]; + const next2 = children[i + 2]; + + if ( + cur && + next && + next2 && + isText(cur) && + cur.data === '<' && + isTag(next) && + next.name.toLowerCase() === 'a' && + isText(next2) && + next2.data === '>' + ) { + const href = (next as Element).attribs.href ?? ''; + const content = (next as Element).children + .map((c) => processNode(c, listDepth, insideCode)) + .join(''); + out.push(`[${content}](<${href}>)`); + i += 2; + continue; + } + + out.push(processNode(cur, listDepth, insideCode)); + } + + return out.join(''); } function processInlineWrapper( @@ -185,7 +223,7 @@ function processInlineWrapper( listDepth: number = 0, insideCode: boolean = false ): string { - const content = node.children.map((c) => processNode(c, listDepth, insideCode)).join(''); + const content = processChildren(node.children, listDepth, insideCode); return `${marker}${content}${marker}`; } @@ -222,7 +260,7 @@ function processHeading( insideCode: boolean = false ): string { const level = tag.charAt(1); - const content = node.children.map((c) => processNode(c, listDepth, insideCode)).join(''); + const content = processChildren(node.children, listDepth, insideCode); return `${'#'.repeat(parseInt(level, 10))} ${content}\n`; } @@ -231,7 +269,7 @@ function processParagraph( listDepth: number = 0, insideCode: boolean = false ): string { - const content = node.children.map((c) => processNode(c, listDepth, insideCode)).join(''); + const content = processChildren(node.children, listDepth, insideCode); return `${content}\n`; } From 1bd6b273e7aad01856a22a0a7176a02402cec3c4 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 7 May 2026 13:47:01 -0500 Subject: [PATCH 05/11] fix tests --- src/app/features/room/message/hiddenLinkPreviews.test.ts | 8 ++++---- src/app/features/settings/cosmetics/Themes.test.tsx | 4 ++++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/app/features/room/message/hiddenLinkPreviews.test.ts b/src/app/features/room/message/hiddenLinkPreviews.test.ts index b5cfd8167..60ff23d0d 100644 --- a/src/app/features/room/message/hiddenLinkPreviews.test.ts +++ b/src/app/features/room/message/hiddenLinkPreviews.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { stripMarkdownEscapesForHiddenPreviews } from './hiddenLinkPreviews'; +import { + readdAngleBracketsForHiddenPreviews, + stripMarkdownEscapesForHiddenPreviews, +} from './hiddenLinkPreviews'; describe('stripMarkdownEscapesForHiddenPreviews', () => { it('removes backslashes around suppressor wrappers', () => { @@ -24,9 +27,6 @@ describe('stripMarkdownEscapesForHiddenPreviews', () => { }); }); -import { describe, expect, it } from 'vitest'; -import { readdAngleBracketsForHiddenPreviews } from './hiddenLinkPreviews'; - describe('readdAngleBracketsForHiddenPreviews', () => { it('wraps URLs in angle brackets when they are not previewed', () => { expect(readdAngleBracketsForHiddenPreviews('see https://example.org/ thanks', [])).toBe( diff --git a/src/app/features/settings/cosmetics/Themes.test.tsx b/src/app/features/settings/cosmetics/Themes.test.tsx index 769cc503c..4dee58361 100644 --- a/src/app/features/settings/cosmetics/Themes.test.tsx +++ b/src/app/features/settings/cosmetics/Themes.test.tsx @@ -25,6 +25,8 @@ type SettingsShape = { autoplayGifs: boolean; autoplayStickers: boolean; autoplayEmojis: boolean; + incomingInlineImagesDefaultHeight: number; + incomingInlineImagesMaxHeight: number; twitterEmoji: boolean; showEasterEggs: boolean; subspaceHierarchyLimit: number; @@ -90,6 +92,8 @@ beforeEach(() => { autoplayGifs: true, autoplayStickers: true, autoplayEmojis: true, + incomingInlineImagesDefaultHeight: 32, + incomingInlineImagesMaxHeight: 64, twitterEmoji: true, showEasterEggs: true, subspaceHierarchyLimit: 3, From c86f8b6e8e38fb9e1496d95d9bc83957d40f7694 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 7 May 2026 14:41:26 -0500 Subject: [PATCH 06/11] unify the two read receipt boxes and fix the sizing inconsistency --- .changeset/fix-read-reciept-size.md | 5 + .../event-readers/EventReaders.css.ts | 3 + .../components/event-readers/EventReaders.tsx | 13 +- .../message/modals/GlobalModalManager.tsx | 2 +- src/app/features/room/RoomViewFollowing.tsx | 171 ++++++++++++++---- 5 files changed, 153 insertions(+), 41 deletions(-) create mode 100644 .changeset/fix-read-reciept-size.md diff --git a/.changeset/fix-read-reciept-size.md b/.changeset/fix-read-reciept-size.md new file mode 100644 index 000000000..54f734848 --- /dev/null +++ b/.changeset/fix-read-reciept-size.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix the inconsistent sizing for the read reciept dialog boxes. diff --git a/src/app/components/event-readers/EventReaders.css.ts b/src/app/components/event-readers/EventReaders.css.ts index 36f47b56e..fa56c221b 100644 --- a/src/app/components/event-readers/EventReaders.css.ts +++ b/src/app/components/event-readers/EventReaders.css.ts @@ -5,6 +5,7 @@ export const EventReaders = style([ DefaultReset, { height: '100%', + width: '280px', }, ]); @@ -18,4 +19,6 @@ export const Header = style({ export const Content = style({ paddingLeft: config.space.S200, paddingBottom: config.space.S400, + width: '100%', + minWidth: 0, }); diff --git a/src/app/components/event-readers/EventReaders.tsx b/src/app/components/event-readers/EventReaders.tsx index c253be54e..88f7eac57 100644 --- a/src/app/components/event-readers/EventReaders.tsx +++ b/src/app/components/event-readers/EventReaders.tsx @@ -58,9 +58,14 @@ export const EventReaders = as<'div', EventReadersProps>( - - - + + + {latestEventReaders.map((readerId) => { const name = getName(readerId); const avatarMxcUrl = room.getMember(readerId)?.getMxcAvatarUrl(); @@ -79,7 +84,7 @@ export const EventReaders = as<'div', EventReadersProps>( return ( { openProfile( diff --git a/src/app/components/message/modals/GlobalModalManager.tsx b/src/app/components/message/modals/GlobalModalManager.tsx index d422d6e60..ddeaf3a03 100644 --- a/src/app/components/message/modals/GlobalModalManager.tsx +++ b/src/app/components/message/modals/GlobalModalManager.tsx @@ -75,7 +75,7 @@ export function GlobalModalManager() { )} {modal.type === ModalType.ReadReceipts && ( - + ( ({ className, room, threadEventId, participantIds, ...props }, ref) => { const mx = useMatrixClient(); - const [open, setOpen] = useState(false); + const [, setModal] = useAtom(modalAtom); const latestEvent = useRoomLatestRenderedEvent(room); const resolvedEventId = threadEventId ?? latestEvent?.getId(); const latestEventReaders = useRoomEventReaders(room, resolvedEventId); @@ -54,24 +41,136 @@ export const RoomViewFollowing = as<'div', RoomViewFollowingProps>( const eventId = resolvedEventId; return ( - <> - {eventId && ( - }> - - setOpen(false), - clickOutsideDeactivates: true, - escapeDeactivates: stopPropagation, - }} - > - - setOpen(false)} /> - - - - + 0 ? 'button' : 'div'} + onClick={ + names.length > 0 + ? () => { + if (eventId) { + setModal({ type: ModalType.ReadReceipts, room, eventId }); + } + } + : undefined + } + className={classNames(css.RoomViewFollowing({ clickable: names.length > 0 }), className)} + alignItems="Center" + justifyContent="End" + gap="200" + {...props} + ref={ref} + > + {names.length > 0 && ( + <> + + + {names.length === 1 && ( + <> + {names[0]} + + {' is following the conversation.'} + + + )} + {names.length === 2 && ( + <> + {names[0]} + + {' and '} + + {names[1]} + + {' are following the conversation.'} + + + )} + {names.length === 3 && ( + <> + {names[0]} + + {', '} + + {names[1]} + + {' and '} + + {names[2]} + + {' are following the conversation.'} + + + )} + {names.length > 3 && ( + <> + {names[0]} + + {', '} + + {names[1]} + + {', '} + + {names[2]} + + {' and '} + + {names.length - 3} others + + {' are following the conversation.'} + + + )} + + )} 0 ? 'button' : 'div'} From ba9e8d055ef5b102351bf205d0c477f9fbc3e8ee Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 7 May 2026 14:44:40 -0500 Subject: [PATCH 07/11] forgot to commit a file whoops --- src/app/features/room/RoomViewFollowing.tsx | 126 +------------------- src/app/plugins/markdown/htmlToMarkdown.ts | 6 +- 2 files changed, 3 insertions(+), 129 deletions(-) diff --git a/src/app/features/room/RoomViewFollowing.tsx b/src/app/features/room/RoomViewFollowing.tsx index 35a61b882..979ee6507 100644 --- a/src/app/features/room/RoomViewFollowing.tsx +++ b/src/app/features/room/RoomViewFollowing.tsx @@ -172,131 +172,7 @@ export const RoomViewFollowing = as<'div', RoomViewFollowingProps>( )} - 0 ? 'button' : 'div'} - onClick={names.length > 0 ? () => setOpen(true) : undefined} - className={classNames(css.RoomViewFollowing({ clickable: names.length > 0 }), className)} - alignItems="Center" - justifyContent="End" - gap="200" - {...props} - ref={ref} - > - {names.length > 0 && ( - <> - - - {names.length === 1 && ( - <> - {names[0]} - - {' is following the conversation.'} - - - )} - {names.length === 2 && ( - <> - {names[0]} - - {' and '} - - {names[1]} - - {' are following the conversation.'} - - - )} - {names.length === 3 && ( - <> - {names[0]} - - {', '} - - {names[1]} - - {' and '} - - {names[2]} - - {' are following the conversation.'} - - - )} - {names.length > 3 && ( - <> - {names[0]} - - {', '} - - {names[1]} - - {', '} - - {names[2]} - - {' and '} - - {names.length - 3} others - - {' are following the conversation.'} - - - )} - - - )} - - + ); } ); diff --git a/src/app/plugins/markdown/htmlToMarkdown.ts b/src/app/plugins/markdown/htmlToMarkdown.ts index b43811452..01fe1f8b9 100644 --- a/src/app/plugins/markdown/htmlToMarkdown.ts +++ b/src/app/plugins/markdown/htmlToMarkdown.ts @@ -202,10 +202,8 @@ function processChildren( isText(next2) && next2.data === '>' ) { - const href = (next as Element).attribs.href ?? ''; - const content = (next as Element).children - .map((c) => processNode(c, listDepth, insideCode)) - .join(''); + const href = next.attribs.href ?? ''; + const content = next.children.map((c) => processNode(c, listDepth, insideCode)).join(''); out.push(`[${content}](<${href}>)`); i += 2; continue; From 1670f36472d3239412b9d6dbe3374b1df01c5ff5 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 7 May 2026 14:46:50 -0500 Subject: [PATCH 08/11] typo --- .changeset/fix-read-reciept-size.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/fix-read-reciept-size.md b/.changeset/fix-read-reciept-size.md index 54f734848..574e88ebc 100644 --- a/.changeset/fix-read-reciept-size.md +++ b/.changeset/fix-read-reciept-size.md @@ -2,4 +2,4 @@ default: patch --- -Fix the inconsistent sizing for the read reciept dialog boxes. +Fix the inconsistent sizing for the read receipt dialog boxes. From cda61305ab2a06d5dbd6f73b6f7e443eb2417b6d Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 7 May 2026 14:50:41 -0500 Subject: [PATCH 09/11] typecheck fix --- src/app/components/message/modals/GlobalModalManager.tsx | 2 +- src/app/plugins/markdown/htmlToMarkdown.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/app/components/message/modals/GlobalModalManager.tsx b/src/app/components/message/modals/GlobalModalManager.tsx index ddeaf3a03..d422d6e60 100644 --- a/src/app/components/message/modals/GlobalModalManager.tsx +++ b/src/app/components/message/modals/GlobalModalManager.tsx @@ -75,7 +75,7 @@ export function GlobalModalManager() { )} {modal.type === ModalType.ReadReceipts && ( - + Date: Thu, 7 May 2026 15:56:46 -0500 Subject: [PATCH 10/11] fix room avatars --- .changeset/fix-room-avatar-settings.md | 5 ++ .../common-settings/cosmetics/Cosmetics.tsx | 46 +++++++++++-------- src/app/features/room/message/Message.tsx | 5 +- src/app/hooks/useCommands.ts | 13 +++++- src/app/hooks/useRoomMembers.ts | 10 +++- 5 files changed, 54 insertions(+), 25 deletions(-) create mode 100644 .changeset/fix-room-avatar-settings.md diff --git a/.changeset/fix-room-avatar-settings.md b/.changeset/fix-room-avatar-settings.md new file mode 100644 index 000000000..5d1a80c5b --- /dev/null +++ b/.changeset/fix-room-avatar-settings.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fixed room avatars set in the settings cosmetics menu not applying. diff --git a/src/app/features/common-settings/cosmetics/Cosmetics.tsx b/src/app/features/common-settings/cosmetics/Cosmetics.tsx index 8ce1f7512..8bbb993d5 100644 --- a/src/app/features/common-settings/cosmetics/Cosmetics.tsx +++ b/src/app/features/common-settings/cosmetics/Cosmetics.tsx @@ -26,6 +26,7 @@ import { SettingTile } from '$components/setting-tile'; import { useRoom } from '$hooks/useRoom'; import { usePowerLevels } from '$hooks/usePowerLevels'; import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useStateEvent } from '$hooks/useStateEvent'; import { useRoomCreators } from '$hooks/useRoomCreators'; import { useRoomPermissions } from '$hooks/useRoomPermissions'; @@ -70,8 +71,13 @@ export function CosmeticsAvatar({ profile, member, userId, room }: CosmeticsSett const capabilities = useCapabilities(); const [alertRemove, setAlertRemove] = useState(false); const disableSetAvatar = capabilities['m.set_avatar_url']?.enabled === false; - - const avatarMxc = member.getMxcAvatarUrl(); + const memberStateEvent = useStateEvent(room, EventType.RoomMember, userId); + const memberStateContent = memberStateEvent?.getContent<{ avatar_url?: string }>(); + const globalAvatarMxc = mx.getUser(userId)?.avatarUrl ?? profile.avatarUrl; + const roomAvatarMxc = memberStateEvent + ? memberStateContent?.avatar_url + : member.getMxcAvatarUrl(); + const avatarMxc = roomAvatarMxc ?? globalAvatarMxc; const avatarUrl = avatarMxc && (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined); @@ -92,15 +98,17 @@ export function CosmeticsAvatar({ profile, member, userId, room }: CosmeticsSett const handleUploaded = useCallback( (upload: UploadSuccess) => { const { mxc } = upload; - myRoomAvatar.exe(mxc); - handleRemoveUpload(); + myRoomAvatar.exe(mxc).finally(() => { + handleRemoveUpload(); + }); }, [myRoomAvatar, handleRemoveUpload] ); const handleRemoveAvatar = () => { - myRoomAvatar.exe(''); - setAlertRemove(false); + myRoomAvatar.exe('').finally(() => { + setAlertRemove(false); + }); }; return ( @@ -139,20 +147,18 @@ export function CosmeticsAvatar({ profile, member, userId, room }: CosmeticsSett > Upload - {avatarUrl && - avatarUrl !== - mxcUrlToHttp(mx, profile.avatarUrl ?? '', useAuthentication, 96, 96, 'crop') && ( - - )} + {roomAvatarMxc && roomAvatarMxc !== globalAvatarMxc && ( + + )} )} diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 090d78135..b57f79b71 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -447,11 +447,12 @@ function MessageInternal( // Avatars // Prefer the room-scoped member avatar (m.room.member) over the global profile // avatar so per-room avatar overrides are respected in the timeline. + const memberAvatarMxc = getMemberAvatarMxc(room, senderId); const avatarUrl = useMemo(() => { if (collapse) return undefined; - const mxc = pmp?.avatar_url || getMemberAvatarMxc(room, senderId) || profile.avatarUrl; + const mxc = pmp?.avatar_url || memberAvatarMxc || profile.avatarUrl; return mxc ? mxcUrlToHttp(mx, mxc, useAuthentication, 48, 48, 'crop') : undefined; - }, [pmp, collapse, profile.avatarUrl, senderId, mx, room, useAuthentication]); + }, [pmp, collapse, memberAvatarMxc, profile.avatarUrl, mx, useAuthentication]); const cachedAvatar = useBlobCache(avatarUrl ?? undefined); diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts index 94a12f8a6..d06188a3b 100644 --- a/src/app/hooks/useCommands.ts +++ b/src/app/hooks/useCommands.ts @@ -642,7 +642,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { let newAvatar: string | undefined = payload.trim(); if (newAvatar.length === 0) { // no avatar, reset to global - newAvatar = profile.avatarUrl; + newAvatar = mx.getUser(mx.getSafeUserId())?.avatarUrl ?? profile.avatarUrl; } else if (!newAvatar.match(/^mxc:\/\/\S+$/)) { // bad mxc return; @@ -653,7 +653,16 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { ?.getStateEvents(EventType.RoomMember, mx.getSafeUserId()); const content = mEvent?.getContent(); if (!content) return; - await mx.sendStateEvent(room.roomId, EventType.RoomMember, content, mx.getSafeUserId()); + const updatedContent: RoomMemberEventContent = { + ...content, + avatar_url: newAvatar, + }; + await mx.sendStateEvent( + room.roomId, + EventType.RoomMember, + updatedContent, + mx.getSafeUserId() + ); }, }, [Command.ConvertToDm]: { diff --git a/src/app/hooks/useRoomMembers.ts b/src/app/hooks/useRoomMembers.ts index 4705bbf65..46640040e 100644 --- a/src/app/hooks/useRoomMembers.ts +++ b/src/app/hooks/useRoomMembers.ts @@ -1,5 +1,5 @@ import type { MatrixClient, MatrixEvent, RoomMember } from '$types/matrix-sdk'; -import { RoomMemberEvent } from '$types/matrix-sdk'; +import { EventType, RoomMemberEvent, RoomStateEvent } from '$types/matrix-sdk'; import { useEffect, useState } from 'react'; export const useRoomMembers = (mx: MatrixClient, roomId: string): RoomMember[] => { @@ -25,12 +25,20 @@ export const useRoomMembers = (mx: MatrixClient, roomId: string): RoomMember[] = }); } + const handleStateEvent = (event: MatrixEvent) => { + if (event.getRoomId() !== roomId) return; + if (event.getType() !== (EventType.RoomMember as string)) return; + updateMemberList(event); + }; + mx.on(RoomMemberEvent.Membership, updateMemberList); mx.on(RoomMemberEvent.PowerLevel, updateMemberList); + mx.on(RoomStateEvent.Events, handleStateEvent); return () => { disposed = true; mx.removeListener(RoomMemberEvent.Membership, updateMemberList); mx.removeListener(RoomMemberEvent.PowerLevel, updateMemberList); + mx.removeListener(RoomStateEvent.Events, handleStateEvent); }; }, [mx, roomId]); From a76f4518e7d7b2b12fe1cd5add42b6a08f1a00c5 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 7 May 2026 16:40:06 -0500 Subject: [PATCH 11/11] add back the proper fallback to global avatar --- src/app/components/member-tile/MemberTile.tsx | 2 +- .../common-settings/cosmetics/Cosmetics.tsx | 6 +++- src/app/hooks/useCommands.ts | 29 ++++++++++--------- 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/src/app/components/member-tile/MemberTile.tsx b/src/app/components/member-tile/MemberTile.tsx index f6cb1cc94..c74433eb7 100644 --- a/src/app/components/member-tile/MemberTile.tsx +++ b/src/app/components/member-tile/MemberTile.tsx @@ -29,7 +29,7 @@ export const MemberTile = as<'button', MemberTileProps>( const name = getName(room, member, nicknames); const presence = useUserPresence(member.userId ?? ''); - const avatarMxcUrl = member.getMxcAvatarUrl(); + const avatarMxcUrl = member.getMxcAvatarUrl() ?? mx.getUser(member.userId)?.avatarUrl; const avatarUrl = avatarMxcUrl ? mx.mxcUrlToHttp(avatarMxcUrl, 100, 100, 'crop', undefined, false, useAuthentication) : undefined; diff --git a/src/app/features/common-settings/cosmetics/Cosmetics.tsx b/src/app/features/common-settings/cosmetics/Cosmetics.tsx index 8bbb993d5..e7d505f88 100644 --- a/src/app/features/common-settings/cosmetics/Cosmetics.tsx +++ b/src/app/features/common-settings/cosmetics/Cosmetics.tsx @@ -78,6 +78,10 @@ export function CosmeticsAvatar({ profile, member, userId, room }: CosmeticsSett ? memberStateContent?.avatar_url : member.getMxcAvatarUrl(); const avatarMxc = roomAvatarMxc ?? globalAvatarMxc; + const hasRoomAvatarOverride = + memberStateEvent !== undefined && + memberStateContent?.avatar_url !== undefined && + memberStateContent.avatar_url !== globalAvatarMxc; const avatarUrl = avatarMxc && (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined); @@ -147,7 +151,7 @@ export function CosmeticsAvatar({ profile, member, userId, room }: CosmeticsSett > Upload - {roomAvatarMxc && roomAvatarMxc !== globalAvatarMxc && ( + {hasRoomAvatarOverride && (