SkateSpots/src/components/CrewRequestToJoin.tsx
shakespeare.diy 0ac5c1019d fix: eliminate all render-path nip44 getter crashes (Browser extension not available)
Root cause: NUser's extension signer exposes .nip44 as a getter that calls
.nostr (window.nostr) and THROWS when the extension isn't present. Any
synchronous access during render crashes the whole tree — the actual culprit
was useStash's `isEncryptionAvailable: !!user?.signer?.nip44` in its return
object, evaluated every render even when logged out.

- Add src/lib/signerCapabilities.ts: hasNip44/hasNip04 helpers that determine
  capability via login type + direct window.nostr probe (no throwing getter).
- useStash: use the helper for hasNip44 + isEncryptionAvailable
- useDirectMessages: replace all render-path `enabled: !!user?.signer?.nip44`
  with a pre-computed nip44Available across all 4 conversation hooks
- CrewChat, CrewRequestToJoin, ConversationPage, DirectMessagesPage,
  ContactsPage: use the safe helper in render/guard paths

Async accesses (inside queryFn/callbacks) are left as-is — they reject
gracefully within react-query and never crash render.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-07-30 19:37:08 +07:00

136 lines
4.4 KiB
TypeScript

import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrLogin } from '@nostrify/react/login';
import { useSendDirectMessage } from '@/hooks/useDirectMessages';
import { useToast } from '@/hooks/useToast';
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
import { UserPlus, Loader2, Lock } from 'lucide-react';
interface CrewRequestToJoinProps {
crewName: string;
founderPubkey: string;
/** Delegated admins — they also receive the request so growth isn't founder-only. */
admins?: string[];
}
/**
* Lets a skater request to join a crew via an encrypted NIP-17 DM to the
* founder and admins. This removes the founder-push-only bottleneck: anyone can
* knock, and any admin can then add them from the crew editor.
*
* Privacy: the request is a gift-wrapped DM (kinds 14/13/1059) — only the
* recipients can read it. No public "join" event leaks the social graph.
*/
export function CrewRequestToJoin({ crewName, founderPubkey, admins = [] }: CrewRequestToJoinProps) {
const { user } = useCurrentUser();
const { logins } = useNostrLogin();
const { mutateAsync: sendMessage, isPending } = useSendDirectMessage();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const [note, setNote] = useState('');
if (!user) return null;
const recipients = Array.from(new Set([founderPubkey, ...admins])).filter(
(pk) => pk !== user.pubkey,
);
// Don't show for people already in the leadership.
if (recipients.length === 0) return null;
const canDM = checkNip44(user, logins);
const handleSend = async () => {
if (!canDM) {
toast({
title: 'Encryption unavailable',
description: 'Your signer does not support NIP-44 encryption. Please upgrade your extension.',
variant: 'destructive',
});
return;
}
try {
const body =
`🛹 Request to join ${crewName}` +
(note.trim() ? `\n\n${note.trim()}` : '\n\nWould love to roll with the crew.');
await sendMessage({
recipients,
content: body,
subject: `${crewName} — join request`,
});
toast({
title: 'Request sent!',
description: `The ${crewName} crew leadership got your encrypted message.`,
});
setOpen(false);
setNote('');
} catch (err) {
toast({
title: 'Could not send request',
description: err instanceof Error ? err.message : 'Please try again.',
variant: 'destructive',
});
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-1.5 border-teal-500/40 text-teal-400 hover:bg-teal-500/10"
>
<UserPlus className="h-3.5 w-3.5" /> Request to join
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<UserPlus className="h-5 w-5 text-teal-400" /> Join {crewName}
</DialogTitle>
<DialogDescription className="flex items-center gap-1.5">
<Lock className="h-3 w-3 text-teal-400" />
Sends a private, encrypted message to the crew's founder &amp; admins.
</DialogDescription>
</DialogHeader>
<Textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Say hi — where you skate, who you know, why you want in…"
rows={4}
maxLength={1000}
className="resize-none"
/>
<DialogFooter>
<Button
onClick={handleSend}
disabled={isPending}
className="w-full bg-teal-500 font-bold text-black hover:bg-teal-600"
>
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Sending…
</>
) : (
<>
<UserPlus className="mr-2 h-4 w-4" /> Send join request
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}