mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-04 14:30:29 +03:00
Merge branch 'dev' into add-international-date
This commit is contained in:
commit
e2c0984620
8 changed files with 206 additions and 21 deletions
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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%',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
37
src/app/hooks/useTimeoutToggle.ts
Normal file
37
src/app/hooks/useTimeoutToggle.ts
Normal 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];
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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([
|
||||||
|
|
|
||||||
|
|
@ -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 => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue