SkateSpots/src/hooks/useCordnJoinNotifications.ts

122 lines
4.2 KiB
TypeScript

import { useCallback, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNostrLogin } from '@nostrify/react/login';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { hasNip44, isInteractiveSigner } from '@/lib/signerCapabilities';
import { APP_DATA_KIND } from '@/lib/types';
import {
CORDN_JOIN_TAG,
CORDN_WATCHER_PUBKEY,
type CordnJoinNotice,
} from '@/lib/cordn/watch';
const DISMISSED_KEY = 'skatespots:cordn-join-dismissed';
/**
* New Cordn private-chat join requests for this viewer, delivered by the
* background watcher as encrypted NIP-78 events. The watcher also sends the
* same event as a NIP-17 DM, so the OS push path works while the app is closed.
*/
export function useCordnJoinNotifications(opened = false) {
const { user } = useCurrentUser();
const { logins } = useNostrLogin();
const interactive = isInteractiveSigner(user, logins);
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const [dismissed, setDismissed] = useLocalStorage<string[]>(DISMISSED_KEY, []);
// Amber prompts once per decrypt. Load these only when the bell is opened;
// passive signers can fetch them quietly in the background.
const canRead = !!user?.pubkey && hasNip44(user, logins) && (!interactive || opened);
const query = useQuery({
queryKey: ['cordn-join-notifications', user?.pubkey, getQueryKeySuffix()],
enabled: canRead,
staleTime: 30_000,
queryFn: async (c): Promise<CordnJoinNotice[]> => {
if (!user?.pubkey || !user.signer.nip44) return [];
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6_000)]);
try {
const events = await querySkateContent(
[
{
kinds: [APP_DATA_KIND],
authors: [CORDN_WATCHER_PUBKEY],
'#t': [CORDN_JOIN_TAG],
'#p': [user.pubkey],
limit: 100,
},
],
{ signal },
);
const notices: CordnJoinNotice[] = [];
for (const event of events) {
let plain: string;
try {
plain = event.content.trim().startsWith('{')
? event.content
: await user.signer.nip44.decrypt(CORDN_WATCHER_PUBKEY, event.content);
} catch {
continue;
}
try {
const parsed = JSON.parse(plain) as Partial<CordnJoinNotice>;
if (
typeof parsed.id !== 'string' ||
typeof parsed.gid !== 'string' ||
typeof parsed.crewName !== 'string' ||
typeof parsed.crewPubkey !== 'string' ||
typeof parsed.crewIdentifier !== 'string' ||
typeof parsed.requester !== 'string'
) {
continue;
}
const rawAt = typeof parsed.at === 'number' ? parsed.at : event.created_at;
notices.push({
id: parsed.id,
gid: parsed.gid,
crewName: parsed.crewName,
crewPubkey: parsed.crewPubkey,
crewIdentifier: parsed.crewIdentifier,
requester: parsed.requester,
at: rawAt > 1e12 ? Math.floor(rawAt / 1000) : rawAt,
eventId: event.id,
});
} catch {
// Ignore malformed notices.
}
}
const seen = new Set<string>();
return notices
.filter((notice) => {
if (seen.has(notice.id)) return false;
seen.add(notice.id);
return true;
})
.sort((a, b) => b.at - a.at)
.slice(0, 30);
} catch {
return [];
}
},
});
const dismissedSet = useMemo(() => new Set(dismissed), [dismissed]);
const requests = useMemo(
() => (query.data ?? []).filter((notice) => !dismissedSet.has(notice.id)),
[query.data, dismissedSet],
);
const dismiss = useCallback(
(id: string) => {
setDismissed((current) => (current.includes(id) ? current : [...current, id].slice(-200)));
},
[setDismissed],
);
return {
requests,
isLoading: query.isLoading,
dismiss,
};
}