mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-03-21 02:17:49 +01:00
* fix intersection & resize observer * add binary search util * add scroll info util * add virtual paginator hook - WIP * render timeline using paginator hook * add continuous pagination to fill timeline * add doc comments in virtual paginator hook * add scroll to element func in virtual paginator * extract timeline pagination login into hook * add sliding name for timeline messages - testing * scroll with live event * change message rending style * make message timestamp smaller * remove unused imports * add random number between util * add compact message component * add sanitize html types * fix sending alias in room mention * get room member display name util * add get room with canonical alias util * add sanitize html util * render custom html with new styles * fix linkifying link text * add reaction component * display message reactions in timeline * Change mention color * show edited message * add event sent by function factory * add functions to get emoji shortcode * add component for reaction msg * add tooltip for who has reacted * add message layouts & placeholder * fix reaction size * fix dark theme colors * add code highlight with prismjs * add options to configure spacing in msgs * render message reply * fix trim reply from body regex * fix crash when loading reply * fix reply hover style * decrypt event on timeline paginate * update custom html code style * remove console logs * fix virtual paginator scroll to func * fix virtual paginator scroll to types * add stop scroll for in view item options * fix virtual paginator out of range scroll to index * scroll to and highlight reply on click * fix reply hover style * make message avatar clickable * fix scrollTo issue in virtual paginator * load reply from fetch * import virtual paginator restore scroll * load timeline for specific event * Fix back pagination recalibration * fix reply min height * revert code block colors to secondary * stop sanitizing text in code block * add decrypt file util * add image media component * update folds * fix code block font style * add msg event type * add scale dimension util * strict msg layout type * add image renderer component * add message content fallback components * add message matrix event renderer components * render matrix event using hooks * add attachment component * add attachment content types * handle error when rendering image in timeline * add video component * render video * include blurhash in thumbnails * generate thumbnails for image message * fix reactToDom spoiler opts * add hooks for HTMLMediaElement * render audio file in timeline * add msg image content component * fix image content props * add video content component * render new image/video component in timeline * remove console.log * convert seconds to milliseconds in video info * add load thumbnail prop to video content component * add file saver types * add file header component * add file content component * render file in timeline * add media control component * render audio message in room timeline * remove moved components * safely load message reply * add media loading hook * update media control layout * add loading indication in audio component * fill audio play icon when playing audio * fix media expanding * add image viewer - WIP * add pan and zoom control to image viewer * add text based file viewer * add pdf viewer * add error handling in pdf viewer * add download btn to pdf viewer * fix file button spinner fill * fix file opens on re-render * add range slider in audio content player * render location in timeline * update folds * display membership event in timeline * make reactions toggle * render sticker messages in timeline * render room name, topic, avatar change and event * fix typos * update render state event type style * add room intro in start of timeline * add power levels context * fix wrong param passing in RoomView * fix sending typing notification in wrong room Slate onChange callback was not updating with react re-renders. * send typing status on key up * add typing indicator component * add typing member atom * display typing status in member drawer * add room view typing member component * display typing members in room view * remove old roomTimeline uses * add event readers hook * add latest event hook * display following members in room view * fetch event instead of event context for reply * fix typo in virtual paginator hook * add scroll to latest btn in timeline * change scroll to latest chip variant * destructure paginator object to improve perf * restore forward dir scroll in virtual paginator * run scroll to bottom in layout effect * display unread message indicator in timeline * make component for room timeline float * add timeline divider component * add day divider and format message time * apply message spacing to dividers * format date in room intro * send read receipt on message arrive * add event readers component * add reply, read receipt, source delete opt * bug fixes * update timeline on delete & show reason * fix empty reaction container style * show msg selection effect on msg option open * add report message options * add options to send quick reactions * add emoji board in message options * add reaction viewer * fix styles * show view reaction in msg options menu * fix spacing between two msg by same person * add option menu in other rendered event * handle m.room.encrypted messages * fix italic reply text overflow cut * handle encrypted sticker messages * remove console log * prevent message context menu with alt key pressed * make mentions clickable in messages * add options to show and hidden events in timeline * add option to disable media autoload * remove old emojiboard opener * add options to use system emoji * refresh timeline on reset * fix stuck typing member in member drawer
555 lines
19 KiB
TypeScript
555 lines
19 KiB
TypeScript
import React, {
|
|
ChangeEventHandler,
|
|
MouseEventHandler,
|
|
useCallback,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import {
|
|
Avatar,
|
|
AvatarFallback,
|
|
AvatarImage,
|
|
Badge,
|
|
Box,
|
|
Chip,
|
|
ContainerColor,
|
|
Header,
|
|
Icon,
|
|
IconButton,
|
|
Icons,
|
|
Input,
|
|
Menu,
|
|
MenuItem,
|
|
PopOut,
|
|
Scroll,
|
|
Spinner,
|
|
Text,
|
|
Tooltip,
|
|
TooltipProvider,
|
|
config,
|
|
} from 'folds';
|
|
import { Room, RoomMember } from 'matrix-js-sdk';
|
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
import FocusTrap from 'focus-trap-react';
|
|
import millify from 'millify';
|
|
import classNames from 'classnames';
|
|
import { useAtomValue } from 'jotai';
|
|
|
|
import { openInviteUser, openProfileViewer } from '../../../client/action/navigation';
|
|
import * as css from './MembersDrawer.css';
|
|
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
import {
|
|
getIntersectionObserverEntry,
|
|
useIntersectionObserver,
|
|
} from '../../hooks/useIntersectionObserver';
|
|
import { Membership } from '../../../types/matrix/room';
|
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
|
import { UseAsyncSearchOptions, useAsyncSearch } from '../../hooks/useAsyncSearch';
|
|
import { useDebounce } from '../../hooks/useDebounce';
|
|
import colorMXID from '../../../util/colorMXID';
|
|
import { usePowerLevelTags, PowerLevelTag } from '../../hooks/usePowerLevelTags';
|
|
import { roomIdToTypingMembersAtom, selectRoomTypingMembersAtom } from '../../state/typingMembers';
|
|
import { TypingIndicator } from '../../components/typing-indicator';
|
|
import { getMemberDisplayName } from '../../utils/room';
|
|
import { getMxIdLocalPart } from '../../utils/matrix';
|
|
|
|
export const MembershipFilters = {
|
|
filterJoined: (m: RoomMember) => m.membership === Membership.Join,
|
|
filterInvited: (m: RoomMember) => m.membership === Membership.Invite,
|
|
filterLeaved: (m: RoomMember) =>
|
|
m.membership === Membership.Leave &&
|
|
m.events.member?.getStateKey() === m.events.member?.getSender(),
|
|
filterKicked: (m: RoomMember) =>
|
|
m.membership === Membership.Leave &&
|
|
m.events.member?.getStateKey() !== m.events.member?.getSender(),
|
|
filterBanned: (m: RoomMember) => m.membership === Membership.Ban,
|
|
};
|
|
|
|
export type MembershipFilterFn = (m: RoomMember) => boolean;
|
|
|
|
export type MembershipFilter = {
|
|
name: string;
|
|
filterFn: MembershipFilterFn;
|
|
color: ContainerColor;
|
|
};
|
|
|
|
const useMembershipFilterMenu = (): MembershipFilter[] =>
|
|
useMemo(
|
|
() => [
|
|
{
|
|
name: 'Joined',
|
|
filterFn: MembershipFilters.filterJoined,
|
|
color: 'Background',
|
|
},
|
|
{
|
|
name: 'Invited',
|
|
filterFn: MembershipFilters.filterInvited,
|
|
color: 'Success',
|
|
},
|
|
{
|
|
name: 'Left',
|
|
filterFn: MembershipFilters.filterLeaved,
|
|
color: 'Secondary',
|
|
},
|
|
{
|
|
name: 'Kicked',
|
|
filterFn: MembershipFilters.filterKicked,
|
|
color: 'Warning',
|
|
},
|
|
{
|
|
name: 'Banned',
|
|
filterFn: MembershipFilters.filterBanned,
|
|
color: 'Critical',
|
|
},
|
|
],
|
|
[]
|
|
);
|
|
|
|
export const SortFilters = {
|
|
filterAscending: (a: RoomMember, b: RoomMember) =>
|
|
a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1,
|
|
filterDescending: (a: RoomMember, b: RoomMember) =>
|
|
a.name.toLowerCase() > b.name.toLowerCase() ? -1 : 1,
|
|
filterNewestFirst: (a: RoomMember, b: RoomMember) =>
|
|
(b.events.member?.getTs() ?? 0) - (a.events.member?.getTs() ?? 0),
|
|
filterOldest: (a: RoomMember, b: RoomMember) =>
|
|
(a.events.member?.getTs() ?? 0) - (b.events.member?.getTs() ?? 0),
|
|
};
|
|
|
|
export type SortFilterFn = (a: RoomMember, b: RoomMember) => number;
|
|
|
|
export type SortFilter = {
|
|
name: string;
|
|
filterFn: SortFilterFn;
|
|
};
|
|
|
|
const useSortFilterMenu = (): SortFilter[] =>
|
|
useMemo(
|
|
() => [
|
|
{
|
|
name: 'A to Z',
|
|
filterFn: SortFilters.filterAscending,
|
|
},
|
|
{
|
|
name: 'Z to A',
|
|
filterFn: SortFilters.filterDescending,
|
|
},
|
|
{
|
|
name: 'Newest',
|
|
filterFn: SortFilters.filterNewestFirst,
|
|
},
|
|
{
|
|
name: 'Oldest',
|
|
filterFn: SortFilters.filterOldest,
|
|
},
|
|
],
|
|
[]
|
|
);
|
|
|
|
export type MembersFilterOptions = {
|
|
membershipFilter: MembershipFilter;
|
|
sortFilter: SortFilter;
|
|
};
|
|
|
|
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
|
limit: 100,
|
|
matchOptions: {
|
|
contain: true,
|
|
},
|
|
};
|
|
const getMemberItemStr = (m: RoomMember) => [m.name, m.userId];
|
|
|
|
type MembersDrawerProps = {
|
|
room: Room;
|
|
};
|
|
export function MembersDrawer({ room }: MembersDrawerProps) {
|
|
const mx = useMatrixClient();
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
const scrollTopAnchorRef = useRef<HTMLDivElement>(null);
|
|
const members = useRoomMembers(mx, room.roomId);
|
|
const getPowerLevelTag = usePowerLevelTags();
|
|
const fetchingMembers = members.length < room.getJoinedMemberCount();
|
|
|
|
const membershipFilterMenu = useMembershipFilterMenu();
|
|
const sortFilterMenu = useSortFilterMenu();
|
|
const [filter, setFilter] = useState<MembersFilterOptions>({
|
|
membershipFilter: membershipFilterMenu[0],
|
|
sortFilter: sortFilterMenu[0],
|
|
});
|
|
const [onTop, setOnTop] = useState(true);
|
|
|
|
const typingMembers = useAtomValue(
|
|
useMemo(() => selectRoomTypingMembersAtom(room.roomId, roomIdToTypingMembersAtom), [room])
|
|
);
|
|
|
|
const filteredMembers = useMemo(
|
|
() =>
|
|
members
|
|
.filter(filter.membershipFilter.filterFn)
|
|
.sort(filter.sortFilter.filterFn)
|
|
.sort((a, b) => b.powerLevel - a.powerLevel),
|
|
[members, filter]
|
|
);
|
|
|
|
const [result, search, resetSearch] = useAsyncSearch(
|
|
filteredMembers,
|
|
getMemberItemStr,
|
|
SEARCH_OPTIONS
|
|
);
|
|
if (!result && searchInputRef.current?.value) search(searchInputRef.current.value);
|
|
|
|
const processMembers = result ? result.items : filteredMembers;
|
|
|
|
const PLTagOrRoomMember = useMemo(() => {
|
|
let prevTag: PowerLevelTag | undefined;
|
|
const tagOrMember: Array<PowerLevelTag | RoomMember> = [];
|
|
processMembers.forEach((m) => {
|
|
const plTag = getPowerLevelTag(m.powerLevel);
|
|
if (plTag !== prevTag) {
|
|
prevTag = plTag;
|
|
tagOrMember.push(plTag);
|
|
}
|
|
tagOrMember.push(m);
|
|
});
|
|
return tagOrMember;
|
|
}, [processMembers, getPowerLevelTag]);
|
|
|
|
const virtualizer = useVirtualizer({
|
|
count: PLTagOrRoomMember.length,
|
|
getScrollElement: () => scrollRef.current,
|
|
estimateSize: () => 40,
|
|
overscan: 10,
|
|
});
|
|
|
|
useIntersectionObserver(
|
|
useCallback((intersectionEntries) => {
|
|
if (!scrollTopAnchorRef.current) return;
|
|
const entry = getIntersectionObserverEntry(scrollTopAnchorRef.current, intersectionEntries);
|
|
if (entry) setOnTop(entry.isIntersecting);
|
|
}, []),
|
|
useCallback(() => ({ root: scrollRef.current }), []),
|
|
useCallback(() => scrollTopAnchorRef.current, [])
|
|
);
|
|
|
|
const handleSearchChange: ChangeEventHandler<HTMLInputElement> = useDebounce(
|
|
useCallback(
|
|
(evt) => {
|
|
if (evt.target.value) search(evt.target.value);
|
|
else resetSearch();
|
|
},
|
|
[search, resetSearch]
|
|
),
|
|
{ wait: 200 }
|
|
);
|
|
|
|
const getName = (member: RoomMember) =>
|
|
getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
|
|
|
|
const handleMemberClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
|
const btn = evt.currentTarget as HTMLButtonElement;
|
|
const userId = btn.getAttribute('data-user-id');
|
|
openProfileViewer(userId, room.roomId);
|
|
};
|
|
|
|
return (
|
|
<Box className={css.MembersDrawer} direction="Column">
|
|
<Header className={css.MembersDrawerHeader} variant="Background" size="600">
|
|
<Box grow="Yes" alignItems="Center" gap="200">
|
|
<Box grow="Yes" alignItems="Center" gap="200">
|
|
<Text size="H5" truncate>
|
|
{`${millify(room.getJoinedMemberCount(), { precision: 1 })} Members`}
|
|
</Text>
|
|
</Box>
|
|
<Box shrink="No" alignItems="Center">
|
|
<TooltipProvider
|
|
position="Bottom"
|
|
align="End"
|
|
offset={4}
|
|
tooltip={
|
|
<Tooltip>
|
|
<Text>Invite Member</Text>
|
|
</Tooltip>
|
|
}
|
|
>
|
|
{(triggerRef) => (
|
|
<IconButton
|
|
ref={triggerRef}
|
|
variant="Background"
|
|
onClick={() => openInviteUser(room.roomId)}
|
|
>
|
|
<Icon src={Icons.UserPlus} />
|
|
</IconButton>
|
|
)}
|
|
</TooltipProvider>
|
|
</Box>
|
|
</Box>
|
|
</Header>
|
|
<Box className={css.MemberDrawerContentBase} grow="Yes">
|
|
<Scroll ref={scrollRef} variant="Background" size="300" visibility="Hover">
|
|
<Box className={css.MemberDrawerContent} direction="Column" gap="200">
|
|
<Box ref={scrollTopAnchorRef} className={css.DrawerGroup} direction="Column" gap="200">
|
|
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
|
<UseStateProvider initial={false}>
|
|
{(open, setOpen) => (
|
|
<PopOut
|
|
open={open}
|
|
position="Bottom"
|
|
align="Start"
|
|
offset={4}
|
|
content={
|
|
<FocusTrap
|
|
focusTrapOptions={{
|
|
initialFocus: false,
|
|
onDeactivate: () => setOpen(false),
|
|
clickOutsideDeactivates: true,
|
|
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
|
}}
|
|
>
|
|
<Menu style={{ padding: config.space.S100 }}>
|
|
{membershipFilterMenu.map((menuItem) => (
|
|
<MenuItem
|
|
key={menuItem.name}
|
|
variant={
|
|
menuItem.name === filter.membershipFilter.name
|
|
? menuItem.color
|
|
: 'Surface'
|
|
}
|
|
aria-pressed={menuItem.name === filter.membershipFilter.name}
|
|
radii="300"
|
|
onClick={() => {
|
|
setFilter((f) => ({ ...f, membershipFilter: menuItem }));
|
|
setOpen(false);
|
|
}}
|
|
>
|
|
<Text>{menuItem.name}</Text>
|
|
</MenuItem>
|
|
))}
|
|
</Menu>
|
|
</FocusTrap>
|
|
}
|
|
>
|
|
{(anchorRef) => (
|
|
<Chip
|
|
ref={anchorRef}
|
|
onClick={() => setOpen(!open)}
|
|
variant={filter.membershipFilter.color}
|
|
size="400"
|
|
radii="300"
|
|
before={<Icon src={Icons.Filter} size="50" />}
|
|
>
|
|
<Text size="T200">{filter.membershipFilter.name}</Text>
|
|
</Chip>
|
|
)}
|
|
</PopOut>
|
|
)}
|
|
</UseStateProvider>
|
|
<UseStateProvider initial={false}>
|
|
{(open, setOpen) => (
|
|
<PopOut
|
|
open={open}
|
|
position="Bottom"
|
|
align="End"
|
|
offset={4}
|
|
content={
|
|
<FocusTrap
|
|
focusTrapOptions={{
|
|
initialFocus: false,
|
|
onDeactivate: () => setOpen(false),
|
|
clickOutsideDeactivates: true,
|
|
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
|
}}
|
|
>
|
|
<Menu style={{ padding: config.space.S100 }}>
|
|
{sortFilterMenu.map((menuItem) => (
|
|
<MenuItem
|
|
key={menuItem.name}
|
|
variant="Surface"
|
|
aria-pressed={menuItem.name === filter.sortFilter.name}
|
|
radii="300"
|
|
onClick={() => {
|
|
setFilter((f) => ({ ...f, sortFilter: menuItem }));
|
|
setOpen(false);
|
|
}}
|
|
>
|
|
<Text>{menuItem.name}</Text>
|
|
</MenuItem>
|
|
))}
|
|
</Menu>
|
|
</FocusTrap>
|
|
}
|
|
>
|
|
{(anchorRef) => (
|
|
<Chip
|
|
ref={anchorRef}
|
|
onClick={() => setOpen(!open)}
|
|
variant="Background"
|
|
size="400"
|
|
radii="300"
|
|
after={<Icon src={Icons.Sort} size="50" />}
|
|
>
|
|
<Text size="T200">{filter.sortFilter.name}</Text>
|
|
</Chip>
|
|
)}
|
|
</PopOut>
|
|
)}
|
|
</UseStateProvider>
|
|
</Box>
|
|
<Box direction="Column" gap="100">
|
|
<Input
|
|
ref={searchInputRef}
|
|
onChange={handleSearchChange}
|
|
style={{ paddingRight: config.space.S200 }}
|
|
placeholder="Type name..."
|
|
variant="Surface"
|
|
size="400"
|
|
radii="400"
|
|
before={<Icon size="50" src={Icons.Search} />}
|
|
after={
|
|
result && (
|
|
<Chip
|
|
variant={result.items.length > 0 ? 'Success' : 'Critical'}
|
|
size="400"
|
|
radii="Pill"
|
|
aria-pressed
|
|
onClick={() => {
|
|
if (searchInputRef.current) {
|
|
searchInputRef.current.value = '';
|
|
searchInputRef.current.focus();
|
|
}
|
|
resetSearch();
|
|
}}
|
|
after={<Icon size="50" src={Icons.Cross} />}
|
|
>
|
|
<Text size="B300">{`${result.items.length || 'No'} ${
|
|
result.items.length === 1 ? 'Result' : 'Results'
|
|
}`}</Text>
|
|
</Chip>
|
|
)
|
|
}
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
|
|
{!onTop && (
|
|
<Box className={css.DrawerScrollTop}>
|
|
<IconButton
|
|
onClick={() => virtualizer.scrollToOffset(0)}
|
|
variant="Surface"
|
|
radii="Pill"
|
|
outlined
|
|
size="300"
|
|
aria-label="Scroll to Top"
|
|
>
|
|
<Icon src={Icons.ChevronTop} size="300" />
|
|
</IconButton>
|
|
</Box>
|
|
)}
|
|
|
|
{!fetchingMembers && !result && processMembers.length === 0 && (
|
|
<Text style={{ padding: config.space.S300 }} align="Center">
|
|
{`No "${filter.membershipFilter.name}" Members`}
|
|
</Text>
|
|
)}
|
|
|
|
<Box className={css.MembersGroup} direction="Column" gap="100">
|
|
<div
|
|
style={{
|
|
position: 'relative',
|
|
height: virtualizer.getTotalSize(),
|
|
}}
|
|
>
|
|
{virtualizer.getVirtualItems().map((vItem) => {
|
|
const tagOrMember = PLTagOrRoomMember[vItem.index];
|
|
if (!('userId' in tagOrMember)) {
|
|
return (
|
|
<Text
|
|
style={{
|
|
transform: `translateY(${vItem.start}px)`,
|
|
}}
|
|
data-index={vItem.index}
|
|
ref={virtualizer.measureElement}
|
|
key={`${room.roomId}-${vItem.index}`}
|
|
className={classNames(css.MembersGroupLabel, css.DrawerVirtualItem)}
|
|
size="L400"
|
|
>
|
|
{tagOrMember.name}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
const member = tagOrMember;
|
|
const name = getName(member);
|
|
const avatarUrl = member.getAvatarUrl(
|
|
mx.baseUrl,
|
|
100,
|
|
100,
|
|
'crop',
|
|
undefined,
|
|
false
|
|
);
|
|
|
|
return (
|
|
<MenuItem
|
|
style={{
|
|
padding: `0 ${config.space.S200}`,
|
|
transform: `translateY(${vItem.start}px)`,
|
|
}}
|
|
data-index={vItem.index}
|
|
data-user-id={member.userId}
|
|
ref={virtualizer.measureElement}
|
|
key={`${room.roomId}-${member.userId}`}
|
|
className={css.DrawerVirtualItem}
|
|
variant="Background"
|
|
radii="400"
|
|
onClick={handleMemberClick}
|
|
before={
|
|
<Avatar size="200">
|
|
{avatarUrl ? (
|
|
<AvatarImage src={avatarUrl} />
|
|
) : (
|
|
<AvatarFallback
|
|
style={{
|
|
background: colorMXID(member.userId),
|
|
color: 'white',
|
|
}}
|
|
>
|
|
<Text size="H6">{name[0]}</Text>
|
|
</AvatarFallback>
|
|
)}
|
|
</Avatar>
|
|
}
|
|
after={
|
|
typingMembers.find((tm) => tm.userId === member.userId) && (
|
|
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
|
|
<TypingIndicator size="300" />
|
|
</Badge>
|
|
)
|
|
}
|
|
>
|
|
<Box grow="Yes">
|
|
<Text size="T400" truncate>
|
|
{name}
|
|
</Text>
|
|
</Box>
|
|
</MenuItem>
|
|
);
|
|
})}
|
|
</div>
|
|
</Box>
|
|
|
|
{fetchingMembers && (
|
|
<Box justifyContent="Center">
|
|
<Spinner />
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
</Scroll>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|