Merge branch 'dev' into add-international-date

This commit is contained in:
Gimle Larpes 2025-07-25 03:20:04 +03:00 committed by GitHub
commit e2c0984620
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 206 additions and 21 deletions

View file

@ -162,7 +162,7 @@ export function RenderElement({ attributes, element, children }: RenderElementPr
visibility="Hover" visibility="Hover"
hideTrack hideTrack
> >
<div className={css.CodeBlockInternal}>{children}</div> <div className={css.CodeBlockInternal()}>{children}</div>
</Scroll> </Scroll>
</Text> </Text>
); );

View file

@ -16,6 +16,7 @@ export const AbsoluteContainer = style([
position: 'absolute', position: 'absolute',
top: 0, top: 0,
left: 0, left: 0,
zIndex: 1,
width: '100%', width: '100%',
height: '100%', height: '100%',
}, },

View file

@ -936,7 +936,7 @@ export function RoomTimeline({
); );
const handleReplyClick: MouseEventHandler<HTMLButtonElement> = useCallback( const handleReplyClick: MouseEventHandler<HTMLButtonElement> = useCallback(
(evt) => { (evt, startThread = false) => {
const replyId = evt.currentTarget.getAttribute('data-event-id'); const replyId = evt.currentTarget.getAttribute('data-event-id');
if (!replyId) { if (!replyId) {
console.warn('Button should have "data-event-id" attribute!'); console.warn('Button should have "data-event-id" attribute!');
@ -947,7 +947,9 @@ export function RoomTimeline({
const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet()); const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent(); const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
const { body, formatted_body: formattedBody } = content; const { body, formatted_body: formattedBody } = content;
const { 'm.relates_to': relation } = replyEvt.getWireContent(); const { 'm.relates_to': relation } = startThread
? { 'm.relates_to': { rel_type: 'm.thread', event_id: replyId } }
: replyEvt.getWireContent();
const senderId = replyEvt.getSender(); const senderId = replyEvt.getSender();
if (senderId && typeof body === 'string') { if (senderId && typeof body === 'string') {
setReplyDraft({ setReplyDraft({

View file

@ -669,7 +669,10 @@ export type MessageProps = {
messageSpacing: MessageSpacing; messageSpacing: MessageSpacing;
onUserClick: MouseEventHandler<HTMLButtonElement>; onUserClick: MouseEventHandler<HTMLButtonElement>;
onUsernameClick: MouseEventHandler<HTMLButtonElement>; onUsernameClick: MouseEventHandler<HTMLButtonElement>;
onReplyClick: MouseEventHandler<HTMLButtonElement>; onReplyClick: (
ev: Parameters<MouseEventHandler<HTMLButtonElement>>[0],
startThread?: boolean
) => void;
onEditId?: (eventId?: string) => void; onEditId?: (eventId?: string) => void;
onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void; onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void;
reply?: ReactNode; reply?: ReactNode;
@ -868,6 +871,8 @@ export const Message = as<'div', MessageProps>(
}, 100); }, 100);
}; };
const isThreadedMessage = mEvent.threadRootId !== undefined;
return ( return (
<MessageBase <MessageBase
className={classNames(css.MessageBase, className)} className={classNames(css.MessageBase, className)}
@ -930,6 +935,17 @@ export const Message = as<'div', MessageProps>(
> >
<Icon src={Icons.ReplyArrow} size="100" /> <Icon src={Icons.ReplyArrow} size="100" />
</IconButton> </IconButton>
{!isThreadedMessage && (
<IconButton
onClick={(ev) => onReplyClick(ev, true)}
data-event-id={mEvent.getId()}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon src={Icons.ThreadPlus} size="100" />
</IconButton>
)}
{canEditEvent(mx, mEvent) && onEditId && ( {canEditEvent(mx, mEvent) && onEditId && (
<IconButton <IconButton
onClick={() => onEditId(mEvent.getId())} onClick={() => onEditId(mEvent.getId())}
@ -1009,6 +1025,27 @@ export const Message = as<'div', MessageProps>(
Reply Reply
</Text> </Text>
</MenuItem> </MenuItem>
{!isThreadedMessage && (
<MenuItem
size="300"
after={<Icon src={Icons.ThreadPlus} size="100" />}
radii="300"
data-event-id={mEvent.getId()}
onClick={(evt: any) => {
onReplyClick(evt, true);
closeMenu();
}}
>
<Text
className={css.MessageMenuItemText}
as="span"
size="T300"
truncate
>
Reply in Thread
</Text>
</MenuItem>
)}
{canEditEvent(mx, mEvent) && onEditId && ( {canEditEvent(mx, mEvent) && onEditId && (
<MenuItem <MenuItem
size="300" size="300"

View file

@ -0,0 +1,37 @@
import { useCallback, useEffect, useRef, useState } from 'react';
/**
* Temporarily sets a boolean state.
*
* @param duration - Duration in milliseconds before resetting (default: 1500)
* @param initial - Initial value (default: false)
*/
export function useTimeoutToggle(duration = 1500, initial = false): [boolean, () => void] {
const [active, setActive] = useState(initial);
const timeoutRef = useRef<number | null>(null);
const clear = () => {
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
const trigger = useCallback(() => {
setActive(!initial);
clear();
timeoutRef.current = window.setTimeout(() => {
setActive(initial);
timeoutRef.current = null;
}, duration);
}, [duration, initial]);
useEffect(
() => () => {
clear();
},
[]
);
return [active, trigger];
}

View file

@ -1,5 +1,13 @@
/* eslint-disable jsx-a11y/alt-text */ /* eslint-disable jsx-a11y/alt-text */
import React, { ComponentPropsWithoutRef, ReactEventHandler, Suspense, lazy } from 'react'; import React, {
ComponentPropsWithoutRef,
ReactEventHandler,
Suspense,
lazy,
useCallback,
useMemo,
useState,
} from 'react';
import { import {
Element, Element,
Text as DOMText, Text as DOMText,
@ -9,10 +17,11 @@ import {
} from 'html-react-parser'; } from 'html-react-parser';
import { MatrixClient } from 'matrix-js-sdk'; import { MatrixClient } from 'matrix-js-sdk';
import classNames from 'classnames'; import classNames from 'classnames';
import { Scroll, Text } from 'folds'; import { Icon, IconButton, Icons, Scroll, Text } from 'folds';
import { IntermediateRepresentation, Opts as LinkifyOpts, OptFn } from 'linkifyjs'; import { IntermediateRepresentation, Opts as LinkifyOpts, OptFn } from 'linkifyjs';
import Linkify from 'linkify-react'; import Linkify from 'linkify-react';
import { ErrorBoundary } from 'react-error-boundary'; import { ErrorBoundary } from 'react-error-boundary';
import { ChildNode } from 'domhandler';
import * as css from '../styles/CustomHtml.css'; import * as css from '../styles/CustomHtml.css';
import { import {
getMxIdLocalPart, getMxIdLocalPart,
@ -31,7 +40,8 @@ import {
testMatrixTo, testMatrixTo,
} from './matrix-to'; } from './matrix-to';
import { onEnterOrSpace } from '../utils/keyboard'; import { onEnterOrSpace } from '../utils/keyboard';
import { tryDecodeURIComponent } from '../utils/dom'; import { copyToClipboard, tryDecodeURIComponent } from '../utils/dom';
import { useTimeoutToggle } from '../hooks/useTimeoutToggle';
const ReactPrism = lazy(() => import('./react-prism/ReactPrism')); const ReactPrism = lazy(() => import('./react-prism/ReactPrism'));
@ -195,6 +205,82 @@ export const highlightText = (
); );
}); });
export function CodeBlock(children: ChildNode[], opts: HTMLReactParserOptions) {
const LINE_LIMIT = 14;
/**
* Recursively extracts and concatenates all text content from an array of ChildNode objects.
*
* @param {ChildNode[]} nodes - An array of ChildNode objects to extract text from.
* @returns {string} The concatenated plain text content of all descendant text nodes.
*/
const extractTextFromChildren = useCallback((nodes: ChildNode[]): string => {
let text = '';
nodes.forEach((node) => {
if (node.type === 'text') {
text += node.data;
} else if (node instanceof Element && node.children) {
text += extractTextFromChildren(node.children);
}
});
return text;
}, []);
const [copied, setCopied] = useTimeoutToggle();
const collapsible = useMemo(
() => extractTextFromChildren(children).split('\n').length > LINE_LIMIT,
[children, extractTextFromChildren]
);
const [collapsed, setCollapsed] = useState(collapsible);
const handleCopy = useCallback(() => {
copyToClipboard(extractTextFromChildren(children));
setCopied();
}, [children, extractTextFromChildren, setCopied]);
const toggleCollapse = useCallback(() => {
setCollapsed((prev) => !prev);
}, []);
return (
<>
<div className={css.CodeBlockControls}>
<IconButton
variant="Secondary" // Needs a better copy icon
size="300"
radii="300"
onClick={handleCopy}
aria-label="Copy Code Block"
>
<Icon src={copied ? Icons.Check : Icons.File} size="50" />
</IconButton>
{collapsible && (
<IconButton
variant="Secondary"
size="300"
radii="300"
onClick={toggleCollapse}
aria-expanded={!collapsed}
aria-pressed={!collapsed}
aria-controls="code-block-content"
aria-label={collapsed ? 'Show Full Code Block' : 'Show Code Block Preview'}
style={collapsed ? { visibility: 'visible' } : {}}
>
<Icon src={collapsed ? Icons.ChevronBottom : Icons.ChevronTop} size="50" />
</IconButton>
)}
</div>
<Scroll direction="Both" variant="Secondary" size="300" visibility="Hover" hideTrack>
<div id="code-block-content" className={css.CodeBlockInternal({ collapsed })}>
{domToReact(children, opts)}
</div>
</Scroll>
</>
);
}
export const getReactCustomHtmlParser = ( export const getReactCustomHtmlParser = (
mx: MatrixClient, mx: MatrixClient,
roomId: string | undefined, roomId: string | undefined,
@ -271,15 +357,7 @@ export const getReactCustomHtmlParser = (
if (name === 'pre') { if (name === 'pre') {
return ( return (
<Text {...props} as="pre" className={css.CodeBlock}> <Text {...props} as="pre" className={css.CodeBlock}>
<Scroll {CodeBlock(children, opts)}
direction="Horizontal"
variant="Secondary"
size="300"
visibility="Hover"
hideTrack
>
<div className={css.CodeBlockInternal}>{domToReact(children, opts)}</div>
</Scroll>
</Text> </Text>
); );
} }

View file

@ -85,10 +85,35 @@ export const CodeBlock = style([
MarginSpaced, MarginSpaced,
{ {
fontStyle: 'normal', fontStyle: 'normal',
position: 'relative',
}, },
]); ]);
export const CodeBlockInternal = style({ export const CodeBlockInternal = recipe({
padding: `${config.space.S200} ${config.space.S200} 0`, base: {
padding: `${config.space.S200} ${config.space.S200} 0`,
minWidth: toRem(100),
},
variants: {
collapsed: {
true: {
maxHeight: `calc(${config.lineHeight.T400} * 9.6)`,
},
},
},
});
export const CodeBlockControls = style({
position: 'absolute',
top: config.space.S200,
right: config.space.S200,
visibility: 'hidden',
selectors: {
[`${CodeBlock}:hover &`]: {
visibility: 'visible',
},
[`${CodeBlock}:focus-within &`]: {
visibility: 'visible',
},
},
}); });
export const List = style([ export const List = style([

View file

@ -294,9 +294,14 @@ export const getDirectRoomAvatarUrl = (
useAuthentication = false useAuthentication = false
): string | undefined => { ): string | undefined => {
const mxcUrl = room.getAvatarFallbackMember()?.getMxcAvatarUrl(); const mxcUrl = room.getAvatarFallbackMember()?.getMxcAvatarUrl();
return mxcUrl
? mx.mxcUrlToHttp(mxcUrl, size, size, 'crop', undefined, false, useAuthentication) ?? undefined if (!mxcUrl) {
: undefined; return getRoomAvatarUrl(mx, room, size, useAuthentication);
}
return (
mx.mxcUrlToHttp(mxcUrl, size, size, 'crop', undefined, false, useAuthentication) ?? undefined
);
}; };
export const trimReplyFromBody = (body: string): string => { export const trimReplyFromBody = (body: string): string => {