diff --git a/src/app/components/AnimatedOutlet.tsx b/src/app/components/AnimatedOutlet.tsx index fc9af0a..9a8eb58 100644 --- a/src/app/components/AnimatedOutlet.tsx +++ b/src/app/components/AnimatedOutlet.tsx @@ -47,7 +47,16 @@ export function AnimatedOutlet() { pendingRoomId !== selectedRoomId; return ( -
+
{showOverlay && } {/* Do NOT key this by pathname — room switches share CachedRooms keep-alive. diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index 9891f98..5e8124f 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -46,6 +46,7 @@ type RenderMessageContentProps = { urlPreview?: boolean; highlightRegex?: RegExp; htmlReactParserOptions: HTMLReactParserOptions; + jumboHtmlReactParserOptions?: HTMLReactParserOptions; linkifyOpts: Opts; outlineAttachment?: boolean; disabledEmbedPatterns?: string[]; @@ -60,6 +61,7 @@ export function RenderMessageContent({ urlPreview, highlightRegex, htmlReactParserOptions, + jumboHtmlReactParserOptions, linkifyOpts, outlineAttachment, disabledEmbedPatterns, @@ -113,6 +115,7 @@ export function RenderMessageContent({ {...props} highlightRegex={highlightRegex} htmlReactParserOptions={htmlReactParserOptions} + jumboHtmlReactParserOptions={jumboHtmlReactParserOptions} linkifyOpts={linkifyOpts} /> )} @@ -187,6 +190,7 @@ export function RenderMessageContent({ {...props} highlightRegex={highlightRegex} htmlReactParserOptions={htmlReactParserOptions} + jumboHtmlReactParserOptions={jumboHtmlReactParserOptions} linkifyOpts={linkifyOpts} /> )} @@ -206,6 +210,7 @@ export function RenderMessageContent({ {...props} highlightRegex={highlightRegex} htmlReactParserOptions={htmlReactParserOptions} + jumboHtmlReactParserOptions={jumboHtmlReactParserOptions} linkifyOpts={linkifyOpts} /> )} @@ -224,6 +229,7 @@ export function RenderMessageContent({ {...props} highlightRegex={highlightRegex} htmlReactParserOptions={htmlReactParserOptions} + jumboHtmlReactParserOptions={jumboHtmlReactParserOptions} linkifyOpts={linkifyOpts} /> )} diff --git a/src/app/components/message/RenderBody.tsx b/src/app/components/message/RenderBody.tsx index 501ca30..ef57315 100644 --- a/src/app/components/message/RenderBody.tsx +++ b/src/app/components/message/RenderBody.tsx @@ -5,6 +5,9 @@ import { Opts } from 'linkifyjs'; import { MessageEmptyContent } from './content'; import { sanitizeCustomHtml } from '../../utils/sanitize'; import { highlightText, scaleSystemEmoji } from '../../plugins/react-custom-html-parser'; +import { isMxEmoticonOnlyHtml } from '../../utils/regex'; +// Ensure jumbo emoji global styles are always in the bundle. +import './layout/layout.css'; type RenderBodyProps = { body: string; @@ -13,6 +16,7 @@ type RenderBodyProps = { highlightRegex?: RegExp; htmlReactParserOptions: HTMLReactParserOptions; + jumboHtmlReactParserOptions?: HTMLReactParserOptions; linkifyOpts: Opts; }; export function RenderBody({ @@ -21,13 +25,22 @@ export function RenderBody({ jumbo = false, highlightRegex, htmlReactParserOptions, + jumboHtmlReactParserOptions, linkifyOpts, }: RenderBodyProps) { - if (body === '') ; - if (customBody) { - if (customBody === '') ; - return parse(sanitizeCustomHtml(customBody), htmlReactParserOptions); + const hasCustomBody = typeof customBody === 'string' && customBody.length > 0; + // Unicode jumbo should use the plain path so scaleSystemEmoji gets the jumbo flag. + // HTML path is only needed for custom mx-emoticon images. + const useCustomHtml = hasCustomBody && (!jumbo || isMxEmoticonOnlyHtml(customBody)); + + if (useCustomHtml) { + const options = + jumbo && jumboHtmlReactParserOptions ? jumboHtmlReactParserOptions : htmlReactParserOptions; + return parse(sanitizeCustomHtml(customBody), options); } + + if (body === '') return ; + return ( {highlightRegex diff --git a/src/app/features/room/CachedRooms.tsx b/src/app/features/room/CachedRooms.tsx index b310b53..a0ab477 100644 --- a/src/app/features/room/CachedRooms.tsx +++ b/src/app/features/room/CachedRooms.tsx @@ -20,7 +20,9 @@ const activeSlotStyle: React.CSSProperties = { flex: 1, minWidth: 0, minHeight: 0, + width: '100%', display: 'flex', + flexDirection: 'column', position: 'relative', zIndex: 1, }; @@ -29,11 +31,12 @@ const inactiveSlotStyle: React.CSSProperties = { position: 'absolute', inset: 0, display: 'flex', - // Prefer opacity over visibility:hidden — the latter can reset overflow scrollTop. - opacity: 0, + flexDirection: 'column', + // Scroll is saved/restored manually — hide without opacity so we don't stack + // compositor layers over the active room (looked like an all-black chat pane). + visibility: 'hidden', pointerEvents: 'none', - zIndex: 0, - // Keep in layout for scroll metrics; inert + aria-hidden handle a11y. + zIndex: -1, }; type CachedRoomSlotProps = { @@ -175,8 +178,10 @@ export function CachedRooms() { flex: 1, minWidth: 0, minHeight: 0, + width: '100%', position: 'relative', display: 'flex', + flexDirection: 'column', }} > {ids.map((roomId) => { diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index bf383e2..b77be59 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -53,7 +53,7 @@ export const RoomFrame = memo(function RoomFrame({ return ( - + {forumRoom && !pending ? ( ) : ( diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 81a35d3..ec28cb2 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -688,6 +688,17 @@ export const RoomTimeline = memo(function RoomTimeline({ }), [mx, room, linkifyOpts, spoilerClickHandler, mentionClickHandler, useAuthentication] ); + const jumboHtmlReactParserOptions = useMemo( + () => + getReactCustomHtmlParser(mx, room.roomId, { + linkifyOpts, + useAuthentication, + handleSpoilerClick: spoilerClickHandler, + handleMentionClick: mentionClickHandler, + jumbo: true, + }), + [mx, room, linkifyOpts, spoilerClickHandler, mentionClickHandler, useAuthentication] + ); const parseMemberEvent = useMemberEventParser(); const [timeline, setTimeline] = useState(() => @@ -1410,6 +1421,7 @@ export const RoomTimeline = memo(function RoomTimeline({ mediaAutoLoad={mediaAutoLoad} urlPreview={showUrlPreview} htmlReactParserOptions={htmlReactParserOptions} + jumboHtmlReactParserOptions={jumboHtmlReactParserOptions} linkifyOpts={linkifyOpts} outlineAttachment={messageLayout === MessageLayout.Bubble} disabledEmbedPatterns={combinedEmbedFilters} @@ -1518,6 +1530,7 @@ export const RoomTimeline = memo(function RoomTimeline({ mediaAutoLoad={mediaAutoLoad} urlPreview={showUrlPreview} htmlReactParserOptions={htmlReactParserOptions} + jumboHtmlReactParserOptions={jumboHtmlReactParserOptions} linkifyOpts={linkifyOpts} outlineAttachment={messageLayout === MessageLayout.Bubble} disabledEmbedPatterns={combinedEmbedFilters} diff --git a/src/app/features/room/ThreadView.tsx b/src/app/features/room/ThreadView.tsx index 7d4511f..8ab1c96 100644 --- a/src/app/features/room/ThreadView.tsx +++ b/src/app/features/room/ThreadView.tsx @@ -212,6 +212,17 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) { }), [mx, room, linkifyOpts, spoilerClickHandler, mentionClickHandler, useAuthentication] ); + const jumboHtmlReactParserOptions = useMemo( + () => + getReactCustomHtmlParser(mx, room.roomId, { + linkifyOpts, + useAuthentication, + handleSpoilerClick: spoilerClickHandler, + handleMentionClick: mentionClickHandler, + jumbo: true, + }), + [mx, room, linkifyOpts, spoilerClickHandler, mentionClickHandler, useAuthentication] + ); const thread = room.getThread(threadRootId); const roomTimelineSet = room.getUnfilteredTimelineSet(); @@ -634,6 +645,7 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) { mediaAutoLoad={mediaAutoLoad} urlPreview={showUrlPreview} htmlReactParserOptions={htmlReactParserOptions} + jumboHtmlReactParserOptions={jumboHtmlReactParserOptions} linkifyOpts={linkifyOpts} outlineAttachment={messageLayout === MessageLayout.Bubble} disabledEmbedPatterns={combinedEmbedFilters} @@ -650,6 +662,7 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) { mediaAutoLoad={mediaAutoLoad} urlPreview={showUrlPreview} htmlReactParserOptions={htmlReactParserOptions} + jumboHtmlReactParserOptions={jumboHtmlReactParserOptions} linkifyOpts={linkifyOpts} outlineAttachment={messageLayout === MessageLayout.Bubble} disabledEmbedPatterns={combinedEmbedFilters} diff --git a/src/app/pages/client/direct/DirectLayout.tsx b/src/app/pages/client/direct/DirectLayout.tsx index 78dd531..d417f5e 100644 --- a/src/app/pages/client/direct/DirectLayout.tsx +++ b/src/app/pages/client/direct/DirectLayout.tsx @@ -37,6 +37,7 @@ export function DirectLayout() { minHeight: 0, position: 'relative', display: 'flex', + flexDirection: 'column', }} > {keepHost && ( @@ -46,6 +47,7 @@ export function DirectLayout() { minWidth: 0, minHeight: 0, display: showingRoom ? 'flex' : 'none', + flexDirection: 'column', position: 'relative', }} aria-hidden={!showingRoom} @@ -59,6 +61,7 @@ export function DirectLayout() { minWidth: 0, minHeight: 0, display: showingRoom ? 'none' : 'flex', + flexDirection: 'column', position: 'relative', }} > diff --git a/src/app/pages/client/home/HomeLayout.tsx b/src/app/pages/client/home/HomeLayout.tsx index 1a25c3d..ceaa388 100644 --- a/src/app/pages/client/home/HomeLayout.tsx +++ b/src/app/pages/client/home/HomeLayout.tsx @@ -34,6 +34,7 @@ export function HomeLayout() { minHeight: 0, position: 'relative', display: 'flex', + flexDirection: 'column', }} > {keepHost && ( @@ -43,6 +44,7 @@ export function HomeLayout() { minWidth: 0, minHeight: 0, display: showingRoom ? 'flex' : 'none', + flexDirection: 'column', position: 'relative', }} aria-hidden={!showingRoom} @@ -56,6 +58,7 @@ export function HomeLayout() { minWidth: 0, minHeight: 0, display: showingRoom ? 'none' : 'flex', + flexDirection: 'column', position: 'relative', }} > diff --git a/src/app/pages/client/space/SpaceRoomLayout.tsx b/src/app/pages/client/space/SpaceRoomLayout.tsx index 8cdb619..bd71af2 100644 --- a/src/app/pages/client/space/SpaceRoomLayout.tsx +++ b/src/app/pages/client/space/SpaceRoomLayout.tsx @@ -46,6 +46,7 @@ export function SpaceRoomLayout() { minHeight: 0, position: 'relative', display: 'flex', + flexDirection: 'column', }} > {keepHost && ( @@ -55,6 +56,7 @@ export function SpaceRoomLayout() { minWidth: 0, minHeight: 0, display: showingRoom ? 'flex' : 'none', + flexDirection: 'column', position: 'relative', }} aria-hidden={!showingRoom} @@ -68,6 +70,7 @@ export function SpaceRoomLayout() { minWidth: 0, minHeight: 0, display: showingRoom ? 'none' : 'flex', + flexDirection: 'column', position: 'relative', }} > diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx index 8e1eb0d..5cc96c3 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -386,8 +386,10 @@ export const getReactCustomHtmlParser = ( handleSpoilerClick?: ReactEventHandler; handleMentionClick?: ReactEventHandler; useAuthentication?: boolean; + jumbo?: boolean; } ): HTMLReactParserOptions => { + const jumbo = params.jumbo ?? false; const opts: HTMLReactParserOptions = { replace: (domNode) => { if (domNode instanceof Element && 'name' in domNode) { @@ -443,6 +445,13 @@ export const getReactCustomHtmlParser = ( } if (name === 'p') { + if (jumbo) { + return ( +

+ {domToReact(children, opts)} +

+ ); + } return ( {domToReact(children, opts)} @@ -561,9 +570,13 @@ export const getReactCustomHtmlParser = ( // Drop presentational height/width so jumbo CSS can size freely const { height: _h, width: _w, ...imgProps } = props; return ( - - - + + + ); @@ -577,7 +590,7 @@ export const getReactCustomHtmlParser = ( !(domNode.parent && 'name' in domNode.parent && domNode.parent.name === 'code') && !(domNode.parent && 'name' in domNode.parent && domNode.parent.name === 'a'); - let jsx = scaleSystemEmoji(domNode.data); + let jsx = scaleSystemEmoji(domNode.data, jumbo); if (params.highlightRegex) { jsx = highlightText(params.highlightRegex, jsx); diff --git a/src/app/utils/regex.ts b/src/app/utils/regex.ts index 81c7341..706f31f 100644 --- a/src/app/utils/regex.ts +++ b/src/app/utils/regex.ts @@ -31,6 +31,15 @@ const JUMBO_IGNORE_CHARS_REG = /[\u200B-\u200D\uFEFF\u00AD\u2060\u00A0]/g; const ONLY_MX_EMOTICON_HTML_REG = /^(?:\s|)*((?:]*\bdata-mx-emoticon\b[^>]*\/?>)(?:\s|)*){1,10}$/i; +/** HTML that contains only custom Matrix emoticon images (no plain unicode body). */ +export const isMxEmoticonOnlyHtml = (html: string): boolean => { + const normalized = html + .replace(JUMBO_IGNORE_CHARS_REG, '') + .trim() + .replace(/(\s*)+$/gi, ''); + return normalized.length > 0 && ONLY_MX_EMOTICON_HTML_REG.test(normalized); +}; + /** * True when a message is emoji/custom-emote only (Discord-style jumbo). * Strips zero-width chars and also accepts HTML that is only mx-emoticon imgs. @@ -40,11 +49,7 @@ export const isJumboEmoji = (body: string, customBody?: string): boolean => { if (plain.length > 0 && JUMBO_EMOJI_REG.test(plain)) return true; if (typeof customBody === 'string') { - const html = customBody - .replace(JUMBO_IGNORE_CHARS_REG, '') - .trim() - .replace(/(\s*)+$/gi, ''); - if (html.length > 0 && ONLY_MX_EMOTICON_HTML_REG.test(html)) return true; + if (isMxEmoticonOnlyHtml(customBody)) return true; } return false;