130 lines
4.7 KiB
TypeScript
130 lines
4.7 KiB
TypeScript
import { useCallback, useMemo, useRef } 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, []);
|
|
// The bell can close while a decrypt loop is running; React Query stops
|
|
// scheduling new fetches when `enabled` flips, but does not necessarily abort
|
|
// the current queryFn. Read the live open state inside the loop.
|
|
const openedRef = useRef(opened);
|
|
openedRef.current = opened;
|
|
// 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) {
|
|
// Closing the bell/unmounting the query must stop further Amber
|
|
// decrypt prompts; the old loop kept going after navigation.
|
|
if (!openedRef.current || c.signal.aborted) break;
|
|
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,
|
|
};
|
|
}
|