import { Avatar, AvatarFallback, AvatarImage, Box, Button, Dialog, Header, Icon, IconButton, Icons, Input, Line, Menu, MenuItem, Modal, Overlay, OverlayBackdrop, OverlayCenter, PopOut, Spinner, Text, as, color, config, } from 'folds'; import React, { FormEventHandler, MouseEventHandler, ReactNode, useCallback, useState, } from 'react'; import FocusTrap from 'focus-trap-react'; import { MatrixEvent, Room } from 'matrix-js-sdk'; import { Relations } from 'matrix-js-sdk/lib/models/relations'; import classNames from 'classnames'; import { AvatarBase, BubbleLayout, CompactLayout, MessageBase, ModernLayout, Time, Username, } from '../../../components/message'; import colorMXID from '../../../../util/colorMXID'; import { getMemberAvatarMxc, getMemberDisplayName } from '../../../utils/room'; import { getMxIdLocalPart } 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'; 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, }} > 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', { mEvent: MatrixEvent; onClose?: () => void; } >(({ mEvent, onClose, ...props }, ref) => { const [open, setOpen] = useState(false); const text = JSON.stringify( mEvent.isEncrypted() ? { [`<== DECRYPTED_EVENT ==>`]: mEvent.getEffectiveEvent(), [`<== ORIGINAL_EVENT ==>`]: mEvent.event, } : mEvent.event, null, 2 ); const handleClose = () => { setOpen(false); onClose?.(); }; return ( <> }> } radii="300" onClick={() => setOpen(true)} {...props} ref={ref} aria-pressed={open} > View Source ); }); 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; canDelete?: boolean; canSendReaction?: boolean; imagePackRooms?: Room[]; relations?: Relations; messageLayout: MessageLayout; messageSpacing: MessageSpacing; onUserClick: MouseEventHandler; onUsernameClick: MouseEventHandler; onReplyClick: MouseEventHandler; onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void; reply?: ReactNode; reactions?: ReactNode; }; export const Message = as<'div', MessageProps>( ( { className, room, mEvent, collapse, highlight, canDelete, canSendReaction, imagePackRooms, relations, messageLayout, messageSpacing, onUserClick, onUsernameClick, onReplyClick, onReactionToggle, reply, reactions, children, ...props }, ref ) => { const mx = useMatrixClient(); const senderId = mEvent.getSender() ?? ''; const [hover, setHover] = useState(false); const [menu, setMenu] = useState(false); const [emojiBoard, setEmojiBoard] = useState(false); const senderDisplayName = getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; const senderAvatarMxc = getMemberAvatarMxc(room, senderId); const headerJSX = !collapse && ( {senderDisplayName} {messageLayout !== 1 && hover && ( <> {senderId} | )} ); const avatarJSX = !collapse && messageLayout !== 1 && ( {senderAvatarMxc ? ( ) : ( {senderDisplayName[0]} )} ); const msgContentJSX = ( {reply} {children} {reactions} ); const showOptions = () => setHover(true); const hideOptions = () => setHover(false); const handleContextMenu: MouseEventHandler = (evt) => { if (evt.altKey) return; const tag = (evt.target as any).tagName; if (typeof tag === 'string' && tag.toLowerCase() === 'a') return; evt.preventDefault(); setMenu(true); }; const closeMenu = () => { setMenu(false); }; return ( {(hover || menu || emojiBoard) && (
{canSendReaction && ( { onReactionToggle(mEvent.getId()!, key); setEmojiBoard(false); }} onCustomEmojiSelect={(mxc, shortcode) => { onReactionToggle(mEvent.getId()!, mxc, shortcode); setEmojiBoard(false); }} requestClose={() => { setEmojiBoard(false); }} /> } > {(anchorRef) => ( setEmojiBoard(true)} variant="SurfaceVariant" size="300" radii="300" aria-pressed={emojiBoard} > )} )} setMenu(false), clickOutsideDeactivates: true, isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', }} > {canSendReaction && ( { onReactionToggle(mEvent.getId()!, key, shortcode); closeMenu(); }} /> )} {canSendReaction && ( } radii="300" onClick={() => { closeMenu(); // open it with timeout because closeMenu // FocusTrap will return focus from emojiBoard setTimeout(() => setEmojiBoard(true), 100); }} > Add Reaction )} {relations && ( )} } radii="300" data-event-id={mEvent.getId()} onClick={(evt: any) => { onReplyClick(evt); closeMenu(); }} > Reply {((!mEvent.isRedacted() && canDelete) || mEvent.getSender() !== mx.getUserId()) && ( <> {!mEvent.isRedacted() && canDelete && ( )} {mEvent.getSender() !== mx.getUserId() && ( )} )} } > {(targetRef) => ( setMenu((v) => !v)} aria-pressed={menu} > )}
)} {messageLayout === 1 && ( {msgContentJSX} )} {messageLayout === 2 && ( {headerJSX} {msgContentJSX} )} {messageLayout !== 1 && messageLayout !== 2 && ( {headerJSX} {msgContentJSX} )}
); } ); export type EventProps = { room: Room; mEvent: MatrixEvent; highlight: boolean; canDelete?: boolean; messageSpacing: MessageSpacing; }; export const Event = as<'div', EventProps>( ({ className, room, mEvent, highlight, canDelete, messageSpacing, children, ...props }, ref) => { const mx = useMatrixClient(); const [hover, setHover] = useState(false); const [menu, setMenu] = useState(false); const stateEvent = typeof mEvent.getStateKey() === 'string'; const showOptions = () => setHover(true); const hideOptions = () => setHover(false); const handleContextMenu: MouseEventHandler = (evt) => { if (evt.altKey) return; const tag = (evt.target as any).tagName; if (typeof tag === 'string' && tag.toLowerCase() === 'a') return; evt.preventDefault(); setMenu(true); }; const closeMenu = () => { setMenu(false); }; return ( {(hover || menu) && (
setMenu(false), clickOutsideDeactivates: true, isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', }} > {((!mEvent.isRedacted() && canDelete && !stateEvent) || (mEvent.getSender() !== mx.getUserId() && !stateEvent)) && ( <> {!mEvent.isRedacted() && canDelete && ( )} {mEvent.getSender() !== mx.getUserId() && ( )} )} } > {(targetRef) => ( setMenu((v) => !v)} aria-pressed={menu} > )}
)}
{children}
); } );