render reply in single line and render custom html

This commit is contained in:
Ajay Bura 2026-08-16 21:38:26 +05:30
commit 95bfa25b58
17 changed files with 632 additions and 228 deletions

View file

@ -0,0 +1,136 @@
import React from 'react';
import { HTMLReactParserOptions } from 'html-react-parser';
import { Opts } from 'linkifyjs';
import { Text } from 'folds';
import {
MessageAudioContent,
MessageFileContent,
MessageImageContent,
MessageStickerContent,
MessageVideoContent,
UnsupportedContent,
ReplyMEmote,
ReplyMNotice,
ReplyMText,
ReplyMLocation,
} from './message';
import { MsgType } from '../../types/matrix/common';
import { rContent } from '../../client/reader';
import { MessageEvent } from '../../types/matrix/room';
type RenderReplyContentProps = {
displayName: string;
eventType: string;
edited?: boolean;
content: object;
highlightRegex?: RegExp;
htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts;
};
export function RenderReplyContent({
displayName,
eventType,
edited,
content,
highlightRegex,
htmlReactParserOptions,
linkifyOpts,
}: RenderReplyContentProps) {
if (eventType === MessageEvent.Sticker) {
return (
<Text size="T300" priority="300">
<MessageStickerContent />
</Text>
);
}
if (eventType !== MessageEvent.RoomMessage) {
return <UnsupportedContent size="T300" />;
}
const msgType = rContent.msgtype(content);
if (msgType === MsgType.Text) {
return (
<ReplyMText
edited={edited}
content={content}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
);
}
if (msgType === MsgType.Emote) {
return (
<ReplyMEmote
displayName={displayName}
edited={edited}
content={content}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
);
}
if (msgType === MsgType.Notice) {
return (
<ReplyMNotice
edited={edited}
content={content}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
);
}
if (msgType === MsgType.Image) {
return (
<Text size="T300" priority="300">
<MessageImageContent />
</Text>
);
}
if (msgType === MsgType.Video) {
return (
<Text size="T300" priority="300">
<MessageVideoContent />
</Text>
);
}
if (msgType === MsgType.Audio) {
return (
<Text size="T300" priority="300">
<MessageAudioContent />
</Text>
);
}
if (msgType === MsgType.File) {
return (
<Text size="T300" priority="300">
<MessageFileContent />
</Text>
);
}
if (msgType === MsgType.Location) {
return <ReplyMLocation content={content} />;
}
return (
<ReplyMText
edited={edited}
content={content}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
);
}

View file

@ -25,36 +25,41 @@ import { rContent, rEncryptedFile, rInfo } from '../../../client/reader';
import { UrlPreviewCard, UrlPreviewHolder } from '../url-preview';
import { testMatrixTo } from '../../plugins/matrix-to';
export function MBadEncrypted() {
type TextProps = {
size?: 'T400' | 'T300';
};
export function MBadEncrypted({ size }: TextProps) {
return (
<Text>
<Text size={size}>
<MessageBadEncryptedContent />
</Text>
);
}
type RedactedContentProps = {
type RedactedContentProps = TextProps & {
reason?: string;
};
export function RedactedContent({ reason }: RedactedContentProps) {
export function RedactedContent({ size, reason }: RedactedContentProps) {
return (
<Text>
<Text size={size}>
<MessageDeletedContent reason={reason} />
</Text>
);
}
export function UnsupportedContent() {
export function UnsupportedContent({ size }: TextProps) {
return (
<Text>
<Text size={size}>
<MessageUnsupportedContent />
</Text>
);
}
export function BrokenContent() {
export function BrokenContent({ size }: TextProps) {
return (
<Text>
<Text size={size}>
<MessageBrokenContent />
</Text>
);

View file

@ -0,0 +1,130 @@
import React, { CSSProperties } from 'react';
import { Text } from 'folds';
import { HTMLReactParserOptions } from 'html-react-parser';
import { Opts } from 'linkifyjs';
import { trimReplyFromBody } from '../../utils/room';
import { MessageTextBody } from './layout';
import { MessageEditedContent } from './content';
import { RenderBody } from './RenderBody';
import { rContent } from '../../../client/reader';
import { parseGeoUri } from '../../utils/common';
import { BrokenContent } from './MsgTypeRenderers';
type ReplyMTextProps = {
edited?: boolean;
content: object;
style?: CSSProperties;
highlightRegex?: RegExp;
htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts;
};
export function ReplyMText({
edited,
content,
style,
highlightRegex,
linkifyOpts,
htmlReactParserOptions,
}: ReplyMTextProps) {
const body = rContent.body(content);
const customBody = rContent.formattedBody(content);
const trimmedBody = trimReplyFromBody(body ?? '');
if (typeof body !== 'string') return <BrokenContent />;
return (
<MessageTextBody size="T300" style={style} truncate>
<RenderBody
body={trimmedBody}
customBody={customBody}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
{edited && <MessageEditedContent />}
</MessageTextBody>
);
}
type ReplyMEmoteProps = {
displayName: string;
edited?: boolean;
content: object;
highlightRegex?: RegExp;
htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts;
};
export function ReplyMEmote({
displayName,
edited,
content,
highlightRegex,
linkifyOpts,
htmlReactParserOptions,
}: ReplyMEmoteProps) {
const body = rContent.body(content);
const customBody = rContent.formattedBody(content);
if (typeof body !== 'string') return <BrokenContent />;
const trimmedBody = trimReplyFromBody(body);
return (
<MessageTextBody size="T300" emote preWrap={typeof customBody !== 'string'} truncate>
<b>{`${displayName} `}</b>
<RenderBody
body={trimmedBody}
customBody={customBody}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
{edited && <MessageEditedContent />}
</MessageTextBody>
);
}
type ReplyMNoticeProps = {
edited?: boolean;
content: object;
highlightRegex?: RegExp;
htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts;
};
export function ReplyMNotice({
edited,
content,
highlightRegex,
linkifyOpts,
htmlReactParserOptions,
}: ReplyMNoticeProps) {
const body = rContent.body(content);
const customBody = rContent.formattedBody(content);
if (typeof body !== 'string') return <BrokenContent />;
const trimmedBody = trimReplyFromBody(body);
return (
<MessageTextBody size="T300" notice preWrap={typeof customBody !== 'string'} truncate>
<RenderBody
body={trimmedBody}
customBody={customBody}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
{edited && <MessageEditedContent />}
</MessageTextBody>
);
}
type ReplyMLocationProps = {
content: object;
};
export function ReplyMLocation({ content }: ReplyMLocationProps) {
const geoUri = rContent.geoUri(content);
if (!geoUri) return <BrokenContent />;
const location = parseGeoUri(geoUri);
if (!location) return <BrokenContent />;
return <Text size="T300">{geoUri}</Text>;
}

View file

@ -1,26 +1,11 @@
import React from 'react';
import parse, { HTMLReactParserOptions } from 'html-react-parser';
import Linkify from 'linkify-react';
import { Chip, Text } from 'folds';
import { Opts } from 'linkifyjs';
import { MessageEmptyContent } from './content';
import { sanitizeCustomHtml } from '../../utils/sanitize';
import { highlightText, scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
export function shouldTruncate(
body: string,
customBody?: string,
maxLines = 15,
charPerLine = 100
) {
// Count text characters (strip HTML tags)
const text = customBody?.replace(/<[^>]+>/g, '') || body;
const newlines = (body.match(/\n/g) || []).length;
return text.length > maxLines * charPerLine || newlines > maxLines;
}
const NEXT_LINE_ONLY_REGEX = /^\n+$/;
type RenderBodyProps = {
@ -50,30 +35,3 @@ export function RenderBody({
</Linkify>
);
}
export function ViewMoreLessButton({
collapsed,
onToggle,
}: {
collapsed: boolean;
onToggle: () => void;
}) {
return (
<Chip
type="button"
variant="Primary"
fill="Soft"
radii="300"
onClick={onToggle}
outlined
style={{
position: collapsed ? 'sticky' : 'initial',
bottom: '0',
borderBottomLeftRadius: collapsed ? 0 : undefined,
borderBottomRightRadius: collapsed ? 0 : undefined,
}}
>
<Text size="L400">{collapsed ? 'View More' : 'View Less'}</Text>
</Chip>
);
}

View file

@ -7,7 +7,7 @@ export const ReplyBar = style({
marginLeft: 'auto',
width: toRem(19),
height: `calc(100% - ${toRem(8)})`,
height: `calc(100% - ${toRem(4)})`,
borderLeft: `${config.borderWidth.B500} solid ${color.Surface.ContainerLine}`,
borderTop: `${config.borderWidth.B500} solid ${color.Surface.ContainerLine}`,

View file

@ -1,4 +1,4 @@
import { Box, Text, color, toRem } from 'folds';
import { Box, Text, color, config, toRem } from 'folds';
import React, { MouseEventHandler, ReactNode, useCallback, useMemo } from 'react';
import { HTMLReactParserOptions } from 'html-react-parser';
import { Opts } from 'linkifyjs';
@ -12,30 +12,60 @@ import { GetMemberPowerTag } from '../../hooks/useMemberPowerTag';
import { GetRoomMemberNameCallback } from '../../hooks/useRoomMembers';
import { rUnsigned } from '../../../client/reader';
import { TimelineView } from '../../../client/room';
import { RenderMessageContent } from '../RenderMessageContent';
import { RenderReplyContent } from '../RenderReplyContent';
import { Time } from './Time';
import { BubbleLayout, CompactLayout, MessageBase, ModernLayout } from './layout';
import { MessageLayout, MessageSpacing } from '../../state/settings';
import { RedactedContent } from './MsgTypeRenderers';
import { HoverUnderline } from '../../styles/Text.css';
type ReplyMessageProps = {
username?: ReactNode;
time?: ReactNode;
messageLayout: MessageLayout;
children: ReactNode;
onClick?: () => void;
};
export function ReplyMessage({ username, time, messageLayout, children }: ReplyMessageProps) {
export function ReplyMessage({
username,
time,
messageLayout,
onClick,
children,
}: ReplyMessageProps) {
const msgBarJSX = <div className={css.ReplyBar} />;
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (!onClick) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onClick();
}
};
const commonProps = onClick
? {
role: 'button' as const,
tabIndex: 0,
onClick,
onKeyDown: handleKeyDown,
className: HoverUnderline,
style: { cursor: 'pointer' },
}
: {};
if (messageLayout === MessageLayout.Compact) {
return (
<CompactLayout before={msgBarJSX}>
<CompactLayout before={msgBarJSX} {...commonProps}>
<Box grow="Yes" direction="Column">
<Box alignItems="Baseline" shrink="No" gap="200">
{username}
<Box alignItems="Center" shrink="No" gap="200">
<Box shrink="No" style={{ maxWidth: toRem(200) }}>
{username}
</Box>
{children}
{time}
</Box>
{children}
</Box>
</CompactLayout>
);
@ -43,28 +73,27 @@ export function ReplyMessage({ username, time, messageLayout, children }: ReplyM
if (messageLayout === MessageLayout.Bubble) {
return (
<BubbleLayout
hideBubble
before={msgBarJSX}
header={
<Box alignItems="Baseline" shrink="No" gap="200">
<BubbleLayout hideBubble before={msgBarJSX} {...commonProps}>
<Box alignItems="Center" shrink="No" gap="200">
<Box shrink="No" style={{ maxWidth: toRem(200) }}>
{username}
{time}
</Box>
}
>
{children}
{children}
{time}
</Box>
</BubbleLayout>
);
}
return (
<ModernLayout before={msgBarJSX}>
<Box alignItems="Baseline" shrink="No" gap="200">
{username}
<ModernLayout before={msgBarJSX} {...commonProps}>
<Box alignItems="Center" shrink="No" gap="200">
<Box shrink="No" style={{ maxWidth: toRem(200) }}>
{username}
</Box>
{children}
{time}
</Box>
{children}
</ModernLayout>
);
}
@ -72,7 +101,7 @@ export function ReplyMessage({ username, time, messageLayout, children }: ReplyM
type ReplyProps = {
roomId: string;
eventId: string;
onClick?: MouseEventHandler | undefined;
onClick?: (replyId: string) => void;
getMemberPowerTag?: GetMemberPowerTag;
accessibleTagColors?: Map<string, string>;
legacyUsernameColor?: boolean;
@ -121,9 +150,11 @@ export function Reply({
const redactedEvent = redacted || !!rUnsigned.redactedBecause(replyEvent?.unsigned);
// TODO: make edit message appear in reply
return (
<MessageBase space={messageSpacing}>
<ReplyMessage
onClick={onClick ? () => onClick(eventId) : undefined}
messageLayout={messageLayout}
username={
sender && (
@ -145,20 +176,18 @@ export function Reply({
>
{replyEvent !== undefined ? (
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }} as="div">
{redactedEvent && <RedactedContent />}
{redactedEvent && <RedactedContent size="T300" />}
{!redactedEvent &&
(replyEvent === null ? (
<Text>
<Text size="B300">
<MessageBadEncryptedContent />
</Text>
) : (
<RenderMessageContent
<RenderReplyContent
displayName={getMemberName(replyEvent.sender)}
eventType={replyEvent.type}
ts={replyEvent.origin_server_ts}
edited={false}
edited={false} // TODO:
content={replyEvent.content}
mediaAutoLoad={mediaAutoLoad}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
@ -170,6 +199,7 @@ export function Reply({
backgroundColor: color.SurfaceVariant.ContainerActive,
width: toRem(placeholderWidth),
maxWidth: '100%',
height: config.lineHeight.T300,
}}
/>
)}

View file

@ -64,3 +64,38 @@ export const MessageEditedContent = as<'span', { children?: never }>(({ ...props
{' (edited)'}
</Text>
));
export const MessageStickerContent = as<'div', { children?: never }>(({ ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" {...props} ref={ref}>
<Icon size="50" src={Icons.Sticker} filled />
<i>Sticker</i>
</Box>
));
export const MessageImageContent = as<'div', { children?: never }>(({ ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" {...props} ref={ref}>
<Icon size="50" src={Icons.Photo} filled />
<i>Image</i>
</Box>
));
export const MessageVideoContent = as<'div', { children?: never }>(({ ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" {...props} ref={ref}>
<Icon size="50" src={Icons.Vlc} filled />
<i>Video</i>
</Box>
));
export const MessageAudioContent = as<'div', { children?: never }>(({ ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" {...props} ref={ref}>
<Icon size="50" src={Icons.Play} filled />
<i>Audio</i>
</Box>
));
export const MessageFileContent = as<'div', { children?: never }>(({ ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" {...props} ref={ref}>
<Icon size="50" src={Icons.File} filled />
<i>File</i>
</Box>
));

View file

@ -6,5 +6,6 @@ export * from './Reply';
export * from './content';
export * from './Time';
export * from './MsgTypeRenderers';
export * from './MsgTypeReplyRenderers';
export * from './FileHeader';
export * from './RenderBody';

View file

@ -28,19 +28,20 @@ export const UsernameBold = as<'b'>(({ as: AsUsernameBold = 'b', className, ...p
<AsUsernameBold className={classNames(css.UsernameBold, className)} {...props} ref={ref} />
));
export const MessageTextBody = as<'div', css.MessageTextBodyVariants & { notice?: boolean }>(
export const MessageTextBody = as<
'div',
css.MessageTextBodyVariants & { size?: 'T400' | 'T300'; notice?: boolean; truncate?: boolean }
>(
(
{ as: asComp = 'div', className, preWrap, jumboEmoji, emote, notice, truncate, ...props },
{ as: asComp = 'div', className, size, preWrap, jumboEmoji, emote, notice, truncate, ...props },
ref
) => (
<Text
as={asComp}
size="T400"
size={size ?? 'T400'}
priority={notice ? '300' : '400'}
className={classNames(
css.MessageTextBody({ preWrap, jumboEmoji, emote, truncate }),
className
)}
className={classNames(css.MessageTextBody({ preWrap, jumboEmoji, emote }), className)}
truncate={truncate}
{...props}
ref={ref}
/>

View file

@ -201,27 +201,6 @@ export const MessageTextBody = recipe({
fontStyle: 'italic',
},
},
truncate: {
true: {
maxHeight: '15em',
overflow: 'hidden',
selectors: {
'&::before': {
content: '',
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
pointerEvents: 'none',
height: config.space.S400,
background: `linear-gradient(to top, ${color.Primary.Container}, #00000000)`,
borderBottom: `${config.borderWidth.B400} solid ${color.Primary.ContainerLine}`,
},
},
},
},
},
});

View file

@ -55,10 +55,14 @@ export function Room() {
useCallback(
(evt) => {
if (isKeyHotkey('escape', evt)) {
// TODO: check overlay's
if (activeThreadId && threadFocusRef.current) {
timeline?.closeThread();
}
// markAsRead(mx, roomId, hideActivity);
}
},
[roomId, hideActivity]
[activeThreadId, timeline]
)
);

View file

@ -9,11 +9,9 @@ import React, {
useRef,
useState,
} from 'react';
import { HTMLReactParserOptions } from 'html-react-parser';
import { ReactEditor } from 'slate-react';
import { Editor } from 'slate';
import { Box, Scroll, config, toRem } from 'folds';
import { Opts as LinkifyOpts } from 'linkifyjs';
import { MessageEventWithoutRoomId } from 'neurone/lib/api/types';
import { useVirtualPaginator } from '../../hooks/useVirtualPaginator';
import { useAlive } from '../../hooks/useAlive';
@ -24,13 +22,6 @@ import {
Reply,
RedactedContent,
} from '../../components/message';
import {
factoryRenderLinkifyWithMention,
getReactCustomHtmlParser,
LINKIFY_OPTS,
makeMentionCustomProps,
renderMatrixMention,
} from '../../plugins/react-custom-html-parser';
import { getLatestEditEvent, getReplyId, getThreadId } from '../../utils/room';
import { useSetting } from '../../state/hooks/settings';
import { MessageLayout, settingsAtom } from '../../state/settings';
@ -39,10 +30,7 @@ import { RoomIntro } from '../../components/room-intro';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { MessageEvent, StateEvent } from '../../../types/matrix/room';
import { RenderMessageContent } from '../../components/RenderMessageContent';
import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
@ -79,6 +67,7 @@ import { TimelineFloat, JumpToLatestButton, ComboDivider, PlaceholderList } from
import { OutboxView } from './OutboxView';
import { EncryptedMessageEvent } from '../../components/encrypted-event';
import { useUserReadReceipt } from '../../hooks/useReadReceipt';
import { useMessageParserOptions } from '../../hooks/useMessageParserOptions';
type RoomTimelineProps = {
eventId?: string;
@ -92,7 +81,6 @@ const PAGINATION_LIMIT = 80;
export function RoomTimeline({ eventId, roomInputRef, editor, timeline }: RoomTimelineProps) {
const ct = useClient();
const roomId = useRoom();
const useAuthentication = useMediaAuthentication();
const encryptedRooms = useObservableValue(ct.encryptedRooms);
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
@ -143,8 +131,6 @@ export function RoomTimeline({ eventId, roomInputRef, editor, timeline }: RoomTi
// const roomToParents = useAtomValue(roomToParentsAtom);
// const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const { navigateRoom } = useRoomNavigate();
const mentionClickHandler = useMentionClickHandler(roomId);
const spoilerClickHandler = useSpoilerClickHandler();
const imagePackRooms = useImagePackRooms(roomId);
@ -166,43 +152,8 @@ export function RoomTimeline({ eventId, roomInputRef, editor, timeline }: RoomTi
>();
const alive = useAlive();
const linkifyOpts = useMemo<LinkifyOpts>(
() => ({
...LINKIFY_OPTS,
render: factoryRenderLinkifyWithMention((href) =>
renderMatrixMention(
ct,
roomId,
href,
makeMentionCustomProps(mentionClickHandler),
membersAPI.getName,
getRoomName
)
),
}),
[ct, roomId, mentionClickHandler, membersAPI.getName, getRoomName]
);
const htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
() =>
getReactCustomHtmlParser(ct, roomId, {
linkifyOpts,
useAuthentication,
handleSpoilerClick: spoilerClickHandler,
handleMentionClick: mentionClickHandler,
getMemberName: membersAPI.getName,
getRoomName,
}),
[
ct,
roomId,
linkifyOpts,
spoilerClickHandler,
mentionClickHandler,
useAuthentication,
membersAPI.getName,
getRoomName,
]
);
const { linkifyOpts, htmlReactParserOptions, htmlReactParserInlineOptions } =
useMessageParserOptions(roomId, getRoomName, membersAPI.getName);
const activeThread = useObservableValue(timeline.activeThread);
const threads = useObservableValue(timeline.threads);
@ -315,10 +266,8 @@ export function RoomTimeline({ eventId, roomInputRef, editor, timeline }: RoomTi
useObservableValueListener(timeline.outbox.senders, stickToBottom);
useObservableValueListener(timeline.range.info, stickToBottom);
const handleOpenReply: MouseEventHandler = useCallback(async (evt) => {
const targetId = evt.currentTarget.getAttribute('data-event-id');
if (!targetId) return;
console.log(targetId);
const handleOpenReply = useCallback((replyId: string) => {
console.log(replyId);
}, []);
const handleUserClick = useUserClickHandler();
@ -553,7 +502,7 @@ export function RoomTimeline({ eventId, roomInputRef, editor, timeline }: RoomTi
redacted={redacted.has(replyEventId)}
getMemberName={membersAPI.getName}
timeline={timeline}
htmlReactParserOptions={htmlReactParserOptions}
htmlReactParserOptions={htmlReactParserInlineOptions}
linkifyOpts={linkifyOpts}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}

View file

@ -4,6 +4,7 @@ import { DefaultReset, color, config, toRem } from 'folds';
export const TimelineDivider = style({
position: 'relative',
minHeight: toRem(20),
});
export const TimelineDividerCenterChild = style({
position: 'absolute',

View file

@ -7,10 +7,8 @@ import React, {
useRef,
useState,
} from 'react';
import { HTMLReactParserOptions } from 'html-react-parser';
import { ReactEditor } from 'slate-react';
import { Box, Scroll, Text, config } from 'folds';
import { Opts as LinkifyOpts } from 'linkifyjs';
import { MessageEventWithoutRoomId } from 'neurone/lib/api/types';
import { useVirtualPaginator } from '../../../hooks/useVirtualPaginator';
import { useAlive } from '../../../hooks/useAlive';
@ -21,13 +19,6 @@ import {
Reply,
DividerTime,
} from '../../../components/message';
import {
factoryRenderLinkifyWithMention,
getReactCustomHtmlParser,
LINKIFY_OPTS,
makeMentionCustomProps,
renderMatrixMention,
} from '../../../plugins/react-custom-html-parser';
import { fallbackReply, getLatestEditEvent, getReplyId } from '../../../utils/room';
import { useSetting } from '../../../state/hooks/settings';
import { MessageLayout, settingsAtom } from '../../../state/settings';
@ -36,9 +27,6 @@ import * as css from './styles.css';
import { usePowerLevelsContext } from '../../../hooks/usePowerLevels';
import { MessageEvent, StateEvent } from '../../../../types/matrix/room';
import { RenderMessageContent } from '../../../components/RenderMessageContent';
import { useMentionClickHandler } from '../../../hooks/useMentionClickHandler';
import { useSpoilerClickHandler } from '../../../hooks/useSpoilerClickHandler';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers';
import { useImagePackRooms } from '../../../hooks/useImagePackRooms';
import { useIsDirectRoom, useRoom } from '../../../hooks/useRoom';
@ -75,6 +63,7 @@ import { useEditor } from '../../../components/editor';
import { TimelineFloat, JumpToLatestButton, ComboDivider, PlaceholderList } from '../components';
import { OutboxView } from '../OutboxView';
import { useUserReadReceipt } from '../../../hooks/useReadReceipt';
import { useMessageParserOptions } from '../../../hooks/useMessageParserOptions';
type TimelineViewProps = {
threadTimeline: ThreadTimeline;
@ -85,7 +74,6 @@ const PAGINATION_LIMIT = 80;
export function TimelineView({ threadTimeline }: TimelineViewProps) {
const ct = useClient();
const roomId = useRoom();
const useAuthentication = useMediaAuthentication();
const encryptedRooms = useObservableValue(ct.encryptedRooms);
const editor = useEditor();
const roomInputRef = useRef<HTMLDivElement>(null);
@ -134,8 +122,6 @@ export function TimelineView({ threadTimeline }: TimelineViewProps) {
const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, ct.session.userId);
const [editId, setEditId] = useState<string>();
const mentionClickHandler = useMentionClickHandler(roomId);
const spoilerClickHandler = useSpoilerClickHandler();
const imagePackRooms = useImagePackRooms(roomId);
const scrollRef = useRef<HTMLDivElement>(null);
@ -154,44 +140,8 @@ export function TimelineView({ threadTimeline }: TimelineViewProps) {
>();
const alive = useAlive();
const linkifyOpts = useMemo<LinkifyOpts>(
() => ({
...LINKIFY_OPTS,
render: factoryRenderLinkifyWithMention((href) =>
renderMatrixMention(
ct,
roomId,
href,
makeMentionCustomProps(mentionClickHandler),
membersAPI.getName,
getRoomName
)
),
}),
[ct, roomId, mentionClickHandler, membersAPI.getName, getRoomName]
);
const htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
() =>
getReactCustomHtmlParser(ct, roomId, {
linkifyOpts,
useAuthentication,
handleSpoilerClick: spoilerClickHandler,
handleMentionClick: mentionClickHandler,
getMemberName: membersAPI.getName,
getRoomName,
}),
[
ct,
roomId,
linkifyOpts,
spoilerClickHandler,
mentionClickHandler,
useAuthentication,
membersAPI.getName,
getRoomName,
]
);
const { linkifyOpts, htmlReactParserOptions, htmlReactParserInlineOptions } =
useMessageParserOptions(roomId, getRoomName, membersAPI.getName);
const redacted = useObservableValue(threadTimeline.relations.redacted);
const reactions = useObservableValue(threadTimeline.relations.reactions.eventToRelations);
@ -296,10 +246,8 @@ export function TimelineView({ threadTimeline }: TimelineViewProps) {
}
}, [scrollToElement, editId]);
const handleOpenReply: MouseEventHandler = useCallback(async (evt) => {
const targetId = evt.currentTarget.getAttribute('data-event-id');
if (!targetId) return;
console.log(targetId);
const handleOpenReply = useCallback((replyId: string) => {
console.log(replyId);
}, []);
const handleUserClick = useUserClickHandler();
@ -450,7 +398,7 @@ export function TimelineView({ threadTimeline }: TimelineViewProps) {
redacted={redacted.has(replyEventId)}
getMemberName={membersAPI.getName}
timeline={threadTimeline}
htmlReactParserOptions={htmlReactParserOptions}
htmlReactParserOptions={htmlReactParserInlineOptions}
linkifyOpts={linkifyOpts}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}

View file

@ -0,0 +1,86 @@
import { useMemo } from 'react';
import { Opts as LinkifyOpts } from 'linkifyjs';
import { HTMLReactParserOptions } from 'html-react-parser';
import {
factoryRenderLinkifyWithMention,
getReactCustomHtmlInlineParser,
getReactCustomHtmlParser,
LINKIFY_OPTS,
makeMentionCustomProps,
renderMatrixMention,
} from '../plugins/react-custom-html-parser';
import { useClient } from './useClient';
import { useMentionClickHandler } from './useMentionClickHandler';
import { useSpoilerClickHandler } from './useSpoilerClickHandler';
import { GetRoomNameCallback } from './storage/useGetRoomName';
import { GetRoomMemberNameCallback } from './useRoomMembers';
import { useMediaAuthentication } from './useMediaAuthentication';
export const useMessageParserOptions = (
roomId: string,
getRoomName: GetRoomNameCallback,
getMemberName: GetRoomMemberNameCallback
) => {
const ct = useClient();
const useAuthentication = useMediaAuthentication();
const mentionClickHandler = useMentionClickHandler(roomId);
const spoilerClickHandler = useSpoilerClickHandler();
const linkifyOpts = useMemo<LinkifyOpts>(
() => ({
...LINKIFY_OPTS,
render: factoryRenderLinkifyWithMention((href) =>
renderMatrixMention(
ct,
roomId,
href,
makeMentionCustomProps(mentionClickHandler),
getMemberName,
getRoomName
)
),
}),
[ct, roomId, mentionClickHandler, getMemberName, getRoomName]
);
const htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
() =>
getReactCustomHtmlParser(ct, roomId, {
linkifyOpts,
useAuthentication,
handleSpoilerClick: spoilerClickHandler,
handleMentionClick: mentionClickHandler,
getMemberName,
getRoomName,
}),
[
ct,
roomId,
linkifyOpts,
spoilerClickHandler,
mentionClickHandler,
useAuthentication,
getMemberName,
getRoomName,
]
);
const htmlReactParserInlineOptions = useMemo<HTMLReactParserOptions>(
() =>
getReactCustomHtmlInlineParser(ct, roomId, {
linkifyOpts,
useAuthentication,
handleMentionClick: mentionClickHandler,
getMemberName,
getRoomName,
}),
[ct, roomId, linkifyOpts, useAuthentication, mentionClickHandler, getMemberName, getRoomName]
);
return {
linkifyOpts,
htmlReactParserOptions,
htmlReactParserInlineOptions,
};
};

View file

@ -474,14 +474,15 @@ export const getReactCustomHtmlParser = (
}
if (name === 'img') {
const htmlSrc = mxcToHttp(ct.session.baseUrl, props.src, params.useAuthentication).url();
if (htmlSrc && props.src.startsWith('mxc://') === false) {
if (props.src && props.src.startsWith('mxc://') === false) {
return (
<a href={htmlSrc} target="_blank" rel="noreferrer noopener">
{props.alt || props.title || htmlSrc}
<a href={props.src} target="_blank" rel="noreferrer noopener">
{props.alt || props.title || props.src}
</a>
);
}
const htmlSrc = mxcToHttp(ct.session.baseUrl, props.src, params.useAuthentication).url();
if (htmlSrc && 'data-mx-emoticon' in props) {
return (
<span className={css.EmoticonBase}>
@ -516,3 +517,143 @@ export const getReactCustomHtmlParser = (
};
return opts;
};
export const getReactCustomHtmlInlineParser = (
ct: ClientContext,
roomId: string | undefined,
params: {
linkifyOpts: LinkifyOpts;
highlightRegex?: RegExp;
useAuthentication?: boolean;
handleMentionClick?: ReactEventHandler<HTMLElement>;
getMemberName: GetRoomMemberNameCallback;
getRoomName: GetRoomNameCallback;
}
): HTMLReactParserOptions => {
const opts: HTMLReactParserOptions = {
replace: (domNode) => {
if (domNode instanceof Element && 'name' in domNode) {
const { name, attribs, children, parent } = domNode;
const props = attributesToProps(attribs);
if (
name === 'h1' ||
name === 'h2' ||
name === 'h3' ||
name === 'h4' ||
name === 'h5' ||
name === 'h6'
) {
return <strong>{domToReact(children, opts)} </strong>;
}
if (name === 'br') {
return <span> </span>;
}
if (name === 'p') {
return <span>{domToReact(children, opts)} </span>;
}
if (name === 'pre') {
return <span>{domToReact(children, opts)} </span>;
}
if (name === 'blockquote') {
return <em>{domToReact(children, opts)} </em>;
}
if (name === 'ul') {
return <span>{domToReact(children, opts)} </span>;
}
if (name === 'ol') {
return <span>{domToReact(children, opts)} </span>;
}
if (name === 'code') {
if (parent && 'name' in parent && parent.name === 'pre') {
const codeReact = domToReact(children, opts);
if (typeof codeReact === 'string') {
return <code className={css.CodeFont}>{codeReact}</code>;
}
} else {
return (
<Text as="code" size="T300" className={css.Code} {...props}>
{domToReact(children, opts)}
</Text>
);
}
}
if (name === 'a' && testMatrixTo(tryDecodeURIComponent(props.href))) {
const content = children.find((child) => !(child instanceof DOMText))
? undefined
: children.map((c) => (c instanceof DOMText ? c.data : '')).join();
const mention = renderMatrixMention(
ct,
roomId,
tryDecodeURIComponent(props.href),
makeMentionCustomProps(params.handleMentionClick, content),
params.getMemberName,
params.getRoomName
);
if (mention) return mention;
}
if (name === 'span' && 'data-mx-spoiler' in props) {
return (
<span {...props} className={css.Spoiler()} aria-pressed style={{ cursor: 'pointer' }}>
{domToReact(children, opts)}
</span>
);
}
if (name === 'img') {
if (props.src && props.src.startsWith('mxc://') === false) {
return (
<a href={props.src} target="_blank" rel="noreferrer noopener">
{props.alt || props.title || props.src}
</a>
);
}
const htmlSrc = mxcToHttp(ct.session.baseUrl, props.src, params.useAuthentication).url();
if (htmlSrc && 'data-mx-emoticon' in props) {
return (
<span className={css.EmoticonBase}>
<span className={css.Emoticon()}>
<img {...props} className={css.EmoticonImg} src={htmlSrc} />
</span>
</span>
);
}
if (htmlSrc) return <em>Click to see image</em>;
}
return <span>{domToReact(children, opts)} </span>;
}
if (domNode instanceof DOMText) {
const linkify =
!(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);
if (params.highlightRegex) {
jsx = highlightText(params.highlightRegex, jsx);
}
if (linkify) {
return <Linkify options={params.linkifyOpts}>{jsx}</Linkify>;
}
return jsx;
}
return undefined;
},
};
return opts;
};

View file

@ -47,7 +47,7 @@ const BaseCode = style({
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
borderRadius: config.radii.R300,
});
const CodeFont = style({
export const CodeFont = style({
fontFamily: 'monospace',
});