SkateSpots/src/hooks/useCrewChatUnread.ts
shakespeare.diy 39afc00b6e Crew chat: unread dot on the Chat tab (cheap, honest, no bell spam)
Instead of wiring encrypted crew chat into the global notification bell — which
would mean background-decrypting every crew's messages to power a badge (slow,
battery-hungry, unreliable with NIP-17's random timestamps) — add a subtle
unread dot to the crew's own Chat tab.

- useCrewChatUnread: member-only, single-crew, runs only while the crew page is
  open. Uses a pure metadata query (newest Cordn envelope by created_at, no
  decryption) vs. the local last-seen timestamp. For the built-in NIP-17
  transport (random timestamps, needs decryption) it deliberately returns false
  rather than fire false pings — the in-chat "New" divider already covers
  "what did I miss" once the tab is opened.
- Tabs made controlled; the dot is suppressed while the Chat tab is active, so
  it clears the moment you look, and is gone on the next visit (last-seen
  advances). No global notification noise.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-03 18:21:38 +07:00

72 lines
2.7 KiB
TypeScript

import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useCrewChatRead } from '@/hooks/useCrewChatRead';
import { resolveCordnEndpoint } from '@/lib/cordn/endpoint';
import { CORDN_APP_TAG, CORDN_MESSAGE_KIND } from '@/lib/cordn/types';
import { SKATE_CREW_KIND, type SkateCrew } from '@/lib/types';
/**
* A cheap, honest "is there new crew chat since I last looked?" signal for the
* Chat tab's unread dot.
*
* Design constraints (kept idiot-proof and light):
* - It must NOT decrypt every message in the background just to power a dot.
* - It must NOT fire false pings.
*
* So it only reports unread for **Cordn** crews, where the newest message time
* is a pure metadata query (`#a` group + newest `created_at`) — no decryption.
* For the built-in NIP-17 transport, gift wraps use randomized timestamps and
* require per-message decryption, so a reliable cheap peek isn't possible; there
* we return `false` (the in-chat "New" divider already covers "what did I miss"
* once the tab is opened). Only members query at all.
*
* Runs only while a crew page is mounted, for a single crew — never fanned out
* across all crews.
*/
export function useCrewChatUnread(crew: SkateCrew | undefined, isMember: boolean) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const crewCoord = crew
? `${SKATE_CREW_KIND}:${crew.pubkey}:${crew.identifier}`
: undefined;
const { lastSeen } = useCrewChatRead(crewCoord);
const endpoint = useMemo(
() => (crew ? resolveCordnEndpoint(crew) : null),
[crew],
);
// Only meaningful for Cordn crews the viewer belongs to.
const enabled = !!user && isMember && !!endpoint;
const { data: newestAt = 0 } = useQuery({
queryKey: ['crew-chat-unread', endpoint?.relayUrl, endpoint?.groupId, user?.pubkey],
enabled,
staleTime: 15_000,
refetchInterval: enabled ? 30_000 : false,
queryFn: async (): Promise<number> => {
if (!endpoint) return 0;
const relay = nostr.relay(endpoint.relayUrl);
const signal = AbortSignal.timeout(8000);
// Metadata-only: newest envelope for this group. No decryption.
const events = await relay.query(
[{
kinds: [CORDN_MESSAGE_KIND],
'#a': [endpoint.groupId],
'#t': [CORDN_APP_TAG],
limit: 1,
}],
{ signal },
);
return events.reduce((max, e) => Math.max(max, e.created_at), 0);
},
});
// Unread only when there's a genuinely newer message than the last visit.
const hasUnread = enabled && newestAt > (lastSeen ?? 0);
return { hasUnread };
}