diff --git a/src/app/components/RenderReplyContent.tsx b/src/app/components/RenderReplyContent.tsx new file mode 100644 index 00000000..f4a85318 --- /dev/null +++ b/src/app/components/RenderReplyContent.tsx @@ -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 ( + + + + ); + } + + if (eventType !== MessageEvent.RoomMessage) { + return ; + } + + const msgType = rContent.msgtype(content); + + if (msgType === MsgType.Text) { + return ( + + ); + } + + if (msgType === MsgType.Emote) { + return ( + + ); + } + + if (msgType === MsgType.Notice) { + return ( + + ); + } + + if (msgType === MsgType.Image) { + return ( + + + + ); + } + + if (msgType === MsgType.Video) { + return ( + + + + ); + } + + if (msgType === MsgType.Audio) { + return ( + + + + ); + } + + if (msgType === MsgType.File) { + return ( + + + + ); + } + + if (msgType === MsgType.Location) { + return ; + } + + return ( + + ); +} diff --git a/src/app/components/message/MsgTypeRenderers.tsx b/src/app/components/message/MsgTypeRenderers.tsx index 1fde640d..aeb3b721 100644 --- a/src/app/components/message/MsgTypeRenderers.tsx +++ b/src/app/components/message/MsgTypeRenderers.tsx @@ -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 ( - + ); } -type RedactedContentProps = { +type RedactedContentProps = TextProps & { reason?: string; }; -export function RedactedContent({ reason }: RedactedContentProps) { + +export function RedactedContent({ size, reason }: RedactedContentProps) { return ( - + ); } -export function UnsupportedContent() { +export function UnsupportedContent({ size }: TextProps) { return ( - + ); } -export function BrokenContent() { +export function BrokenContent({ size }: TextProps) { return ( - + ); diff --git a/src/app/components/message/MsgTypeReplyRenderers.tsx b/src/app/components/message/MsgTypeReplyRenderers.tsx new file mode 100644 index 00000000..2729afbb --- /dev/null +++ b/src/app/components/message/MsgTypeReplyRenderers.tsx @@ -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 ; + + return ( + + + {edited && } + + ); +} + +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 ; + const trimmedBody = trimReplyFromBody(body); + + return ( + + {`${displayName} `} + + {edited && } + + ); +} + +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 ; + const trimmedBody = trimReplyFromBody(body); + + return ( + + + {edited && } + + ); +} + +type ReplyMLocationProps = { + content: object; +}; +export function ReplyMLocation({ content }: ReplyMLocationProps) { + const geoUri = rContent.geoUri(content); + if (!geoUri) return ; + const location = parseGeoUri(geoUri); + if (!location) return ; + + return {geoUri}; +} diff --git a/src/app/components/message/RenderBody.tsx b/src/app/components/message/RenderBody.tsx index d48c92a6..f9518171 100644 --- a/src/app/components/message/RenderBody.tsx +++ b/src/app/components/message/RenderBody.tsx @@ -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({ ); } - -export function ViewMoreLessButton({ - collapsed, - onToggle, -}: { - collapsed: boolean; - onToggle: () => void; -}) { - return ( - - {collapsed ? 'View More' : 'View Less'} - - ); -} diff --git a/src/app/components/message/Reply.css.ts b/src/app/components/message/Reply.css.ts index 94394f88..24c11682 100644 --- a/src/app/components/message/Reply.css.ts +++ b/src/app/components/message/Reply.css.ts @@ -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}`, diff --git a/src/app/components/message/Reply.tsx b/src/app/components/message/Reply.tsx index f3bc3c5f..32c82a33 100644 --- a/src/app/components/message/Reply.tsx +++ b/src/app/components/message/Reply.tsx @@ -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 =
; + const handleKeyDown = (event: React.KeyboardEvent) => { + 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 ( - + - - {username} + + + {username} + + {children} {time} - {children} ); @@ -43,28 +73,27 @@ export function ReplyMessage({ username, time, messageLayout, children }: ReplyM if (messageLayout === MessageLayout.Bubble) { return ( - + + + {username} - {time} - } - > - {children} + {children} + {time} + ); } return ( - - - {username} + + + + {username} + + {children} {time} - {children} ); } @@ -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; legacyUsernameColor?: boolean; @@ -121,9 +150,11 @@ export function Reply({ const redactedEvent = redacted || !!rUnsigned.redactedBecause(replyEvent?.unsigned); + // TODO: make edit message appear in reply return ( onClick(eventId) : undefined} messageLayout={messageLayout} username={ sender && ( @@ -145,20 +176,18 @@ export function Reply({ > {replyEvent !== undefined ? ( - {redactedEvent && } + {redactedEvent && } {!redactedEvent && (replyEvent === null ? ( - + ) : ( - @@ -170,6 +199,7 @@ export function Reply({ backgroundColor: color.SurfaceVariant.ContainerActive, width: toRem(placeholderWidth), maxWidth: '100%', + height: config.lineHeight.T300, }} /> )} diff --git a/src/app/components/message/content/FallbackContent.tsx b/src/app/components/message/content/FallbackContent.tsx index 9edb9678..8353ee89 100644 --- a/src/app/components/message/content/FallbackContent.tsx +++ b/src/app/components/message/content/FallbackContent.tsx @@ -64,3 +64,38 @@ export const MessageEditedContent = as<'span', { children?: never }>(({ ...props {' (edited)'} )); + +export const MessageStickerContent = as<'div', { children?: never }>(({ ...props }, ref) => ( + + + Sticker + +)); + +export const MessageImageContent = as<'div', { children?: never }>(({ ...props }, ref) => ( + + + Image + +)); + +export const MessageVideoContent = as<'div', { children?: never }>(({ ...props }, ref) => ( + + + Video + +)); + +export const MessageAudioContent = as<'div', { children?: never }>(({ ...props }, ref) => ( + + + Audio + +)); + +export const MessageFileContent = as<'div', { children?: never }>(({ ...props }, ref) => ( + + + File + +)); diff --git a/src/app/components/message/index.ts b/src/app/components/message/index.ts index 6f7415ad..e90e2516 100644 --- a/src/app/components/message/index.ts +++ b/src/app/components/message/index.ts @@ -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'; diff --git a/src/app/components/message/layout/Base.tsx b/src/app/components/message/layout/Base.tsx index b07ae257..a8957c5c 100644 --- a/src/app/components/message/layout/Base.tsx +++ b/src/app/components/message/layout/Base.tsx @@ -28,19 +28,20 @@ export const UsernameBold = as<'b'>(({ as: AsUsernameBold = 'b', className, ...p )); -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 ) => ( diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index 0a61b361..1acd505f 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -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}`, - }, - }, - }, - }, }, }); diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index b171d7eb..85301f94 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -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] ) ); diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 315638af..6ae19132 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -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( - () => ({ - ...LINKIFY_OPTS, - render: factoryRenderLinkifyWithMention((href) => - renderMatrixMention( - ct, - roomId, - href, - makeMentionCustomProps(mentionClickHandler), - membersAPI.getName, - getRoomName - ) - ), - }), - [ct, roomId, mentionClickHandler, membersAPI.getName, getRoomName] - ); - const htmlReactParserOptions = useMemo( - () => - 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} diff --git a/src/app/features/room/components/styles.css.ts b/src/app/features/room/components/styles.css.ts index 440322bb..5d5cecf0 100644 --- a/src/app/features/room/components/styles.css.ts +++ b/src/app/features/room/components/styles.css.ts @@ -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', diff --git a/src/app/features/room/thread-view/TimelineView.tsx b/src/app/features/room/thread-view/TimelineView.tsx index 01275943..e85b5b8d 100644 --- a/src/app/features/room/thread-view/TimelineView.tsx +++ b/src/app/features/room/thread-view/TimelineView.tsx @@ -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(null); @@ -134,8 +122,6 @@ export function TimelineView({ threadTimeline }: TimelineViewProps) { const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, ct.session.userId); const [editId, setEditId] = useState(); - const mentionClickHandler = useMentionClickHandler(roomId); - const spoilerClickHandler = useSpoilerClickHandler(); const imagePackRooms = useImagePackRooms(roomId); const scrollRef = useRef(null); @@ -154,44 +140,8 @@ export function TimelineView({ threadTimeline }: TimelineViewProps) { >(); const alive = useAlive(); - const linkifyOpts = useMemo( - () => ({ - ...LINKIFY_OPTS, - render: factoryRenderLinkifyWithMention((href) => - renderMatrixMention( - ct, - roomId, - href, - makeMentionCustomProps(mentionClickHandler), - membersAPI.getName, - getRoomName - ) - ), - }), - [ct, roomId, mentionClickHandler, membersAPI.getName, getRoomName] - ); - - const htmlReactParserOptions = useMemo( - () => - 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} diff --git a/src/app/hooks/useMessageParserOptions.ts b/src/app/hooks/useMessageParserOptions.ts new file mode 100644 index 00000000..6fadca79 --- /dev/null +++ b/src/app/hooks/useMessageParserOptions.ts @@ -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( + () => ({ + ...LINKIFY_OPTS, + render: factoryRenderLinkifyWithMention((href) => + renderMatrixMention( + ct, + roomId, + href, + makeMentionCustomProps(mentionClickHandler), + getMemberName, + getRoomName + ) + ), + }), + [ct, roomId, mentionClickHandler, getMemberName, getRoomName] + ); + + const htmlReactParserOptions = useMemo( + () => + getReactCustomHtmlParser(ct, roomId, { + linkifyOpts, + useAuthentication, + handleSpoilerClick: spoilerClickHandler, + handleMentionClick: mentionClickHandler, + getMemberName, + getRoomName, + }), + [ + ct, + roomId, + linkifyOpts, + spoilerClickHandler, + mentionClickHandler, + useAuthentication, + getMemberName, + getRoomName, + ] + ); + + const htmlReactParserInlineOptions = useMemo( + () => + getReactCustomHtmlInlineParser(ct, roomId, { + linkifyOpts, + useAuthentication, + handleMentionClick: mentionClickHandler, + getMemberName, + getRoomName, + }), + [ct, roomId, linkifyOpts, useAuthentication, mentionClickHandler, getMemberName, getRoomName] + ); + + return { + linkifyOpts, + htmlReactParserOptions, + htmlReactParserInlineOptions, + }; +}; diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx index 9cc86d1f..6da8f251 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -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 ( - - {props.alt || props.title || htmlSrc} + + {props.alt || props.title || props.src} ); } + + const htmlSrc = mxcToHttp(ct.session.baseUrl, props.src, params.useAuthentication).url(); if (htmlSrc && 'data-mx-emoticon' in props) { return ( @@ -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; + 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 {domToReact(children, opts)} ; + } + + if (name === 'br') { + return ; + } + + if (name === 'p') { + return {domToReact(children, opts)} ; + } + + if (name === 'pre') { + return {domToReact(children, opts)} ; + } + + if (name === 'blockquote') { + return {domToReact(children, opts)} ; + } + + if (name === 'ul') { + return {domToReact(children, opts)} ; + } + if (name === 'ol') { + return {domToReact(children, opts)} ; + } + + if (name === 'code') { + if (parent && 'name' in parent && parent.name === 'pre') { + const codeReact = domToReact(children, opts); + if (typeof codeReact === 'string') { + return {codeReact}; + } + } else { + return ( + + {domToReact(children, opts)} + + ); + } + } + + 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 ( + + {domToReact(children, opts)} + + ); + } + + if (name === 'img') { + if (props.src && props.src.startsWith('mxc://') === false) { + return ( + + {props.alt || props.title || props.src} + + ); + } + + const htmlSrc = mxcToHttp(ct.session.baseUrl, props.src, params.useAuthentication).url(); + if (htmlSrc && 'data-mx-emoticon' in props) { + return ( + + + + + + ); + } + if (htmlSrc) return Click to see image; + } + + return {domToReact(children, opts)} ; + } + + 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 {jsx}; + } + return jsx; + } + + return undefined; + }, + }; + return opts; +}; diff --git a/src/app/styles/CustomHtml.css.ts b/src/app/styles/CustomHtml.css.ts index 51f841b7..acf2c079 100644 --- a/src/app/styles/CustomHtml.css.ts +++ b/src/app/styles/CustomHtml.css.ts @@ -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', });