mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-09-13 14:22:25 +03:00

* add pinned room events hook * room pinned message - WIP * add room event hook * fetch pinned messages before displaying * use react-query in room event hook * disable staleTime and gc to 1 hour in room event hook * use room event hook in reply component * render pinned messages * add option to pin/unpin messages * remove message base from message placeholders and add variant * display message placeholder while loading pinned messages * render pinned event error * show no pinned message placeholder * fix message placeholder flickering
424 lines
14 KiB
TypeScript
424 lines
14 KiB
TypeScript
import React, { MouseEventHandler, forwardRef, useState } from 'react';
|
|
import FocusTrap from 'focus-trap-react';
|
|
import {
|
|
Box,
|
|
Avatar,
|
|
Text,
|
|
Overlay,
|
|
OverlayCenter,
|
|
OverlayBackdrop,
|
|
IconButton,
|
|
Icon,
|
|
Icons,
|
|
Tooltip,
|
|
TooltipProvider,
|
|
Menu,
|
|
MenuItem,
|
|
toRem,
|
|
config,
|
|
Line,
|
|
PopOut,
|
|
RectCords,
|
|
Badge,
|
|
} from 'folds';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { JoinRule, Room } from 'matrix-js-sdk';
|
|
import { useAtomValue } from 'jotai';
|
|
|
|
import { useStateEvent } from '../../hooks/useStateEvent';
|
|
import { PageHeader } from '../../components/page';
|
|
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
|
import { RoomTopicViewer } from '../../components/room-topic-viewer';
|
|
import { StateEvent } from '../../../types/matrix/room';
|
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
import { useRoom } from '../../hooks/useRoom';
|
|
import { useSetSetting } from '../../state/hooks/settings';
|
|
import { settingsAtom } from '../../state/settings';
|
|
import { useSpaceOptionally } from '../../hooks/useSpace';
|
|
import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '../../pages/pathUtils';
|
|
import { getCanonicalAliasOrRoomId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix';
|
|
import { _SearchPathSearchParams } from '../../pages/paths';
|
|
import * as css from './RoomViewHeader.css';
|
|
import { useRoomUnread } from '../../state/hooks/unread';
|
|
import { usePowerLevelsAPI, usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
|
import { markAsRead } from '../../../client/action/notifications';
|
|
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
|
import { openInviteUser, toggleRoomSettings } from '../../../client/action/navigation';
|
|
import { copyToClipboard } from '../../utils/dom';
|
|
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
|
|
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
|
|
import { mDirectAtom } from '../../state/mDirectList';
|
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
|
import { stopPropagation } from '../../utils/keyboard';
|
|
import { getMatrixToRoom } from '../../plugins/matrix-to';
|
|
import { getViaServers } from '../../plugins/via-servers';
|
|
import { BackRouteHandler } from '../../components/BackRouteHandler';
|
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
|
import { useRoomPinnedEvents } from '../../hooks/useRoomPinnedEvents';
|
|
import { RoomPinMenu } from './room-pin-menu';
|
|
|
|
type RoomMenuProps = {
|
|
room: Room;
|
|
requestClose: () => void;
|
|
};
|
|
const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose }, ref) => {
|
|
const mx = useMatrixClient();
|
|
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
|
const powerLevels = usePowerLevelsContext();
|
|
const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
|
|
const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
|
|
|
|
const handleMarkAsRead = () => {
|
|
markAsRead(mx, room.roomId);
|
|
requestClose();
|
|
};
|
|
|
|
const handleInvite = () => {
|
|
openInviteUser(room.roomId);
|
|
requestClose();
|
|
};
|
|
|
|
const handleCopyLink = () => {
|
|
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
|
|
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
|
|
copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers));
|
|
requestClose();
|
|
};
|
|
|
|
const handleRoomSettings = () => {
|
|
toggleRoomSettings(room.roomId);
|
|
requestClose();
|
|
};
|
|
|
|
return (
|
|
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
|
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
|
<MenuItem
|
|
onClick={handleMarkAsRead}
|
|
size="300"
|
|
after={<Icon size="100" src={Icons.CheckTwice} />}
|
|
radii="300"
|
|
disabled={!unread}
|
|
>
|
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
|
Mark as Read
|
|
</Text>
|
|
</MenuItem>
|
|
</Box>
|
|
<Line variant="Surface" size="300" />
|
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
|
<MenuItem
|
|
onClick={handleInvite}
|
|
variant="Primary"
|
|
fill="None"
|
|
size="300"
|
|
after={<Icon size="100" src={Icons.UserPlus} />}
|
|
radii="300"
|
|
disabled={!canInvite}
|
|
>
|
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
|
Invite
|
|
</Text>
|
|
</MenuItem>
|
|
<MenuItem
|
|
onClick={handleCopyLink}
|
|
size="300"
|
|
after={<Icon size="100" src={Icons.Link} />}
|
|
radii="300"
|
|
>
|
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
|
Copy Link
|
|
</Text>
|
|
</MenuItem>
|
|
<MenuItem
|
|
onClick={handleRoomSettings}
|
|
size="300"
|
|
after={<Icon size="100" src={Icons.Setting} />}
|
|
radii="300"
|
|
>
|
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
|
Room Settings
|
|
</Text>
|
|
</MenuItem>
|
|
</Box>
|
|
<Line variant="Surface" size="300" />
|
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
|
<UseStateProvider initial={false}>
|
|
{(promptLeave, setPromptLeave) => (
|
|
<>
|
|
<MenuItem
|
|
onClick={() => setPromptLeave(true)}
|
|
variant="Critical"
|
|
fill="None"
|
|
size="300"
|
|
after={<Icon size="100" src={Icons.ArrowGoLeft} />}
|
|
radii="300"
|
|
aria-pressed={promptLeave}
|
|
>
|
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
|
Leave Room
|
|
</Text>
|
|
</MenuItem>
|
|
{promptLeave && (
|
|
<LeaveRoomPrompt
|
|
roomId={room.roomId}
|
|
onDone={requestClose}
|
|
onCancel={() => setPromptLeave(false)}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</UseStateProvider>
|
|
</Box>
|
|
</Menu>
|
|
);
|
|
});
|
|
|
|
export function RoomViewHeader() {
|
|
const navigate = useNavigate();
|
|
const mx = useMatrixClient();
|
|
const useAuthentication = useMediaAuthentication();
|
|
const screenSize = useScreenSizeContext();
|
|
const room = useRoom();
|
|
const space = useSpaceOptionally();
|
|
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
|
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
|
|
const mDirects = useAtomValue(mDirectAtom);
|
|
|
|
const pinnedEvents = useRoomPinnedEvents(room);
|
|
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
|
|
const ecryptedRoom = !!encryptionEvent;
|
|
const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId));
|
|
const name = useRoomName(room);
|
|
const topic = useRoomTopic(room);
|
|
const avatarUrl = avatarMxc
|
|
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
|
|
: undefined;
|
|
|
|
const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
|
|
|
|
const handleSearchClick = () => {
|
|
const searchParams: _SearchPathSearchParams = {
|
|
rooms: room.roomId,
|
|
};
|
|
const path = space
|
|
? getSpaceSearchPath(getCanonicalAliasOrRoomId(mx, space.roomId))
|
|
: getHomeSearchPath();
|
|
navigate(withSearchParam(path, searchParams));
|
|
};
|
|
|
|
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
|
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
|
};
|
|
|
|
const handleOpenPinMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
|
setPinMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
|
};
|
|
|
|
return (
|
|
<PageHeader balance={screenSize === ScreenSize.Mobile}>
|
|
<Box grow="Yes" gap="300">
|
|
{screenSize === ScreenSize.Mobile && (
|
|
<BackRouteHandler>
|
|
{(onBack) => (
|
|
<Box shrink="No" alignItems="Center">
|
|
<IconButton onClick={onBack}>
|
|
<Icon src={Icons.ArrowLeft} />
|
|
</IconButton>
|
|
</Box>
|
|
)}
|
|
</BackRouteHandler>
|
|
)}
|
|
<Box grow="Yes" alignItems="Center" gap="300">
|
|
{screenSize !== ScreenSize.Mobile && (
|
|
<Avatar size="300">
|
|
<RoomAvatar
|
|
roomId={room.roomId}
|
|
src={avatarUrl}
|
|
alt={name}
|
|
renderFallback={() => (
|
|
<RoomIcon
|
|
size="200"
|
|
joinRule={room.getJoinRule() ?? JoinRule.Restricted}
|
|
filled
|
|
/>
|
|
)}
|
|
/>
|
|
</Avatar>
|
|
)}
|
|
<Box direction="Column">
|
|
<Text size={topic ? 'H5' : 'H3'} truncate>
|
|
{name}
|
|
</Text>
|
|
{topic && (
|
|
<UseStateProvider initial={false}>
|
|
{(viewTopic, setViewTopic) => (
|
|
<>
|
|
<Overlay open={viewTopic} backdrop={<OverlayBackdrop />}>
|
|
<OverlayCenter>
|
|
<FocusTrap
|
|
focusTrapOptions={{
|
|
initialFocus: false,
|
|
clickOutsideDeactivates: true,
|
|
onDeactivate: () => setViewTopic(false),
|
|
escapeDeactivates: stopPropagation,
|
|
}}
|
|
>
|
|
<RoomTopicViewer
|
|
name={name}
|
|
topic={topic}
|
|
requestClose={() => setViewTopic(false)}
|
|
/>
|
|
</FocusTrap>
|
|
</OverlayCenter>
|
|
</Overlay>
|
|
<Text
|
|
as="button"
|
|
type="button"
|
|
onClick={() => setViewTopic(true)}
|
|
className={css.HeaderTopic}
|
|
size="T200"
|
|
priority="300"
|
|
truncate
|
|
>
|
|
{topic}
|
|
</Text>
|
|
</>
|
|
)}
|
|
</UseStateProvider>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
<Box shrink="No">
|
|
{!ecryptedRoom && (
|
|
<TooltipProvider
|
|
position="Bottom"
|
|
offset={4}
|
|
tooltip={
|
|
<Tooltip>
|
|
<Text>Search</Text>
|
|
</Tooltip>
|
|
}
|
|
>
|
|
{(triggerRef) => (
|
|
<IconButton ref={triggerRef} onClick={handleSearchClick}>
|
|
<Icon size="400" src={Icons.Search} />
|
|
</IconButton>
|
|
)}
|
|
</TooltipProvider>
|
|
)}
|
|
<TooltipProvider
|
|
position="Bottom"
|
|
offset={4}
|
|
tooltip={
|
|
<Tooltip>
|
|
<Text>Pinned Messages</Text>
|
|
</Tooltip>
|
|
}
|
|
>
|
|
{(triggerRef) => (
|
|
<IconButton
|
|
style={{ position: 'relative' }}
|
|
onClick={handleOpenPinMenu}
|
|
ref={triggerRef}
|
|
aria-pressed={!!pinMenuAnchor}
|
|
>
|
|
{pinnedEvents.length > 0 && (
|
|
<Badge
|
|
style={{
|
|
position: 'absolute',
|
|
left: toRem(3),
|
|
top: toRem(3),
|
|
}}
|
|
variant="Secondary"
|
|
size="400"
|
|
fill="Solid"
|
|
radii="Pill"
|
|
>
|
|
<Text as="span" size="L400">
|
|
{pinnedEvents.length}
|
|
</Text>
|
|
</Badge>
|
|
)}
|
|
<Icon size="400" src={Icons.Pin} filled={!!pinMenuAnchor} />
|
|
</IconButton>
|
|
)}
|
|
</TooltipProvider>
|
|
<PopOut
|
|
anchor={pinMenuAnchor}
|
|
position="Bottom"
|
|
content={
|
|
<FocusTrap
|
|
focusTrapOptions={{
|
|
initialFocus: false,
|
|
returnFocusOnDeactivate: false,
|
|
onDeactivate: () => setPinMenuAnchor(undefined),
|
|
clickOutsideDeactivates: true,
|
|
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
|
escapeDeactivates: stopPropagation,
|
|
}}
|
|
>
|
|
<RoomPinMenu room={room} requestClose={() => setPinMenuAnchor(undefined)} />
|
|
</FocusTrap>
|
|
}
|
|
/>
|
|
{screenSize === ScreenSize.Desktop && (
|
|
<TooltipProvider
|
|
position="Bottom"
|
|
offset={4}
|
|
tooltip={
|
|
<Tooltip>
|
|
<Text>Members</Text>
|
|
</Tooltip>
|
|
}
|
|
>
|
|
{(triggerRef) => (
|
|
<IconButton ref={triggerRef} onClick={() => setPeopleDrawer((drawer) => !drawer)}>
|
|
<Icon size="400" src={Icons.User} />
|
|
</IconButton>
|
|
)}
|
|
</TooltipProvider>
|
|
)}
|
|
<TooltipProvider
|
|
position="Bottom"
|
|
align="End"
|
|
offset={4}
|
|
tooltip={
|
|
<Tooltip>
|
|
<Text>More Options</Text>
|
|
</Tooltip>
|
|
}
|
|
>
|
|
{(triggerRef) => (
|
|
<IconButton onClick={handleOpenMenu} ref={triggerRef} aria-pressed={!!menuAnchor}>
|
|
<Icon size="400" src={Icons.VerticalDots} filled={!!menuAnchor} />
|
|
</IconButton>
|
|
)}
|
|
</TooltipProvider>
|
|
<PopOut
|
|
anchor={menuAnchor}
|
|
position="Bottom"
|
|
align="End"
|
|
content={
|
|
<FocusTrap
|
|
focusTrapOptions={{
|
|
initialFocus: false,
|
|
returnFocusOnDeactivate: false,
|
|
onDeactivate: () => setMenuAnchor(undefined),
|
|
clickOutsideDeactivates: true,
|
|
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
|
escapeDeactivates: stopPropagation,
|
|
}}
|
|
>
|
|
<RoomMenu room={room} requestClose={() => setMenuAnchor(undefined)} />
|
|
</FocusTrap>
|
|
}
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
</PageHeader>
|
|
);
|
|
}
|