2026-07-01 21:45:20 -04:00
|
|
|
import React, { useCallback, useEffect, useRef } from 'react';
|
|
|
|
|
import {
|
|
|
|
|
Box,
|
|
|
|
|
Header,
|
|
|
|
|
Icon,
|
|
|
|
|
IconButton,
|
|
|
|
|
Icons,
|
|
|
|
|
Spinner,
|
|
|
|
|
Text,
|
|
|
|
|
Tooltip,
|
|
|
|
|
TooltipProvider,
|
|
|
|
|
} from 'folds';
|
|
|
|
|
import { Room, RoomEvent, ThreadEvent } from 'matrix-js-sdk';
|
|
|
|
|
import { isKeyHotkey } from 'is-hotkey';
|
|
|
|
|
import classNames from 'classnames';
|
|
|
|
|
import * as css from './ThreadPanel.css';
|
|
|
|
|
import { ContainerColor } from '../../../styles/ContainerColor.css';
|
|
|
|
|
import { ThreadTimeline } from './ThreadTimeline';
|
|
|
|
|
import { markThreadAsRead, useThreadInstance } from './useThread';
|
|
|
|
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
|
|
|
|
import { useEditor } from '../../../components/editor';
|
|
|
|
|
import { useKeyDown } from '../../../hooks/useKeyDown';
|
|
|
|
|
import { useSetting } from '../../../state/hooks/settings';
|
|
|
|
|
import { settingsAtom } from '../../../state/settings';
|
|
|
|
|
import { RoomInput } from '../RoomInput';
|
2026-07-01 22:39:10 -04:00
|
|
|
import {
|
|
|
|
|
getThreadNotificationModeIcon,
|
|
|
|
|
ThreadNotificationModeSwitcher,
|
|
|
|
|
} from '../../../components/ThreadNotificationModeSwitcher';
|
|
|
|
|
import { useThreadNotificationMode } from '../../../hooks/useThreadNotifications';
|
|
|
|
|
import { ThreadNotificationMode } from '../../../utils/threadNotifications';
|
2026-07-01 21:45:20 -04:00
|
|
|
|
|
|
|
|
type ThreadPanelHeaderProps = {
|
|
|
|
|
room: Room;
|
2026-07-01 22:39:10 -04:00
|
|
|
threadId: string;
|
2026-07-01 21:45:20 -04:00
|
|
|
requestClose: () => void;
|
|
|
|
|
};
|
2026-07-01 22:39:10 -04:00
|
|
|
function ThreadPanelHeader({ room, threadId, requestClose }: ThreadPanelHeaderProps) {
|
|
|
|
|
const mode = useThreadNotificationMode(room.roomId, threadId);
|
|
|
|
|
|
2026-07-01 21:45:20 -04:00
|
|
|
return (
|
|
|
|
|
<Header className={css.ThreadPanelHeader} variant="Background" size="600">
|
|
|
|
|
<Box grow="Yes" alignItems="Center" gap="200">
|
|
|
|
|
<Box grow="Yes" direction="Column">
|
|
|
|
|
<Text size="H5" truncate>
|
|
|
|
|
Thread
|
|
|
|
|
</Text>
|
|
|
|
|
<Text size="T200" truncate style={{ opacity: 0.65 }}>
|
|
|
|
|
{room.name}
|
|
|
|
|
</Text>
|
|
|
|
|
</Box>
|
2026-07-01 22:39:10 -04:00
|
|
|
<Box shrink="No" alignItems="Center" gap="100">
|
|
|
|
|
<ThreadNotificationModeSwitcher roomId={room.roomId} threadId={threadId} value={mode}>
|
|
|
|
|
{(handleOpen, opened) => (
|
|
|
|
|
<TooltipProvider
|
|
|
|
|
position="Bottom"
|
|
|
|
|
align="End"
|
|
|
|
|
offset={4}
|
|
|
|
|
tooltip={
|
|
|
|
|
<Tooltip>
|
|
|
|
|
<Text>Notifications</Text>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{(triggerRef) => (
|
|
|
|
|
<IconButton
|
|
|
|
|
ref={triggerRef}
|
|
|
|
|
variant="Background"
|
|
|
|
|
aria-label="Thread notifications"
|
|
|
|
|
aria-pressed={opened}
|
|
|
|
|
onClick={handleOpen}
|
|
|
|
|
>
|
|
|
|
|
<Icon
|
|
|
|
|
src={getThreadNotificationModeIcon(mode)}
|
|
|
|
|
filled={mode !== ThreadNotificationMode.Default}
|
|
|
|
|
/>
|
|
|
|
|
</IconButton>
|
|
|
|
|
)}
|
|
|
|
|
</TooltipProvider>
|
|
|
|
|
)}
|
|
|
|
|
</ThreadNotificationModeSwitcher>
|
2026-07-01 21:45:20 -04:00
|
|
|
<TooltipProvider
|
|
|
|
|
position="Bottom"
|
|
|
|
|
align="End"
|
|
|
|
|
offset={4}
|
|
|
|
|
tooltip={
|
|
|
|
|
<Tooltip>
|
|
|
|
|
<Text>Close</Text>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{(triggerRef) => (
|
|
|
|
|
<IconButton
|
|
|
|
|
ref={triggerRef}
|
|
|
|
|
variant="Background"
|
|
|
|
|
aria-label="Close thread"
|
|
|
|
|
onClick={requestClose}
|
|
|
|
|
>
|
|
|
|
|
<Icon src={Icons.Cross} />
|
|
|
|
|
</IconButton>
|
|
|
|
|
)}
|
|
|
|
|
</TooltipProvider>
|
|
|
|
|
</Box>
|
|
|
|
|
</Box>
|
|
|
|
|
</Header>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type ThreadPanelProps = {
|
|
|
|
|
room: Room;
|
|
|
|
|
threadId: string;
|
|
|
|
|
requestClose: () => void;
|
|
|
|
|
};
|
|
|
|
|
export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps) {
|
|
|
|
|
const mx = useMatrixClient();
|
|
|
|
|
const editor = useEditor();
|
|
|
|
|
const thread = useThreadInstance(room, threadId);
|
|
|
|
|
const [privateReadReceipts] = useSetting(settingsAtom, 'privateReadReceipts');
|
|
|
|
|
const fileDropContainerRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
|
|
|
|
|
|
|
|
|
|
useKeyDown(
|
|
|
|
|
window,
|
|
|
|
|
useCallback(
|
|
|
|
|
(evt) => {
|
|
|
|
|
if (isKeyHotkey('escape', evt)) {
|
2026-07-02 00:18:51 -04:00
|
|
|
// The composer preventDefaults Escape when it consumes it (dismissing
|
|
|
|
|
// autocomplete / clearing a reply draft). Don't close the panel in
|
|
|
|
|
// that case — only when Escape wasn't already handled.
|
|
|
|
|
if (evt.defaultPrevented) return;
|
2026-07-01 21:45:20 -04:00
|
|
|
evt.preventDefault();
|
|
|
|
|
evt.stopPropagation();
|
|
|
|
|
requestClose();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[requestClose],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Mark the thread read when the panel is open and on each new thread event.
|
2026-07-01 21:58:42 -04:00
|
|
|
// Deduped on the latest event id: RoomEvent.Timeline re-emits per event during
|
|
|
|
|
// backfill and for every edit/reaction, and sendReadReceipt POSTs
|
|
|
|
|
// unconditionally — without the guard, opening a thread with N replies would
|
|
|
|
|
// fire up to N receipt requests at the same event.
|
|
|
|
|
const lastReadEventIdRef = useRef<string | undefined>(undefined);
|
2026-07-01 21:45:20 -04:00
|
|
|
useEffect(() => {
|
2026-07-01 21:58:42 -04:00
|
|
|
lastReadEventIdRef.current = undefined;
|
2026-07-01 21:45:20 -04:00
|
|
|
if (!thread) return undefined;
|
|
|
|
|
const markRead = () => {
|
2026-07-01 21:58:42 -04:00
|
|
|
const events = thread.liveTimeline.getEvents();
|
|
|
|
|
let latestId: string | undefined;
|
|
|
|
|
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
|
|
|
const evt = events[i];
|
|
|
|
|
if (evt && !evt.isSending()) {
|
|
|
|
|
latestId = evt.getId() ?? undefined;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!latestId || latestId === lastReadEventIdRef.current) return;
|
|
|
|
|
lastReadEventIdRef.current = latestId;
|
|
|
|
|
markThreadAsRead(mx, thread, privateReadReceipts).catch(() => {
|
|
|
|
|
// Allow a retry on the next event if the receipt POST failed.
|
|
|
|
|
if (lastReadEventIdRef.current === latestId) {
|
|
|
|
|
lastReadEventIdRef.current = undefined;
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-07-01 21:45:20 -04:00
|
|
|
};
|
|
|
|
|
markRead();
|
|
|
|
|
thread.on(ThreadEvent.NewReply, markRead);
|
|
|
|
|
thread.on(RoomEvent.Timeline, markRead);
|
|
|
|
|
return () => {
|
|
|
|
|
thread.off(ThreadEvent.NewReply, markRead);
|
|
|
|
|
thread.off(RoomEvent.Timeline, markRead);
|
|
|
|
|
};
|
|
|
|
|
}, [mx, thread, privateReadReceipts]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Box
|
|
|
|
|
className={classNames(css.ThreadPanel, ContainerColor({ variant: 'Background' }))}
|
|
|
|
|
shrink="No"
|
|
|
|
|
direction="Column"
|
|
|
|
|
>
|
2026-07-01 22:39:10 -04:00
|
|
|
<ThreadPanelHeader room={room} threadId={threadId} requestClose={requestClose} />
|
2026-07-01 21:45:20 -04:00
|
|
|
{!thread ? (
|
|
|
|
|
<Box grow="Yes" alignItems="Center" justifyContent="Center" gap="200">
|
|
|
|
|
<Spinner size="400" variant="Secondary" />
|
|
|
|
|
<Text size="T300">Loading thread…</Text>
|
|
|
|
|
</Box>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<Box grow="Yes" className={css.ThreadPanelContent} direction="Column">
|
|
|
|
|
<ThreadTimeline room={room} thread={thread} editor={editor} />
|
|
|
|
|
</Box>
|
|
|
|
|
<Box className={css.ThreadPanelInput} shrink="No" direction="Column">
|
|
|
|
|
<RoomInput
|
|
|
|
|
room={room}
|
|
|
|
|
roomId={room.roomId}
|
|
|
|
|
threadRootId={threadId}
|
|
|
|
|
editor={editor}
|
|
|
|
|
fileDropContainerRef={fileDropContainerRef}
|
|
|
|
|
/>
|
|
|
|
|
</Box>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</Box>
|
|
|
|
|
);
|
|
|
|
|
}
|