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>
136 lines
4.4 KiB
TypeScript
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 & 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>
|
|
);
|
|
}
|