')) {
- 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;
};
@@ -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, {
@@ -373,6 +380,14 @@ export const MessageEditor = as<'div', MessageEditorProps>(
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 +395,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/room/message/hiddenLinkPreviews.test.ts b/src/app/features/room/message/hiddenLinkPreviews.test.ts
new file mode 100644
index 000000000..60ff23d0d
--- /dev/null
+++ b/src/app/features/room/message/hiddenLinkPreviews.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from 'vitest';
+import {
+ readdAngleBracketsForHiddenPreviews,
+ 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 \`
+ );
+ });
+});
+
+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..7ca3111dc
--- /dev/null
+++ b/src/app/features/room/message/hiddenLinkPreviews.ts
@@ -0,0 +1,49 @@
+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
+): 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}>`;
+ });
+}
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,
diff --git a/src/app/features/settings/cosmetics/Themes.tsx b/src/app/features/settings/cosmetics/Themes.tsx
index 0fe2d716f..fd732b029 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 {
name: Command.MyRoomAvatar,
description: 'Change profile picture in current room. Example /myroomavatar mxc://xyzabc',
exe: async (payload) => {
- let newAvatar: string | undefined = payload.trim();
- if (newAvatar.length === 0) {
- // no avatar, reset to global
- newAvatar = profile.avatarUrl;
- } else if (!newAvatar.match(/^mxc:\/\/\S+$/)) {
- // bad mxc
- return;
- }
+ const trimmed = payload.trim();
+ const isRemove = trimmed.length === 0;
const mEvent = room
.getLiveTimeline()
.getState(EventTimeline.FORWARDS)
?.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 };
+ if (isRemove) {
+ // Reset to global avatar
+ const globalAvatar = mx.getUser(mx.getSafeUserId())?.avatarUrl ?? undefined;
+ (updatedContent as RoomMemberEventContent & { avatar_url?: string }).avatar_url =
+ globalAvatar;
+ } else {
+ if (!trimmed.match(/^mxc:\/\/\S+$/)) {
+ // bad mxc
+ return;
+ }
+ (updatedContent as RoomMemberEventContent & { avatar_url?: string }).avatar_url =
+ trimmed;
+ }
+ await mx.sendStateEvent(
+ room.roomId,
+ EventType.RoomMember,
+ updatedContent,
+ mx.getSafeUserId()
+ );
},
},
[Command.ConvertToDm]: {
@@ -1595,7 +1608,6 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
navigateRoom,
room,
profile.displayName,
- profile.avatarUrl,
pkitcmdHandler,
developerTools,
enableMSC4268CMD,
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]);
diff --git a/src/app/plugins/markdown/htmlToMarkdown.test.ts b/src/app/plugins/markdown/htmlToMarkdown.test.ts
index a48dd28a9..6899b2a6a 100644
--- a/src/app/plugins/markdown/htmlToMarkdown.test.ts
+++ b/src/app/plugins/markdown/htmlToMarkdown.test.ts
@@ -62,6 +62,11 @@ describe('htmlToMarkdown', () => {
);
});
+ it('converts hidden-preview wrapped links to markdown with ', () => {
+ const html = '';
+ 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..9e22f48cf 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.attribs.href ?? '';
+ const content = next.children.map((c) => processNode(c, listDepth, insideCode)).join('');
+ out.push(`[${content}](<${href}>)`);
+ i += 2;
+ continue;
+ }
+
+ if (cur) {
+ 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`;
}
diff --git a/src/app/plugins/markdown/markdownToHtml.test.ts b/src/app/plugins/markdown/markdownToHtml.test.ts
index 93fc71321..14eb8bc9d 100644
--- a/src/app/plugins/markdown/markdownToHtml.test.ts
+++ b/src/app/plugins/markdown/markdownToHtml.test.ts
@@ -134,6 +134,7 @@ describe('markdownToHtml', () => {
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(
+ '
',
+ {
+ 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(
+ '
',
+ {
+ 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,