Files
cinny/src/app/features/room/reaction-viewer/ReactionViewer.tsx
T

196 lines
7.0 KiB
TypeScript
Raw Normal View History

import React, { useCallback, useRef, useState } from 'react';
2023-10-06 13:44:06 +11:00
import classNames from 'classnames';
import {
Avatar,
Box,
Header,
Icon,
IconButton,
Icons,
Line,
MenuItem,
Scroll,
Text,
as,
config,
} from 'folds';
import { MatrixEvent, Room, RoomMember } from 'matrix-js-sdk';
import { Relations } from 'matrix-js-sdk/lib/models/relations';
import { getMemberDisplayName } from '../../../utils/room';
import { eventWithShortcode, getMxIdLocalPart } from '../../../utils/matrix';
import * as css from './ReactionViewer.css';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRelations } from '../../../hooks/useRelations';
import { Reaction } from '../../../components/message';
import { getHexcodeForEmoji, getShortcodeFor } from '../../../plugins/emoji';
import { UserAvatar } from '../../../components/user-avatar';
2024-09-09 18:45:20 +10:00
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
2025-08-09 17:46:10 +05:30
import { useOpenUserRoomProfile } from '../../../state/hooks/userRoomProfile';
import { useSpaceOptionally } from '../../../hooks/useSpace';
import { getMouseEventCords } from '../../../utils/dom';
2023-10-06 13:44:06 +11:00
export type ReactionViewerProps = {
room: Room;
initialKey?: string;
relations: Relations;
requestClose: () => void;
};
export const ReactionViewer = as<'div', ReactionViewerProps>(
({ className, room, initialKey, relations, requestClose, ...props }, ref) => {
const mx = useMatrixClient();
2024-09-09 18:45:20 +10:00
const useAuthentication = useMediaAuthentication();
2023-10-06 13:44:06 +11:00
const reactions = useRelations(
relations,
useCallback((rel) => [...(rel.getSortedAnnotationsByKey() ?? [])], []),
2023-10-06 13:44:06 +11:00
);
2025-08-09 17:46:10 +05:30
const space = useSpaceOptionally();
const openProfile = useOpenUserRoomProfile();
const [selectedKey, setSelectedKey] = useState<string>(() => {
if (initialKey) return initialKey;
const defaultReaction = reactions.find((reaction) => typeof reaction[0] === 'string');
return defaultReaction ? defaultReaction[0] : '';
});
const sidebarRef = useRef<HTMLDivElement>(null);
const handleSidebarKeyDown = (e: React.KeyboardEvent) => {
const keys = reactions.map(([k]) => k).filter((k): k is string => typeof k === 'string');
const currentIdx = keys.indexOf(selectedKey);
if (e.key === 'ArrowDown') {
e.preventDefault();
const next = keys[(currentIdx + 1) % keys.length];
if (next) setSelectedKey(next);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const prev = keys[(currentIdx - 1 + keys.length) % keys.length];
if (prev) setSelectedKey(prev);
}
};
2023-10-06 13:44:06 +11:00
const getName = (member: RoomMember) =>
getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
const getReactionsForKey = (key: string): MatrixEvent[] => {
const reactSet = reactions.find(([k]) => k === key)?.[1];
if (!reactSet) return [];
return Array.from(reactSet);
};
const selectedReactions = getReactionsForKey(selectedKey);
const selectedShortcode =
selectedReactions.find(eventWithShortcode)?.getContent().shortcode ??
getShortcodeFor(getHexcodeForEmoji(selectedKey)) ??
selectedKey;
return (
<Box
className={classNames(css.ReactionViewer, className)}
direction="Row"
{...props}
ref={ref}
>
<Box
shrink="No"
className={css.Sidebar}
ref={sidebarRef}
role="listbox"
aria-label="Reactions"
tabIndex={0}
onKeyDown={handleSidebarKeyDown}
style={{ outline: 'none' }}
>
2023-10-06 13:44:06 +11:00
<Scroll visibility="Hover" hideTrack size="300">
<Box className={css.SidebarContent} direction="Column" gap="200">
{reactions.map(([key, evts]) => {
if (typeof key !== 'string') return null;
return (
<Reaction
key={key}
mx={mx}
reaction={key}
count={evts.size}
role="option"
aria-selected={key === selectedKey}
onClick={() => setSelectedKey(key)}
2024-09-07 21:45:55 +08:00
useAuthentication={useAuthentication}
/>
);
})}
2023-10-06 13:44:06 +11:00
</Box>
</Scroll>
</Box>
<Line variant="Surface" direction="Vertical" size="300" />
<Box grow="Yes" direction="Column">
<Header className={css.Header} variant="Surface" size="600">
<Box grow="Yes">
<Text size="H3" truncate>{`Reacted with :${selectedShortcode}:`}</Text>
</Box>
<IconButton size="300" onClick={requestClose} aria-label="Close">
2023-10-06 13:44:06 +11:00
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box grow="Yes">
<Scroll visibility="Hover" hideTrack size="300">
<Box className={css.Content} direction="Column">
{selectedReactions.map((mEvent) => {
const senderId = mEvent.getSender();
if (!senderId) return null;
const member = room.getMember(senderId);
2023-10-07 18:19:01 +11:00
const name = (member ? getName(member) : getMxIdLocalPart(senderId)) ?? senderId;
2023-10-06 13:44:06 +11:00
2024-09-07 21:45:55 +08:00
const avatarMxcUrl = member?.getMxcAvatarUrl();
2025-08-09 17:46:10 +05:30
const avatarUrl = avatarMxcUrl
? mx.mxcUrlToHttp(
avatarMxcUrl,
100,
100,
'crop',
undefined,
false,
useAuthentication,
2025-08-09 17:46:10 +05:30
)
: undefined;
2023-10-06 13:44:06 +11:00
return (
<MenuItem
2023-10-07 18:19:01 +11:00
key={senderId}
2023-10-06 13:44:06 +11:00
style={{ padding: `0 ${config.space.S200}` }}
radii="400"
2025-08-09 17:46:10 +05:30
onClick={(event) => {
openProfile(
room.roomId,
space?.roomId,
senderId,
getMouseEventCords(event.nativeEvent),
'Bottom',
2025-08-09 17:46:10 +05:30
);
2023-10-06 13:44:06 +11:00
}}
before={
<Avatar size="200">
<UserAvatar
userId={senderId}
src={avatarUrl ?? undefined}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
2023-10-06 13:44:06 +11:00
</Avatar>
}
>
<Box grow="Yes">
<Text size="T400" truncate>
{name}
</Text>
</Box>
</MenuItem>
);
})}
</Box>
</Scroll>
</Box>
</Box>
</Box>
);
},
2023-10-06 13:44:06 +11:00
);