~3,470 new keys (5,770 total, EN=ES), ~115 more files wired. - nav + chromeUi: header, More menu, burger menu, footer, map toolbar widgets (sort/trust/weather), spot-type label helper. - placesUi: events, shops, tours, food. socialUi: crews, DMs, notifications, collectives, crew chat/coordinator. settingsUi: settings, auth/signup, onboarding, profile edit, legal. walletUi: wallet/Cashu, stash, mints, app shop, newsletter, community stats, leaderboards, profile. - Fixed 738 usages where an agent dropped the socialUi. prefix (caught by check:i18n).
295 lines
12 KiB
TypeScript
295 lines
12 KiB
TypeScript
import { useState } from 'react';
|
|
import { UserPlus, Copy, Check, Share2, Edit, Send, Loader2, ShieldCheck } from 'lucide-react';
|
|
import { Link } from 'react-router-dom';
|
|
import { nip19 } from 'nostr-tools';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from '@/components/ui/dialog';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { useTranslation } from '@/contexts/language';
|
|
import { useAuthor } from '@/hooks/useAuthor';
|
|
import { useSendCrewInvite } from '@/hooks/useCrewInvites';
|
|
import { genUserName } from '@/lib/genUserName';
|
|
import { cn } from '@/lib/utils';
|
|
import type { SkateCrew } from '@/lib/types';
|
|
|
|
interface CrewInviteDialogProps {
|
|
crew: SkateCrew;
|
|
/** Whether the viewer is the founder (gets the extra "add member" edit link + admin toggle). */
|
|
isFounder: boolean;
|
|
triggerClassName?: string;
|
|
}
|
|
|
|
/** Try to resolve a raw string (npub / nprofile / hex) to a hex pubkey. */
|
|
function resolvePubkey(input: string): string | null {
|
|
const trimmed = input.trim();
|
|
if (!trimmed) return null;
|
|
// Bare hex
|
|
if (/^[0-9a-f]{64}$/i.test(trimmed)) return trimmed.toLowerCase();
|
|
try {
|
|
const decoded = nip19.decode(trimmed);
|
|
if (decoded.type === 'npub') return decoded.data.toLowerCase();
|
|
if (decoded.type === 'nprofile') return decoded.data.pubkey.toLowerCase();
|
|
} catch {
|
|
return null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Small live preview of who a pubkey resolves to (avatar + name). */
|
|
function InviteePreview({ pubkey }: { pubkey: string }) {
|
|
const author = useAuthor(pubkey);
|
|
const name = author.data?.metadata?.name ?? genUserName(pubkey);
|
|
const pic = author.data?.metadata?.picture;
|
|
return (
|
|
<div className="flex items-center gap-2 rounded-lg border border-teal-500/25 bg-teal-500/5 px-3 py-2">
|
|
<Avatar className="h-8 w-8">
|
|
<AvatarImage src={pic} alt={name} />
|
|
<AvatarFallback className="text-[10px]">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
|
|
</Avatar>
|
|
<div className="min-w-0">
|
|
<p className="truncate text-sm font-bold text-white">{name}</p>
|
|
<p className="truncate font-mono text-[10px] text-gray-500">{nip19.npubEncode(pubkey)}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Invite flow for crew moderators (founder + admins).
|
|
*
|
|
* Two ways to invite:
|
|
* 1. **Send invite** — publishes a structured invite (kind 36229) addressed to a
|
|
* specific npub. That person gets a notification with Accept / Decline.
|
|
* When the FOUNDER sends it, the invitee is pre-listed so accepting instantly
|
|
* confirms membership. Admins can invite too (founder auto-confirms on accept).
|
|
* 2. **Share link** — copy/share a link for people not yet on Nostr.
|
|
*/
|
|
export function CrewInviteDialog({ crew, isFounder, triggerClassName }: CrewInviteDialogProps) {
|
|
const { t } = useTranslation();
|
|
const { toast } = useToast();
|
|
const [open, setOpen] = useState(false);
|
|
const [copiedLink, setCopiedLink] = useState(false);
|
|
const [copiedMsg, setCopiedMsg] = useState(false);
|
|
|
|
// Send-invite state
|
|
const [inviteeInput, setInviteeInput] = useState('');
|
|
const [note, setNote] = useState('');
|
|
const [asAdmin, setAsAdmin] = useState(false);
|
|
const { mutateAsync: sendInvite, isPending: sending } = useSendCrewInvite();
|
|
|
|
const resolvedPubkey = resolvePubkey(inviteeInput);
|
|
const inputLooksLikeKey = inviteeInput.trim().length > 0;
|
|
const invalidKey = inputLooksLikeKey && !resolvedPubkey;
|
|
|
|
const crewUrl = `${window.location.origin}/crew/${crew.pubkey}/${crew.identifier}`;
|
|
const inviteMessage =
|
|
`${t('socialUi.inviteDialog.messageIntro', { crew: crew.name })}\n${crewUrl}\n\n` +
|
|
`${t('socialUi.inviteDialog.messageBody')}`;
|
|
|
|
const copy = async (text: string, which: 'link' | 'msg') => {
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
if (which === 'link') {
|
|
setCopiedLink(true);
|
|
setTimeout(() => setCopiedLink(false), 2000);
|
|
} else {
|
|
setCopiedMsg(true);
|
|
setTimeout(() => setCopiedMsg(false), 2000);
|
|
}
|
|
toast({ title: t('common.copied') });
|
|
} catch {
|
|
toast({ title: t('socialUi.inviteDialog.copyFailed'), variant: 'destructive' });
|
|
}
|
|
};
|
|
|
|
const nativeShare = async () => {
|
|
if (navigator.share) {
|
|
try {
|
|
await navigator.share({ title: t('socialUi.inviteDialog.shareTitle', { crew: crew.name }), text: inviteMessage, url: crewUrl });
|
|
} catch {
|
|
/* cancelled */
|
|
}
|
|
} else {
|
|
copy(inviteMessage, 'msg');
|
|
}
|
|
};
|
|
|
|
const handleSend = async () => {
|
|
if (!resolvedPubkey) return;
|
|
try {
|
|
await sendInvite({
|
|
crew,
|
|
invitee: resolvedPubkey,
|
|
role: asAdmin ? 'admin' : 'member',
|
|
note,
|
|
isFounder,
|
|
});
|
|
toast({
|
|
title: t('socialUi.inviteDialog.sentTitle'),
|
|
description: t('socialUi.inviteDialog.sentDesc'),
|
|
});
|
|
setInviteeInput('');
|
|
setNote('');
|
|
setAsAdmin(false);
|
|
setOpen(false);
|
|
} catch (err) {
|
|
toast({
|
|
title: t('socialUi.inviteDialog.couldNotSend'),
|
|
description: err instanceof Error ? err.message : t('common.tryAgain'),
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className={cn('gap-1.5 border-teal-500/40 text-teal-400 hover:bg-teal-500/10', triggerClassName)}
|
|
>
|
|
<UserPlus className="h-3.5 w-3.5" /> {t('socialUi.inviteDialog.trigger')}
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<UserPlus className="h-5 w-5 text-teal-400" /> {t('socialUi.inviteDialog.title', { crew: crew.name })}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{t('socialUi.inviteDialog.description')}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<Tabs defaultValue="send" className="w-full">
|
|
<TabsList className="grid w-full grid-cols-2">
|
|
<TabsTrigger value="send" className="gap-1.5">
|
|
<Send className="h-3.5 w-3.5" /> {t('socialUi.inviteDialog.sendTab')}
|
|
</TabsTrigger>
|
|
<TabsTrigger value="share" className="gap-1.5">
|
|
<Share2 className="h-3.5 w-3.5" /> {t('socialUi.inviteDialog.shareTab')}
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* ── Send a structured invite to an npub ── */}
|
|
<TabsContent value="send" className="space-y-4 pt-2">
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-bold uppercase tracking-wide text-gray-500">
|
|
{t('socialUi.inviteDialog.npubLabel')}
|
|
</label>
|
|
<Input
|
|
value={inviteeInput}
|
|
onChange={(e) => setInviteeInput(e.target.value)}
|
|
placeholder="npub1… or nprofile1…"
|
|
className={cn('font-mono text-xs', invalidKey && 'border-red-500/50')}
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
/>
|
|
{invalidKey && (
|
|
<p className="text-xs text-red-400">{t('socialUi.inviteDialog.invalidNpub')}</p>
|
|
)}
|
|
</div>
|
|
|
|
{resolvedPubkey && <InviteePreview pubkey={resolvedPubkey} />}
|
|
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-bold uppercase tracking-wide text-gray-500">
|
|
{t('socialUi.inviteDialog.noteLabel')} <span className="font-normal text-gray-600">{t('socialUi.inviteDialog.optional')}</span>
|
|
</label>
|
|
<Textarea
|
|
value={note}
|
|
onChange={(e) => setNote(e.target.value.slice(0, 500))}
|
|
placeholder={t('socialUi.inviteDialog.notePlaceholder')}
|
|
rows={2}
|
|
className="text-sm"
|
|
/>
|
|
</div>
|
|
|
|
{isFounder && (
|
|
<div className="flex items-center justify-between rounded-lg border border-teal-500/25 bg-teal-500/5 px-3 py-2.5">
|
|
<div className="flex items-center gap-2">
|
|
<ShieldCheck className="h-4 w-4 text-teal-400" />
|
|
<div>
|
|
<Label htmlFor="invite-admin" className="text-sm font-bold text-white">
|
|
{t('socialUi.inviteDialog.inviteAsAdmin')}
|
|
</Label>
|
|
<p className="text-[11px] text-gray-500">{t('socialUi.inviteDialog.inviteAsAdminDesc')}</p>
|
|
</div>
|
|
</div>
|
|
<Switch id="invite-admin" checked={asAdmin} onCheckedChange={setAsAdmin} />
|
|
</div>
|
|
)}
|
|
|
|
<Button
|
|
type="button"
|
|
onClick={handleSend}
|
|
disabled={!resolvedPubkey || sending}
|
|
className="w-full gap-1.5 bg-teal-500 font-bold text-black hover:bg-teal-600"
|
|
>
|
|
{sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
|
{t('socialUi.inviteDialog.sendButton')}
|
|
</Button>
|
|
<p className="text-center text-[11px] text-gray-500">
|
|
{t('socialUi.inviteDialog.sendFooter')}
|
|
</p>
|
|
</TabsContent>
|
|
|
|
{/* ── Share a link (for people not on Nostr yet) ── */}
|
|
<TabsContent value="share" className="space-y-4 pt-2">
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-bold uppercase tracking-wide text-gray-500">{t('socialUi.inviteDialog.inviteLink')}</label>
|
|
<div className="flex gap-2">
|
|
<Input readOnly value={crewUrl} className="font-mono text-xs" onFocus={(e) => e.target.select()} />
|
|
<Button type="button" variant="outline" size="icon" className="shrink-0" onClick={() => copy(crewUrl, 'link')}>
|
|
{copiedLink ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-bold uppercase tracking-wide text-gray-500">{t('socialUi.inviteDialog.messageLabel')}</label>
|
|
<Textarea readOnly value={inviteMessage} rows={4} className="text-sm" />
|
|
<div className="flex gap-2">
|
|
<Button type="button" variant="outline" className="flex-1 gap-1.5" onClick={() => copy(inviteMessage, 'msg')}>
|
|
{copiedMsg ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
|
|
{t('socialUi.inviteDialog.copyMessage')}
|
|
</Button>
|
|
<Button type="button" className="flex-1 gap-1.5 bg-teal-500 hover:bg-teal-600 text-black" onClick={nativeShare}>
|
|
<Share2 className="h-4 w-4" /> {t('common.share')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{isFounder && (
|
|
<div className="rounded-lg border border-teal-500/30 bg-teal-500/5 p-3">
|
|
<p className="mb-2 text-xs text-gray-400">
|
|
{t('socialUi.inviteDialog.founderHint')}
|
|
</p>
|
|
<Link to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}>
|
|
<Button type="button" variant="outline" size="sm" className="w-full gap-1.5 border-teal-500/40 text-teal-400 hover:bg-teal-500/10">
|
|
<Edit className="h-3.5 w-3.5" /> {t('socialUi.inviteDialog.addMemberSettings')}
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
</Tabs>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|