SkateSpots/src/hooks/useDeleteEntity.ts
shakespeare.diy 63db160b80 fix(amber): Amethyst-style foreground-intent signer to end the GrapheneOS sign hang
Root cause: the stock nostr-signer-capacitor-plugin (0.0.5) tries Amber's
background ContentProvider first, which GrapheneOS blocks and never returns,
with no clean fallback — so signing hung 90s and Amber never appeared.

Fix (matches Amethyst, the reference NIP-55 client):
- New native Capacitor plugin AmberIntent (android-templates/AmberIntentPlugin.java)
  that goes STRAIGHT to a foreground nostrsigner: intent via a properly-registered
  ActivityResultLauncher, tracks each request by id, and times out gracefully.
- MainActivity now registers the launcher in onCreate (AndroidX contract) and
  hands it to the plugin, while staying a safe stock BridgeActivity (no
  WebViewClient override → no black screen).
- inject-main-activity.mjs now installs both MainActivity.java and AmberIntentPlugin.java.
- capacitorAmberSigner.ts rewired to registerPlugin('AmberIntent'); every op is a
  user-visible Amber Approve/Reject popup; rejections surface as a friendly message.
- Removed the dead npm nostr-signer-capacitor-plugin dependency (and its phantom
  coracle fork spec) plus its vite external entry — signing no longer uses it.
- Delete flow copy updated to the new foreground UX (Approve in Amber / Cancelled
  on reject) instead of the diagnostic SIGN/PUBLISH narration.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-07 12:39:00 +07:00

155 lines
5.2 KiB
TypeScript

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { isAdminPubkey } from '@/lib/admins';
import { addLocalHidden } from '@/lib/localModeration';
import { toast } from '@/hooks/useToast';
import type { NostrEvent } from '@nostrify/nostrify';
/**
* DIAGNOSTIC DELETE
* =================
* We could not identify WHERE the delete hangs (signing? relay publish?), so
* this version narrates every step on-screen via toasts and splits the flow into
* explicit, independently-timed phases:
*
* 1. SIGN — call the signer (Amber). Own timeout. If it hangs here, Amber
* never returned (app↔Amber problem).
* 2. PUBLISH — send the signed event to relays. Own timeout. If it hangs here,
* the relay is the problem, not Amber.
*
* Whatever step is on screen when it freezes tells us EXACTLY what to fix.
*/
const SIGN_TIMEOUT_MS = 90_000; // matches the signer's own cap
const PUBLISH_TIMEOUT_MS = 20_000; // relays should answer well within this
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`${label} timed out after ${Math.round(ms / 1000)}s.`));
}, ms);
promise.then(
(v) => {
clearTimeout(timer);
resolve(v);
},
(e) => {
clearTimeout(timer);
reject(e);
},
);
});
}
export function useDeleteEntity() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
event,
reason,
}: {
event: NostrEvent;
reason?: string;
}) => runDelete(event, reason),
onSuccess: () => {
queryClient.invalidateQueries();
},
});
async function runDelete(event: NostrEvent, reason?: string): Promise<void> {
if (!user) throw new Error('You must be logged in to delete.');
const isOwner = user.pubkey === event.pubkey;
const isAdmin = isAdminPubkey(user.pubkey);
if (!isOwner && !isAdmin) {
throw new Error('You can only delete your own listing.');
}
const dTag = event.tags.find(([name]) => name === 'd')?.[1] ?? '';
const coord = `${event.kind}:${event.pubkey}:${dTag}`;
const deletionTags: string[][] = [
['a', coord],
['e', event.id],
['k', String(event.kind)],
];
const template = {
kind: 5,
content: reason?.trim() || '',
tags: deletionTags,
created_at: Math.floor(Date.now() / 1000),
};
// ── PHASE 1: SIGN ────────────────────────────────────────────────────────
// With the Amethyst-style foreground signer, Amber pops up its Approve/Reject
// screen directly — no more silent ContentProvider stall. The signer has its
// own graceful timeout, but we keep an outer guard as a belt-and-braces.
toast({
title: 'Approve in Amber',
description: 'Amber will open — tap Approve to confirm the deletion.',
});
if (!user.signer) {
throw new Error('No signer available. Please log in again.');
}
let signed: NostrEvent;
try {
signed = await withTimeout(
user.signer.signEvent(template),
SIGN_TIMEOUT_MS,
'Signing (Amber)',
);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
// Distinguish a deliberate rejection from a real failure.
if (/reject/i.test(msg)) {
toast({ title: 'Cancelled', description: 'You rejected the request in Amber.' });
throw new Error('Signing cancelled.');
}
toast({
title: "Couldn't sign",
description: msg,
variant: 'destructive',
});
throw new Error(`SIGN step failed: ${msg}`);
}
// ── PHASE 2: PUBLISH ─────────────────────────────────────────────────────
toast({
title: 'Step 2/2: Publishing…',
description: 'Sending the deletion to relays.',
});
try {
await withTimeout(
nostr.event(signed),
PUBLISH_TIMEOUT_MS,
'Publishing (relays)',
);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
// The event was SIGNED fine — the relay is the slow/failing part. Hide it
// locally so the user's action still takes visible effect on this device.
if (isAdmin && !isOwner) addLocalHidden({ coord, id: event.id });
toast({
title: 'Stuck at PUBLISHING',
description: `Relay step failed: ${msg}. It was signed OK; the relay didn't confirm.`,
variant: 'destructive',
});
throw new Error(`PUBLISH step failed: ${msg}`);
}
// Admin: hide locally too (no signature needed).
if (isAdmin && !isOwner) {
addLocalHidden({ coord, id: event.id });
}
toast({ title: 'Deleted', description: 'The deletion was signed and published.' });
}
}