import { Avatar, Box, Button, Dialog, Header, Icon, IconButton, Icons, Input, Line, Menu, MenuItem, Modal, Overlay, OverlayBackdrop, OverlayCenter, PopOut, RectCords, Spinner, Text, as, color, config, } from 'folds'; import React, { FormEventHandler, MouseEventHandler, ReactNode, useCallback, useState, } from 'react'; import FocusTrap from 'focus-trap-react'; import { useHover, useFocusWithin } from 'react-aria'; import { MatrixEvent, Room } from 'matrix-js-sdk'; import { Relations } from 'matrix-js-sdk/lib/models/relations'; import classNames from 'classnames'; import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types'; import { AvatarBase, BubbleLayout, CompactLayout, MessageBase, ModernLayout, Time, Username, UsernameBold, } from '../../../components/message'; import { canEditEvent, getEventEdits, getMemberAvatarMxc, getMemberDisplayName, } from '../../../utils/room'; import { getCanonicalAliasOrRoomId, getMxIdLocalPart, isRoomAlias, mxcUrlToHttp, } from '../../../utils/matrix'; import { MessageLayout, MessageSpacing } from '../../../state/settings'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useRecentEmoji } from '../../../hooks/useRecentEmoji'; import * as css from './styles.css'; import { EventReaders } from '../../../components/event-readers'; import { TextViewer } from '../../../components/text-viewer'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { EmojiBoard } from '../../../components/emoji-board'; import { ReactionViewer } from '../reaction-viewer'; import { MessageEditor } from './MessageEditor'; import { UserAvatar } from '../../../components/user-avatar'; import { copyToClipboard } from '../../../utils/dom'; import { stopPropagation } from '../../../utils/keyboard'; import { getMatrixToRoomEvent } from '../../../plugins/matrix-to'; import { getViaServers } from '../../../plugins/via-servers'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents'; import { StateEvent } from '../../../../types/matrix/room'; import { getTagIconSrc, PowerLevelTag } from '../../../hooks/usePowerLevelTags'; import { PowerIcon } from '../../../components/power'; import colorMXID from '../../../../util/colorMXID'; export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void; type MessageQuickReactionsProps = { onReaction: ReactionHandler; }; export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>( ({ onReaction, ...props }, ref) => { const mx = useMatrixClient(); const recentEmojis = useRecentEmoji(mx, 4); if (recentEmojis.length === 0) return ; return ( <> {recentEmojis.map((emoji) => ( onReaction(emoji.unicode, emoji.shortcode)} > {emoji.unicode} ))} ); } ); export const MessageAllReactionItem = as< 'button', { room: Room; relations: Relations; onClose?: () => void; } >(({ room, relations, onClose, ...props }, ref) => { const [open, setOpen] = useState(false); const handleClose = () => { setOpen(false); onClose?.(); }; return ( <> { evt.stopPropagation(); }} open={open} backdrop={} > handleClose(), clickOutsideDeactivates: true, escapeDeactivates: stopPropagation, }} > setOpen(false)} /> } radii="300" onClick={() => setOpen(true)} {...props} ref={ref} aria-pressed={open} > View Reactions ); }); export const MessageReadReceiptItem = as< 'button', { room: Room; eventId: string; onClose?: () => void; } >(({ room, eventId, onClose, ...props }, ref) => { const [open, setOpen] = useState(false); const handleClose = () => { setOpen(false); onClose?.(); }; return ( <> }> } radii="300" onClick={() => setOpen(true)} {...props} ref={ref} aria-pressed={open} > Read Receipts ); }); export const MessageSourceCodeItem = as< 'button', { room: Room; mEvent: MatrixEvent; onClose?: () => void; } >(({ room, mEvent, onClose, ...props }, ref) => { const [open, setOpen] = useState(false); const getContent = (evt: MatrixEvent) => evt.isEncrypted() ? { [`<== DECRYPTED_EVENT ==>`]: evt.getEffectiveEvent(), [`<== ORIGINAL_EVENT ==>`]: evt.event, } : evt.event; const getText = (): string => { const evtId = mEvent.getId()!; const evtTimeline = room.getTimelineForEvent(evtId); const edits = evtTimeline && getEventEdits(evtTimeline.getTimelineSet(), evtId, mEvent.getType())?.getRelations(); if (!edits) return JSON.stringify(getContent(mEvent), null, 2); const content: Record = { '<== MAIN_EVENT ==>': getContent(mEvent), }; edits.forEach((editEvt, index) => { content[`<== REPLACEMENT_EVENT_${index + 1} ==>`] = getContent(editEvt); }); return JSON.stringify(content, null, 2); }; const handleClose = () => { setOpen(false); onClose?.(); }; return ( <> }> } radii="300" onClick={() => setOpen(true)} {...props} ref={ref} aria-pressed={open} > View Source ); }); export const MessageCopyLinkItem = as< 'button', { room: Room; mEvent: MatrixEvent; onClose?: () => void; } >(({ room, mEvent, onClose, ...props }, ref) => { const mx = useMatrixClient(); const handleCopy = () => { const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId); const eventId = mEvent.getId(); const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room); if (!eventId) return; copyToClipboard(getMatrixToRoomEvent(roomIdOrAlias, eventId, viaServers)); onClose?.(); }; return ( } radii="300" onClick={handleCopy} {...props} ref={ref} > Copy Link ); }); export const MessagePinItem = as< 'button', { room: Room; mEvent: MatrixEvent; onClose?: () => void; } >(({ room, mEvent, onClose, ...props }, ref) => { const mx = useMatrixClient(); const pinnedEvents = useRoomPinnedEvents(room); const isPinned = pinnedEvents.includes(mEvent.getId() ?? ''); const handlePin = () => { const eventId = mEvent.getId(); const pinContent: RoomPinnedEventsEventContent = { pinned: Array.from(pinnedEvents).filter((id) => id !== eventId), }; if (!isPinned && eventId) { pinContent.pinned.push(eventId); } mx.sendStateEvent(room.roomId, StateEvent.RoomPinnedEvents, pinContent); onClose?.(); }; return ( } radii="300" onClick={handlePin} {...props} ref={ref} > {isPinned ? 'Unpin Message' : 'Pin Message'} ); }); export const MessageDeleteItem = as< 'button', { room: Room; mEvent: MatrixEvent; onClose?: () => void; } >(({ room, mEvent, onClose, ...props }, ref) => { const mx = useMatrixClient(); const [open, setOpen] = useState(false); const [deleteState, deleteMessage] = useAsyncCallback( useCallback( (eventId: string, reason?: string) => mx.redactEvent(room.roomId, eventId, undefined, reason ? { reason } : undefined), [mx, room] ) ); const handleSubmit: FormEventHandler = (evt) => { evt.preventDefault(); const eventId = mEvent.getId(); if ( !eventId || deleteState.status === AsyncStatus.Loading || deleteState.status === AsyncStatus.Success ) return; const target = evt.target as HTMLFormElement | undefined; const reasonInput = target?.reasonInput as HTMLInputElement | undefined; const reason = reasonInput && reasonInput.value.trim(); deleteMessage(eventId, reason); }; const handleClose = () => { setOpen(false); onClose?.(); }; return ( <> }>
Delete Message
This action is irreversible! Are you sure that you want to delete this message? Reason{' '} (optional) {deleteState.status === AsyncStatus.Error && ( Failed to delete message! Please try again. )}
); }); export const MessageReportItem = as< 'button', { room: Room; mEvent: MatrixEvent; onClose?: () => void; } >(({ room, mEvent, onClose, ...props }, ref) => { const mx = useMatrixClient(); const [open, setOpen] = useState(false); const [reportState, reportMessage] = useAsyncCallback( useCallback( (eventId: string, score: number, reason: string) => mx.reportEvent(room.roomId, eventId, score, reason), [mx, room] ) ); const handleSubmit: FormEventHandler = (evt) => { evt.preventDefault(); const eventId = mEvent.getId(); if ( !eventId || reportState.status === AsyncStatus.Loading || reportState.status === AsyncStatus.Success ) return; const target = evt.target as HTMLFormElement | undefined; const reasonInput = target?.reasonInput as HTMLInputElement | undefined; const reason = reasonInput && reasonInput.value.trim(); if (reasonInput) reasonInput.value = ''; reportMessage(eventId, reason ? -100 : -50, reason || 'No reason provided'); }; const handleClose = () => { setOpen(false); onClose?.(); }; return ( <> }>
Report Message
Report this message to server, which may then notify the appropriate people to take action. Reason {reportState.status === AsyncStatus.Error && ( Failed to report message! Please try again. )} {reportState.status === AsyncStatus.Success && ( Message has been reported to server. )}
); }); export type MessageProps = { room: Room; mEvent: MatrixEvent; collapse: boolean; highlight: boolean; edit?: boolean; canDelete?: boolean; canSendReaction?: boolean; canPinEvent?: boolean; imagePackRooms?: Room[]; relations?: Relations; messageLayout: MessageLayout; messageSpacing: MessageSpacing; onUserClick: MouseEventHandler; onUsernameClick: MouseEventHandler; onReplyClick: ( ev: Parameters>[0], startThread?: boolean ) => void; onEditId?: (eventId?: string) => void; onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void; reply?: ReactNode; reactions?: ReactNode; hideReadReceipts?: boolean; showDeveloperTools?: boolean; powerLevelTag?: PowerLevelTag; accessibleTagColors?: Map; legacyUsernameColor?: boolean; hour24Clock: boolean; dateFormatString: string; }; export const Message = as<'div', MessageProps>( ( { className, room, mEvent, collapse, highlight, edit, canDelete, canSendReaction, canPinEvent, imagePackRooms, relations, messageLayout, messageSpacing, onUserClick, onUsernameClick, onReplyClick, onReactionToggle, onEditId, reply, reactions, hideReadReceipts, showDeveloperTools, powerLevelTag, accessibleTagColors, legacyUsernameColor, hour24Clock, dateFormatString, children, ...props }, ref ) => { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const senderId = mEvent.getSender() ?? ''; const [hover, setHover] = useState(false); const { hoverProps } = useHover({ onHoverChange: setHover }); const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover }); const [menuAnchor, setMenuAnchor] = useState(); const [emojiBoardAnchor, setEmojiBoardAnchor] = useState(); const senderDisplayName = getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; const senderAvatarMxc = getMemberAvatarMxc(room, senderId); const tagColor = powerLevelTag?.color ? accessibleTagColors?.get(powerLevelTag.color) : undefined; const tagIconSrc = powerLevelTag?.icon ? getTagIconSrc(mx, useAuthentication, powerLevelTag.icon) : undefined; const usernameColor = legacyUsernameColor ? colorMXID(senderId) : tagColor; const headerJSX = !collapse && ( {senderDisplayName} {tagIconSrc && } {messageLayout === MessageLayout.Modern && hover && ( <> {senderId} | )} ); const avatarJSX = !collapse && messageLayout !== MessageLayout.Compact && ( } /> ); const msgContentJSX = ( {reply} {edit && onEditId ? ( onEditId()} /> ) : ( children )} {reactions} ); const handleContextMenu: MouseEventHandler = (evt) => { if (evt.altKey || !window.getSelection()?.isCollapsed || edit) return; const tag = (evt.target as any).tagName; if (typeof tag === 'string' && tag.toLowerCase() === 'a') return; evt.preventDefault(); setMenuAnchor({ x: evt.clientX, y: evt.clientY, width: 0, height: 0, }); }; const handleOpenMenu: MouseEventHandler = (evt) => { const target = evt.currentTarget.parentElement?.parentElement ?? evt.currentTarget; setMenuAnchor(target.getBoundingClientRect()); }; const closeMenu = () => { setMenuAnchor(undefined); }; const handleOpenEmojiBoard: MouseEventHandler = (evt) => { const target = evt.currentTarget.parentElement?.parentElement ?? evt.currentTarget; setEmojiBoardAnchor(target.getBoundingClientRect()); }; const handleAddReactions: MouseEventHandler = () => { const rect = menuAnchor; closeMenu(); // open it with timeout because closeMenu // FocusTrap will return focus from emojiBoard setTimeout(() => { setEmojiBoardAnchor(rect); }, 100); }; const isThreadedMessage = mEvent.threadRootId !== undefined; return ( {!edit && (hover || !!menuAnchor || !!emojiBoardAnchor) && (
{canSendReaction && ( { onReactionToggle(mEvent.getId()!, key); setEmojiBoardAnchor(undefined); }} onCustomEmojiSelect={(mxc, shortcode) => { onReactionToggle(mEvent.getId()!, mxc, shortcode); setEmojiBoardAnchor(undefined); }} requestClose={() => { setEmojiBoardAnchor(undefined); }} /> } > )} {!isThreadedMessage && ( onReplyClick(ev, true)} data-event-id={mEvent.getId()} variant="SurfaceVariant" size="300" radii="300" > )} {canEditEvent(mx, mEvent) && onEditId && ( onEditId(mEvent.getId())} variant="SurfaceVariant" size="300" radii="300" > )} setMenuAnchor(undefined), clickOutsideDeactivates: true, isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', escapeDeactivates: stopPropagation, }} > {canSendReaction && ( { onReactionToggle(mEvent.getId()!, key, shortcode); closeMenu(); }} /> )} {canSendReaction && ( } radii="300" onClick={handleAddReactions} > Add Reaction )} {relations && ( )} } radii="300" data-event-id={mEvent.getId()} onClick={(evt: any) => { onReplyClick(evt); closeMenu(); }} > Reply {!isThreadedMessage && ( } radii="300" data-event-id={mEvent.getId()} onClick={(evt: any) => { onReplyClick(evt, true); closeMenu(); }} > Reply in Thread )} {canEditEvent(mx, mEvent) && onEditId && ( } radii="300" data-event-id={mEvent.getId()} onClick={() => { onEditId(mEvent.getId()); closeMenu(); }} > Edit Message )} {!hideReadReceipts && ( )} {showDeveloperTools && ( )} {canPinEvent && ( )} {((!mEvent.isRedacted() && canDelete) || mEvent.getSender() !== mx.getUserId()) && ( <> {!mEvent.isRedacted() && canDelete && ( )} {mEvent.getSender() !== mx.getUserId() && ( )} )} } >
)} {messageLayout === MessageLayout.Compact && ( {msgContentJSX} )} {messageLayout === MessageLayout.Bubble && ( {headerJSX} {msgContentJSX} )} {messageLayout !== MessageLayout.Compact && messageLayout !== MessageLayout.Bubble && ( {headerJSX} {msgContentJSX} )}
); } ); export type EventProps = { room: Room; mEvent: MatrixEvent; highlight: boolean; canDelete?: boolean; messageSpacing: MessageSpacing; hideReadReceipts?: boolean; showDeveloperTools?: boolean; }; export const Event = as<'div', EventProps>( ( { className, room, mEvent, highlight, canDelete, messageSpacing, hideReadReceipts, showDeveloperTools, children, ...props }, ref ) => { const mx = useMatrixClient(); const [hover, setHover] = useState(false); const { hoverProps } = useHover({ onHoverChange: setHover }); const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover }); const [menuAnchor, setMenuAnchor] = useState(); const stateEvent = typeof mEvent.getStateKey() === 'string'; const handleContextMenu: MouseEventHandler = (evt) => { if (evt.altKey || !window.getSelection()?.isCollapsed) return; const tag = (evt.target as any).tagName; if (typeof tag === 'string' && tag.toLowerCase() === 'a') return; evt.preventDefault(); setMenuAnchor({ x: evt.clientX, y: evt.clientY, width: 0, height: 0, }); }; const handleOpenMenu: MouseEventHandler = (evt) => { const target = evt.currentTarget.parentElement?.parentElement ?? evt.currentTarget; setMenuAnchor(target.getBoundingClientRect()); }; const closeMenu = () => { setMenuAnchor(undefined); }; return ( {(hover || !!menuAnchor) && (
setMenuAnchor(undefined), clickOutsideDeactivates: true, isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', escapeDeactivates: stopPropagation, }} > {!hideReadReceipts && ( )} {showDeveloperTools && ( )} {((!mEvent.isRedacted() && canDelete && !stateEvent) || (mEvent.getSender() !== mx.getUserId() && !stateEvent)) && ( <> {!mEvent.isRedacted() && canDelete && ( )} {mEvent.getSender() !== mx.getUserId() && ( )} )} } >
)}
{children}
); } );