mirror of
https://github.com/suitenumerique/messages.git
synced 2026-04-25 17:15:21 +02:00
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.
68 lines
3.1 KiB
TypeScript
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;
|