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>
72 lines
2.7 KiB
TypeScript
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 };
|
|
}
|