From 3cdb5c2fe6483e08ea49ff4f90209fd22dc329fc Mon Sep 17 00:00:00 2001 From: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com> Date: Wed, 23 Jul 2025 18:10:56 +0300 Subject: [PATCH 1/4] Add code block copy and collapse functionality (#2361) * add buttons to codeblocks * add functionality * Document functions * Improve accessibility * Remove pointless DefaultReset * implement some requested changes * fix content shift when expanding or collapsing --------- Co-authored-by: Ajay Bura <32841439+ajbura@users.noreply.github.com> --- src/app/components/editor/Elements.tsx | 2 +- src/app/hooks/useTimeoutToggle.ts | 37 +++++++ src/app/plugins/react-custom-html-parser.tsx | 102 ++++++++++++++++--- src/app/styles/CustomHtml.css.ts | 29 +++++- 4 files changed, 155 insertions(+), 15 deletions(-) create mode 100644 src/app/hooks/useTimeoutToggle.ts diff --git a/src/app/components/editor/Elements.tsx b/src/app/components/editor/Elements.tsx index a7438ecd..6a6659b9 100644 --- a/src/app/components/editor/Elements.tsx +++ b/src/app/components/editor/Elements.tsx @@ -162,7 +162,7 @@ export function RenderElement({ attributes, element, children }: RenderElementPr visibility="Hover" hideTrack > -
{children}
+
{children}
); diff --git a/src/app/hooks/useTimeoutToggle.ts b/src/app/hooks/useTimeoutToggle.ts new file mode 100644 index 00000000..7eda99c1 --- /dev/null +++ b/src/app/hooks/useTimeoutToggle.ts @@ -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(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]; +} diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx index cd683e36..04ebacd4 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -1,5 +1,13 @@ /* 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 { Element, Text as DOMText, @@ -9,10 +17,11 @@ import { } from 'html-react-parser'; import { MatrixClient } from 'matrix-js-sdk'; 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 Linkify from 'linkify-react'; import { ErrorBoundary } from 'react-error-boundary'; +import { ChildNode } from 'domhandler'; import * as css from '../styles/CustomHtml.css'; import { getMxIdLocalPart, @@ -31,7 +40,8 @@ import { testMatrixTo, } from './matrix-to'; 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')); @@ -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 ( + <> +
+ + + + {collapsible && ( + + + + )} +
+ +
+ {domToReact(children, opts)} +
+
+ + ); +} + export const getReactCustomHtmlParser = ( mx: MatrixClient, roomId: string | undefined, @@ -271,15 +357,7 @@ export const getReactCustomHtmlParser = ( if (name === 'pre') { return ( - -
{domToReact(children, opts)}
-
+ {CodeBlock(children, opts)}
); } diff --git a/src/app/styles/CustomHtml.css.ts b/src/app/styles/CustomHtml.css.ts index d86a3236..ecbdbeee 100644 --- a/src/app/styles/CustomHtml.css.ts +++ b/src/app/styles/CustomHtml.css.ts @@ -85,10 +85,35 @@ export const CodeBlock = style([ MarginSpaced, { fontStyle: 'normal', + position: 'relative', }, ]); -export const CodeBlockInternal = style({ - padding: `${config.space.S200} ${config.space.S200} 0`, +export const CodeBlockInternal = recipe({ + 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([ From 9073dee9862c898dfdc206dbd6b301008c3bff83 Mon Sep 17 00:00:00 2001 From: Filipe Medeiros Date: Wed, 23 Jul 2025 16:17:17 +0100 Subject: [PATCH 2/4] Add button to start thread on reply (#2320) * add simple button to start a thread on reply * force build * remove useless actions * add actions back * change icon to ThreadPlus * add button to context menu * fix capital T --------- Co-authored-by: Ajay Bura <32841439+ajbura@users.noreply.github.com> --- src/app/features/room/RoomTimeline.tsx | 6 ++-- src/app/features/room/message/Message.tsx | 39 ++++++++++++++++++++++- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 773e115b..f2218b04 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -933,7 +933,7 @@ export function RoomTimeline({ ); const handleReplyClick: MouseEventHandler = useCallback( - (evt) => { + (evt, startThread = false) => { const replyId = evt.currentTarget.getAttribute('data-event-id'); if (!replyId) { console.warn('Button should have "data-event-id" attribute!'); @@ -944,7 +944,9 @@ export function RoomTimeline({ const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet()); const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent(); 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(); if (senderId && typeof body === 'string') { setReplyDraft({ diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index b85605d5..c5de9ea1 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -669,7 +669,10 @@ export type MessageProps = { messageSpacing: MessageSpacing; onUserClick: MouseEventHandler; onUsernameClick: MouseEventHandler; - onReplyClick: MouseEventHandler; + onReplyClick: ( + ev: Parameters>[0], + startThread?: boolean + ) => void; onEditId?: (eventId?: string) => void; onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void; reply?: ReactNode; @@ -859,6 +862,8 @@ export const Message = as<'div', MessageProps>( }, 100); }; + const isThreadedMessage = mEvent.threadRootId !== undefined; + return ( ( > + {!isThreadedMessage && ( + onReplyClick(ev, true)} + data-event-id={mEvent.getId()} + variant="SurfaceVariant" + size="300" + radii="300" + > + + + )} {canEditEvent(mx, mEvent) && onEditId && ( onEditId(mEvent.getId())} @@ -1000,6 +1016,27 @@ export const Message = as<'div', MessageProps>( Reply + {!isThreadedMessage && ( + } + radii="300" + data-event-id={mEvent.getId()} + onClick={(evt: any) => { + onReplyClick(evt, true); + closeMenu(); + }} + > + + Reply in Thread + + + )} {canEditEvent(mx, mEvent) && onEditId && ( Date: Wed, 23 Jul 2025 20:59:32 +0530 Subject: [PATCH 3/4] Fix small height image half clickable view button (#2397) --- src/app/components/message/content/style.css.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/components/message/content/style.css.ts b/src/app/components/message/content/style.css.ts index f6cadd3c..93f3649c 100644 --- a/src/app/components/message/content/style.css.ts +++ b/src/app/components/message/content/style.css.ts @@ -16,6 +16,7 @@ export const AbsoluteContainer = style([ position: 'absolute', top: 0, left: 0, + zIndex: 1, width: '100%', height: '100%', }, From 67b05eeb09c8019fc67564f65ebca3d4b37653c6 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Wed, 23 Jul 2025 21:00:02 +0530 Subject: [PATCH 4/4] Render room avatar as fallback for dm group chat (#2398) * render room avatar for dm group chat * remove extra conditions --- src/app/utils/room.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index cae23514..a962c45d 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -294,9 +294,14 @@ export const getDirectRoomAvatarUrl = ( useAuthentication = false ): string | undefined => { const mxcUrl = room.getAvatarFallbackMember()?.getMxcAvatarUrl(); - return mxcUrl - ? mx.mxcUrlToHttp(mxcUrl, size, size, 'crop', undefined, false, useAuthentication) ?? undefined - : undefined; + + if (!mxcUrl) { + return getRoomAvatarUrl(mx, room, size, useAuthentication); + } + + return ( + mx.mxcUrlToHttp(mxcUrl, size, size, 'crop', undefined, false, useAuthentication) ?? undefined + ); }; export const trimReplyFromBody = (body: string): string => {