Files
messages/src/frontend/src/features/message/use-mention-read.tsx
Jean-Baptiste PENRATH 1044614a76 (global) add mention notifications via UserEvent (#621)
ThreadEvent IM mentions previously lived only inside the event payload,
with no per-user tracking, so a user had no way to see or filter the
threads where they were mentioned. The new UserEvent model materializes
mentions as first-class records (one row per mentioned user per event),
reconciled by a post_save signal whenever a ThreadEvent is created or
edited.

ThreadEvent edits and deletes are now bounded by THREAD_EVENT_EDIT_DELAY
(1h default) so UserEvent records cannot drift out of sync with stale
audit data past the window.
2026-04-10 00:54:39 +02:00

68 lines
3.1 KiB
TypeScript

import { useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { getMailboxThreadsListQueryKeyPrefix, useMailboxContext } from "@/features/providers/mailbox";
import { threadsEventsReadMentionPartialUpdate } from "@/features/api/gen/thread-events/thread-events";
type UseMentionReadReturn = {
markMentionsRead: (threadEventIds: string[]) => void;
};
/**
* Hook to acknowledge mention UserEvents as read for a given thread.
*
* The thread id is bound at init time because the backend endpoint is
* nested under `/threads/{thread_id}/events/`, and because a given caller
* (typically the thread view) is always scoped to a single thread. The
* endpoint is unitary (PATCH per ThreadEvent); this hook fans out to N
* parallel calls when the intersection observer batches several events in
* the same debounce window.
*
* Cache strategy (invalidation-only, no optimistic updates):
* 1. Stats cache → invalidated on settle so the sidebar badge reflects
* the server-authoritative count.
* 2. Thread list cache → invalidated on settle so threads leave the
* has_unread_mention=1 filter once no unread mention remains.
*
* We deliberately avoid optimistic updates on stats. The mailbox stats
* cache is multi-keyed (global `['threads', 'stats', mailboxId]` coexists
* with per-label `['threads', 'stats', mailboxId, 'label_slug=…']` entries
* under the same prefix), so any `setQueriesData` here would fan out to
* label counters that must not be touched. Keeping this flow
* invalidation-only is simpler and stays consistent with how the rest of
* the app treats the stats cache (see `invalidateThreadsStats`).
*
* The thread events cache is also deliberately NOT touched. Keeping
* `has_unread_mention=true` on the currently displayed thread events means
* the "Mentioned" badge stays visible for the whole thread session, giving
* the user time to actually notice why the thread was flagged. The cache
* gets refreshed naturally on the next refetch (thread switch + return,
* window refocus, manual refresh), at which point the badge disappears.
*/
const useMentionRead = (threadId: string): UseMentionReadReturn => {
const { selectedMailbox, invalidateThreadsStats } = useMailboxContext();
const queryClient = useQueryClient();
const markMentionsRead = useCallback((threadEventIds: string[]) => {
if (!threadEventIds.length) return;
Promise.all(
threadEventIds.map((id) =>
threadsEventsReadMentionPartialUpdate(threadId, id),
),
)
.catch(() => {
// Swallow: the invalidation below will reconcile with the
// server state, so a transient PATCH failure is self-healing
// on the next refetch.
})
.finally(() => {
invalidateThreadsStats();
queryClient.invalidateQueries({ queryKey: getMailboxThreadsListQueryKeyPrefix(selectedMailbox?.id) });
});
}, [threadId, selectedMailbox?.id, queryClient, invalidateThreadsStats]);
return { markMentionsRead };
};
export default useMentionRead;