feat(threads): "Mark all read" in the threads list (#165)
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
A "Mark all read" chip appears in the Threads panel header whenever any thread in the room has unread replies, and sends one threaded receipt per unread thread (never the root — a root lives in the main timeline and a receipt there would drag the MAIN read marker backwards, the P6 regression). Honours the private-receipt settings. Verified: two threads with unread replies → the chip appears, the rows say "unread" in their labels; after clicking, no unread rows and the chip is gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -1,8 +1,20 @@
|
|||||||
import React, { useEffect, useMemo, useRef } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useAtom, useAtomValue } from 'jotai';
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
import { atomWithStorage, createJSONStorage } from 'jotai/utils';
|
import { atomWithStorage, createJSONStorage } from 'jotai/utils';
|
||||||
import { NotificationCountType, Room, Thread } from 'matrix-js-sdk';
|
import { NotificationCountType, Room, Thread } from 'matrix-js-sdk';
|
||||||
import { Avatar, Box, Button, Header, Icon, IconButton, Icons, Scroll, Text, config } from 'folds';
|
import {
|
||||||
|
Avatar,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
Header,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
Scroll,
|
||||||
|
Text,
|
||||||
|
config,
|
||||||
|
} from 'folds';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import * as css from './ThreadsListPanel.css';
|
import * as css from './ThreadsListPanel.css';
|
||||||
@@ -14,6 +26,10 @@ import { UnreadBadge, UnreadBadgeCenter } from '../../../components/unread-badge
|
|||||||
import { useMemberAvatar } from '../../../hooks/useMemberAvatar';
|
import { useMemberAvatar } from '../../../hooks/useMemberAvatar';
|
||||||
import { trimReplyFromBody } from '../../../utils/room';
|
import { trimReplyFromBody } from '../../../utils/room';
|
||||||
import { scaleSystemEmoji } from '../../../plugins/react-custom-html-parser';
|
import { scaleSystemEmoji } from '../../../plugins/react-custom-html-parser';
|
||||||
|
import { markAllThreadsRead } from '../../../utils/markThreadsRead';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../../../state/settings';
|
||||||
import { threadNotificationsAtom } from '../../../state/threadNotifications';
|
import { threadNotificationsAtom } from '../../../state/threadNotifications';
|
||||||
import { getMutedThreads } from '../../../utils/threadNotifications';
|
import { getMutedThreads } from '../../../utils/threadNotifications';
|
||||||
import { nameInitials } from '../../../utils/common';
|
import { nameInitials } from '../../../utils/common';
|
||||||
@@ -259,6 +275,22 @@ export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPan
|
|||||||
};
|
};
|
||||||
}, [threads, room, filter, sort, threadNotifications]);
|
}, [threads, room, filter, sort, threadNotifications]);
|
||||||
|
|
||||||
|
// [Gitea #165] "Mark all read" — one threaded receipt per unread thread.
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const [marking, setMarking] = useState(false);
|
||||||
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||||
|
const [privateReadReceipts] = useSetting(settingsAtom, 'privateReadReceipts');
|
||||||
|
const totalUnread = useMemo(
|
||||||
|
() => [...unreadById.values()].reduce((n, c) => n + c, 0),
|
||||||
|
[unreadById],
|
||||||
|
);
|
||||||
|
const handleMarkAllRead = useCallback(() => {
|
||||||
|
setMarking(true);
|
||||||
|
markAllThreadsRead(mx, room, hideActivity || privateReadReceipts).finally(() =>
|
||||||
|
setMarking(false),
|
||||||
|
);
|
||||||
|
}, [mx, room, hideActivity, privateReadReceipts]);
|
||||||
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
|
const scrollRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: visible.length,
|
count: visible.length,
|
||||||
@@ -291,6 +323,19 @@ export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPan
|
|||||||
{room.name}
|
{room.name}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
{totalUnread > 0 && (
|
||||||
|
<Chip
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
outlined
|
||||||
|
disabled={marking}
|
||||||
|
onClick={handleMarkAllRead}
|
||||||
|
before={<Icon size="50" src={Icons.Check} />}
|
||||||
|
>
|
||||||
|
<Text size="B300">Mark all read</Text>
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
<IconButton size="300" radii="300" aria-label="Close threads" onClick={onClose}>
|
<IconButton size="300" radii="300" aria-label="Close threads" onClick={onClose}>
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { MatrixClient, NotificationCountType, ReceiptType, Room } from 'matrix-js-sdk';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #165] Send a threaded read receipt for every thread in the room that
|
||||||
|
* has unread replies. Mirrors the per-thread half of `markAsRead`, including
|
||||||
|
* its rule: never fall back to the thread root (a root lives in the main
|
||||||
|
* timeline, so a receipt there would move the MAIN read marker backwards).
|
||||||
|
* Returns how many threads were marked.
|
||||||
|
*/
|
||||||
|
export async function markAllThreadsRead(
|
||||||
|
mx: MatrixClient,
|
||||||
|
room: Room,
|
||||||
|
privateReceipt: boolean,
|
||||||
|
): Promise<number> {
|
||||||
|
const receiptType = privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read;
|
||||||
|
const sends = room
|
||||||
|
.getThreads()
|
||||||
|
.map((thread) => {
|
||||||
|
const unread =
|
||||||
|
room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) ?? 0;
|
||||||
|
if (unread <= 0) return undefined;
|
||||||
|
const lastReply = thread.lastReply();
|
||||||
|
if (!lastReply || lastReply.isSending()) return undefined;
|
||||||
|
return mx.sendReadReceipt(lastReply, receiptType, false).catch(() => undefined);
|
||||||
|
})
|
||||||
|
.filter((p): p is NonNullable<typeof p> => !!p);
|
||||||
|
await Promise.all(sends);
|
||||||
|
return sends.length;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user