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 (
<>
}>
}
radii="300"
onClick={() => setOpen(true)}
aria-pressed={open}
{...props}
ref={ref}
>
Delete
>
);
});
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 (
<>
}>
}
radii="300"
onClick={() => setOpen(true)}
aria-pressed={open}
{...props}
ref={ref}
>
Report
>
);
});
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) && (
)}
{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}
);
}
);