Fix black chat pane and restore jumbo emoji rendering.

Column flex on keep-alive room hosts so the timeline fills the pane again, and route unicode jumbo through the plain emoji path with dedicated HTML parser options for custom emoticons.
This commit is contained in:
2026-07-22 18:21:08 +10:00
parent 30edd62d96
commit 57828dd33a
12 changed files with 105 additions and 19 deletions

View File

@@ -47,7 +47,16 @@ export function AnimatedOutlet() {
pendingRoomId !== selectedRoomId; pendingRoomId !== selectedRoomId;
return ( return (
<div style={{ flex: 1, minWidth: 0, minHeight: 0, position: 'relative', display: 'flex' }}> <div
style={{
flex: 1,
minWidth: 0,
minHeight: 0,
position: 'relative',
display: 'flex',
flexDirection: 'column',
}}
>
{showOverlay && <PendingRoomShell roomId={pendingRoomId} />} {showOverlay && <PendingRoomShell roomId={pendingRoomId} />}
{/* {/*
Do NOT key this by pathname — room switches share CachedRooms keep-alive. Do NOT key this by pathname — room switches share CachedRooms keep-alive.

View File

@@ -46,6 +46,7 @@ type RenderMessageContentProps = {
urlPreview?: boolean; urlPreview?: boolean;
highlightRegex?: RegExp; highlightRegex?: RegExp;
htmlReactParserOptions: HTMLReactParserOptions; htmlReactParserOptions: HTMLReactParserOptions;
jumboHtmlReactParserOptions?: HTMLReactParserOptions;
linkifyOpts: Opts; linkifyOpts: Opts;
outlineAttachment?: boolean; outlineAttachment?: boolean;
disabledEmbedPatterns?: string[]; disabledEmbedPatterns?: string[];
@@ -60,6 +61,7 @@ export function RenderMessageContent({
urlPreview, urlPreview,
highlightRegex, highlightRegex,
htmlReactParserOptions, htmlReactParserOptions,
jumboHtmlReactParserOptions,
linkifyOpts, linkifyOpts,
outlineAttachment, outlineAttachment,
disabledEmbedPatterns, disabledEmbedPatterns,
@@ -113,6 +115,7 @@ export function RenderMessageContent({
{...props} {...props}
highlightRegex={highlightRegex} highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions} htmlReactParserOptions={htmlReactParserOptions}
jumboHtmlReactParserOptions={jumboHtmlReactParserOptions}
linkifyOpts={linkifyOpts} linkifyOpts={linkifyOpts}
/> />
)} )}
@@ -187,6 +190,7 @@ export function RenderMessageContent({
{...props} {...props}
highlightRegex={highlightRegex} highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions} htmlReactParserOptions={htmlReactParserOptions}
jumboHtmlReactParserOptions={jumboHtmlReactParserOptions}
linkifyOpts={linkifyOpts} linkifyOpts={linkifyOpts}
/> />
)} )}
@@ -206,6 +210,7 @@ export function RenderMessageContent({
{...props} {...props}
highlightRegex={highlightRegex} highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions} htmlReactParserOptions={htmlReactParserOptions}
jumboHtmlReactParserOptions={jumboHtmlReactParserOptions}
linkifyOpts={linkifyOpts} linkifyOpts={linkifyOpts}
/> />
)} )}
@@ -224,6 +229,7 @@ export function RenderMessageContent({
{...props} {...props}
highlightRegex={highlightRegex} highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions} htmlReactParserOptions={htmlReactParserOptions}
jumboHtmlReactParserOptions={jumboHtmlReactParserOptions}
linkifyOpts={linkifyOpts} linkifyOpts={linkifyOpts}
/> />
)} )}

View File

@@ -5,6 +5,9 @@ import { Opts } from 'linkifyjs';
import { MessageEmptyContent } from './content'; import { MessageEmptyContent } from './content';
import { sanitizeCustomHtml } from '../../utils/sanitize'; import { sanitizeCustomHtml } from '../../utils/sanitize';
import { highlightText, scaleSystemEmoji } from '../../plugins/react-custom-html-parser'; 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 = { type RenderBodyProps = {
body: string; body: string;
@@ -13,6 +16,7 @@ type RenderBodyProps = {
highlightRegex?: RegExp; highlightRegex?: RegExp;
htmlReactParserOptions: HTMLReactParserOptions; htmlReactParserOptions: HTMLReactParserOptions;
jumboHtmlReactParserOptions?: HTMLReactParserOptions;
linkifyOpts: Opts; linkifyOpts: Opts;
}; };
export function RenderBody({ export function RenderBody({
@@ -21,13 +25,22 @@ export function RenderBody({
jumbo = false, jumbo = false,
highlightRegex, highlightRegex,
htmlReactParserOptions, htmlReactParserOptions,
jumboHtmlReactParserOptions,
linkifyOpts, linkifyOpts,
}: RenderBodyProps) { }: RenderBodyProps) {
if (body === '') <MessageEmptyContent />; const hasCustomBody = typeof customBody === 'string' && customBody.length > 0;
if (customBody) { // Unicode jumbo should use the plain path so scaleSystemEmoji gets the jumbo flag.
if (customBody === '') <MessageEmptyContent />; // HTML path is only needed for custom mx-emoticon images.
return parse(sanitizeCustomHtml(customBody), htmlReactParserOptions); const useCustomHtml = hasCustomBody && (!jumbo || isMxEmoticonOnlyHtml(customBody));
if (useCustomHtml) {
const options =
jumbo && jumboHtmlReactParserOptions ? jumboHtmlReactParserOptions : htmlReactParserOptions;
return parse(sanitizeCustomHtml(customBody), options);
} }
if (body === '') return <MessageEmptyContent />;
return ( return (
<Linkify options={linkifyOpts}> <Linkify options={linkifyOpts}>
{highlightRegex {highlightRegex

View File

@@ -20,7 +20,9 @@ const activeSlotStyle: React.CSSProperties = {
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
minHeight: 0, minHeight: 0,
width: '100%',
display: 'flex', display: 'flex',
flexDirection: 'column',
position: 'relative', position: 'relative',
zIndex: 1, zIndex: 1,
}; };
@@ -29,11 +31,12 @@ const inactiveSlotStyle: React.CSSProperties = {
position: 'absolute', position: 'absolute',
inset: 0, inset: 0,
display: 'flex', display: 'flex',
// Prefer opacity over visibility:hidden — the latter can reset overflow scrollTop. flexDirection: 'column',
opacity: 0, // 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', pointerEvents: 'none',
zIndex: 0, zIndex: -1,
// Keep in layout for scroll metrics; inert + aria-hidden handle a11y.
}; };
type CachedRoomSlotProps = { type CachedRoomSlotProps = {
@@ -175,8 +178,10 @@ export function CachedRooms() {
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
minHeight: 0, minHeight: 0,
width: '100%',
position: 'relative', position: 'relative',
display: 'flex', display: 'flex',
flexDirection: 'column',
}} }}
> >
{ids.map((roomId) => { {ids.map((roomId) => {

View File

@@ -53,7 +53,7 @@ export const RoomFrame = memo(function RoomFrame({
return ( return (
<PowerLevelsContextProvider value={powerLevels}> <PowerLevelsContextProvider value={powerLevels}>
<Box grow="Yes"> <Box grow="Yes" direction="Column" style={{ minHeight: 0, minWidth: 0 }}>
{forumRoom && !pending ? ( {forumRoom && !pending ? (
<ForumRoomView room={room} eventId={eventId} /> <ForumRoomView room={room} eventId={eventId} />
) : ( ) : (

View File

@@ -688,6 +688,17 @@ export const RoomTimeline = memo(function RoomTimeline({
}), }),
[mx, room, linkifyOpts, spoilerClickHandler, mentionClickHandler, useAuthentication] [mx, room, linkifyOpts, spoilerClickHandler, mentionClickHandler, useAuthentication]
); );
const jumboHtmlReactParserOptions = useMemo<HTMLReactParserOptions>(
() =>
getReactCustomHtmlParser(mx, room.roomId, {
linkifyOpts,
useAuthentication,
handleSpoilerClick: spoilerClickHandler,
handleMentionClick: mentionClickHandler,
jumbo: true,
}),
[mx, room, linkifyOpts, spoilerClickHandler, mentionClickHandler, useAuthentication]
);
const parseMemberEvent = useMemberEventParser(); const parseMemberEvent = useMemberEventParser();
const [timeline, setTimeline] = useState<Timeline>(() => const [timeline, setTimeline] = useState<Timeline>(() =>
@@ -1410,6 +1421,7 @@ export const RoomTimeline = memo(function RoomTimeline({
mediaAutoLoad={mediaAutoLoad} mediaAutoLoad={mediaAutoLoad}
urlPreview={showUrlPreview} urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions} htmlReactParserOptions={htmlReactParserOptions}
jumboHtmlReactParserOptions={jumboHtmlReactParserOptions}
linkifyOpts={linkifyOpts} linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble} outlineAttachment={messageLayout === MessageLayout.Bubble}
disabledEmbedPatterns={combinedEmbedFilters} disabledEmbedPatterns={combinedEmbedFilters}
@@ -1518,6 +1530,7 @@ export const RoomTimeline = memo(function RoomTimeline({
mediaAutoLoad={mediaAutoLoad} mediaAutoLoad={mediaAutoLoad}
urlPreview={showUrlPreview} urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions} htmlReactParserOptions={htmlReactParserOptions}
jumboHtmlReactParserOptions={jumboHtmlReactParserOptions}
linkifyOpts={linkifyOpts} linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble} outlineAttachment={messageLayout === MessageLayout.Bubble}
disabledEmbedPatterns={combinedEmbedFilters} disabledEmbedPatterns={combinedEmbedFilters}

View File

@@ -212,6 +212,17 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
}), }),
[mx, room, linkifyOpts, spoilerClickHandler, mentionClickHandler, useAuthentication] [mx, room, linkifyOpts, spoilerClickHandler, mentionClickHandler, useAuthentication]
); );
const jumboHtmlReactParserOptions = useMemo<HTMLReactParserOptions>(
() =>
getReactCustomHtmlParser(mx, room.roomId, {
linkifyOpts,
useAuthentication,
handleSpoilerClick: spoilerClickHandler,
handleMentionClick: mentionClickHandler,
jumbo: true,
}),
[mx, room, linkifyOpts, spoilerClickHandler, mentionClickHandler, useAuthentication]
);
const thread = room.getThread(threadRootId); const thread = room.getThread(threadRootId);
const roomTimelineSet = room.getUnfilteredTimelineSet(); const roomTimelineSet = room.getUnfilteredTimelineSet();
@@ -634,6 +645,7 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
mediaAutoLoad={mediaAutoLoad} mediaAutoLoad={mediaAutoLoad}
urlPreview={showUrlPreview} urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions} htmlReactParserOptions={htmlReactParserOptions}
jumboHtmlReactParserOptions={jumboHtmlReactParserOptions}
linkifyOpts={linkifyOpts} linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble} outlineAttachment={messageLayout === MessageLayout.Bubble}
disabledEmbedPatterns={combinedEmbedFilters} disabledEmbedPatterns={combinedEmbedFilters}
@@ -650,6 +662,7 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
mediaAutoLoad={mediaAutoLoad} mediaAutoLoad={mediaAutoLoad}
urlPreview={showUrlPreview} urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions} htmlReactParserOptions={htmlReactParserOptions}
jumboHtmlReactParserOptions={jumboHtmlReactParserOptions}
linkifyOpts={linkifyOpts} linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble} outlineAttachment={messageLayout === MessageLayout.Bubble}
disabledEmbedPatterns={combinedEmbedFilters} disabledEmbedPatterns={combinedEmbedFilters}

View File

@@ -37,6 +37,7 @@ export function DirectLayout() {
minHeight: 0, minHeight: 0,
position: 'relative', position: 'relative',
display: 'flex', display: 'flex',
flexDirection: 'column',
}} }}
> >
{keepHost && ( {keepHost && (
@@ -46,6 +47,7 @@ export function DirectLayout() {
minWidth: 0, minWidth: 0,
minHeight: 0, minHeight: 0,
display: showingRoom ? 'flex' : 'none', display: showingRoom ? 'flex' : 'none',
flexDirection: 'column',
position: 'relative', position: 'relative',
}} }}
aria-hidden={!showingRoom} aria-hidden={!showingRoom}
@@ -59,6 +61,7 @@ export function DirectLayout() {
minWidth: 0, minWidth: 0,
minHeight: 0, minHeight: 0,
display: showingRoom ? 'none' : 'flex', display: showingRoom ? 'none' : 'flex',
flexDirection: 'column',
position: 'relative', position: 'relative',
}} }}
> >

View File

@@ -34,6 +34,7 @@ export function HomeLayout() {
minHeight: 0, minHeight: 0,
position: 'relative', position: 'relative',
display: 'flex', display: 'flex',
flexDirection: 'column',
}} }}
> >
{keepHost && ( {keepHost && (
@@ -43,6 +44,7 @@ export function HomeLayout() {
minWidth: 0, minWidth: 0,
minHeight: 0, minHeight: 0,
display: showingRoom ? 'flex' : 'none', display: showingRoom ? 'flex' : 'none',
flexDirection: 'column',
position: 'relative', position: 'relative',
}} }}
aria-hidden={!showingRoom} aria-hidden={!showingRoom}
@@ -56,6 +58,7 @@ export function HomeLayout() {
minWidth: 0, minWidth: 0,
minHeight: 0, minHeight: 0,
display: showingRoom ? 'none' : 'flex', display: showingRoom ? 'none' : 'flex',
flexDirection: 'column',
position: 'relative', position: 'relative',
}} }}
> >

View File

@@ -46,6 +46,7 @@ export function SpaceRoomLayout() {
minHeight: 0, minHeight: 0,
position: 'relative', position: 'relative',
display: 'flex', display: 'flex',
flexDirection: 'column',
}} }}
> >
{keepHost && ( {keepHost && (
@@ -55,6 +56,7 @@ export function SpaceRoomLayout() {
minWidth: 0, minWidth: 0,
minHeight: 0, minHeight: 0,
display: showingRoom ? 'flex' : 'none', display: showingRoom ? 'flex' : 'none',
flexDirection: 'column',
position: 'relative', position: 'relative',
}} }}
aria-hidden={!showingRoom} aria-hidden={!showingRoom}
@@ -68,6 +70,7 @@ export function SpaceRoomLayout() {
minWidth: 0, minWidth: 0,
minHeight: 0, minHeight: 0,
display: showingRoom ? 'none' : 'flex', display: showingRoom ? 'none' : 'flex',
flexDirection: 'column',
position: 'relative', position: 'relative',
}} }}
> >

View File

@@ -386,8 +386,10 @@ export const getReactCustomHtmlParser = (
handleSpoilerClick?: ReactEventHandler<HTMLElement>; handleSpoilerClick?: ReactEventHandler<HTMLElement>;
handleMentionClick?: ReactEventHandler<HTMLElement>; handleMentionClick?: ReactEventHandler<HTMLElement>;
useAuthentication?: boolean; useAuthentication?: boolean;
jumbo?: boolean;
} }
): HTMLReactParserOptions => { ): HTMLReactParserOptions => {
const jumbo = params.jumbo ?? false;
const opts: HTMLReactParserOptions = { const opts: HTMLReactParserOptions = {
replace: (domNode) => { replace: (domNode) => {
if (domNode instanceof Element && 'name' in domNode) { if (domNode instanceof Element && 'name' in domNode) {
@@ -443,6 +445,13 @@ export const getReactCustomHtmlParser = (
} }
if (name === 'p') { if (name === 'p') {
if (jumbo) {
return (
<p {...props} className={classNames(css.Paragraph, css.MarginSpaced)}>
{domToReact(children, opts)}
</p>
);
}
return ( return (
<Text {...props} className={classNames(css.Paragraph, css.MarginSpaced)} size="Inherit"> <Text {...props} className={classNames(css.Paragraph, css.MarginSpaced)} size="Inherit">
{domToReact(children, opts)} {domToReact(children, opts)}
@@ -561,9 +570,13 @@ export const getReactCustomHtmlParser = (
// Drop presentational height/width so jumbo CSS can size freely // Drop presentational height/width so jumbo CSS can size freely
const { height: _h, width: _w, ...imgProps } = props; const { height: _h, width: _w, ...imgProps } = props;
return ( return (
<span className={css.EmoticonBase}> <span className={jumbo ? css.EmoticonBaseJumbo : css.EmoticonBase}>
<span className={css.Emoticon()}> <span className={css.Emoticon({ jumbo })}>
<AuthenticatedImg {...imgProps} className={css.EmoticonImg()} src={htmlSrc} /> <AuthenticatedImg
{...imgProps}
className={css.EmoticonImg({ jumbo })}
src={htmlSrc}
/>
</span> </span>
</span> </span>
); );
@@ -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 === 'code') &&
!(domNode.parent && 'name' in domNode.parent && domNode.parent.name === 'a'); !(domNode.parent && 'name' in domNode.parent && domNode.parent.name === 'a');
let jsx = scaleSystemEmoji(domNode.data); let jsx = scaleSystemEmoji(domNode.data, jumbo);
if (params.highlightRegex) { if (params.highlightRegex) {
jsx = highlightText(params.highlightRegex, jsx); jsx = highlightText(params.highlightRegex, jsx);

View File

@@ -31,6 +31,15 @@ const JUMBO_IGNORE_CHARS_REG = /[\u200B-\u200D\uFEFF\u00AD\u2060\u00A0]/g;
const ONLY_MX_EMOTICON_HTML_REG = const ONLY_MX_EMOTICON_HTML_REG =
/^(?:\s|<br\s*\/?>)*((?:<img\b[^>]*\bdata-mx-emoticon\b[^>]*\/?>)(?:\s|<br\s*\/?>)*){1,10}$/i; /^(?:\s|<br\s*\/?>)*((?:<img\b[^>]*\bdata-mx-emoticon\b[^>]*\/?>)(?:\s|<br\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(/(<br\s*\/?>\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). * 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. * 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 (plain.length > 0 && JUMBO_EMOJI_REG.test(plain)) return true;
if (typeof customBody === 'string') { if (typeof customBody === 'string') {
const html = customBody if (isMxEmoticonOnlyHtml(customBody)) return true;
.replace(JUMBO_IGNORE_CHARS_REG, '')
.trim()
.replace(/(<br\s*\/?>\s*)+$/gi, '');
if (html.length > 0 && ONLY_MX_EMOTICON_HTML_REG.test(html)) return true;
} }
return false; return false;