feat(cordn): polish private chat onboarding, polling and invites

This commit is contained in:
SkateSpots 2026-10-27 16:22:25 +07:00
parent 1fc91d1192
commit 690c9c93bd
28 changed files with 1519 additions and 149 deletions

View file

@ -1,12 +1,39 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { KeyRound, Lock, Plus, RefreshCw, Send, UserPlus, Copy, Check, ImagePlus, Loader2, Share2 } from 'lucide-react';
import {
AlertTriangle,
Check,
ChevronDown,
Copy,
ImagePlus,
KeyRound,
Loader2,
Lock,
Plus,
QrCode,
RefreshCw,
Send,
Share2,
UserPlus,
Users,
X,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { QRCodeCanvas } from '@/components/ui/qrcode';
import { ImageWithFallback } from '@/components/ImageWithFallback';
import { useTranslation } from '@/contexts/language';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useCordnNative } from '@/hooks/useCordnNative';
@ -23,6 +50,14 @@ interface CordnNativePanelProps {
isMember: boolean;
}
type MemberRole = 'founder' | 'admin' | 'member';
interface PendingMessage {
text: string;
imageUrl?: string;
state: 'sending' | 'failed';
}
/**
* The crew's **native Cordn (MLS) chat** — real end-to-end encryption between
* members, routed through the crew's coordinator. See `lib/cordn/native/`.
@ -31,23 +66,83 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
const { t } = useTranslation();
const { user } = useCurrentUser();
const native = useCordnNative({ crew, enabled: !!user && isMember });
// Stable callbacks (the hook's return object is recreated each render).
const sendNative = native.send;
const checkInviteNative = native.checkInvite;
const [draft, setDraft] = useState('');
const [copied, setCopied] = useState(false);
const [copiedLink, setCopiedLink] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const [awaiting, setAwaiting] = useState(false);
const [pending, setPending] = useState<PendingMessage | null>(null);
const [sentIds, setSentIds] = useState<Set<string>>(() => new Set());
const [inviteOpen, setInviteOpen] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
const checkingRef = useRef(false);
const loadedRequestsRef = useRef(false);
const { mutateAsync: uploadFile, isPending: uploading } = useUploadFile();
const inviteUrl = useMemo(
() => `${window.location.origin}/#/crew/${crew.pubkey}/${crew.identifier}?tab=mls`,
[crew.pubkey, crew.identifier],
);
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
}, [native.messages.length]);
}, [native.messages.length, pending?.state]);
useEffect(() => {
if (native.group && awaiting) setAwaiting(false);
}, [native.group, awaiting]);
// Founders should see waiting requests without hunting for the check button.
// One quiet read when the group opens; the manual button handles the rest.
const loadRequestsNative = native.loadRequests;
useEffect(() => {
if (!canManage || !native.group || !native.ready || loadedRequestsRef.current) return;
loadedRequestsRef.current = true;
void loadRequestsNative();
}, [canManage, native.group, native.ready, loadRequestsNative]);
const markSent = useCallback((id: string) => {
if (!id) return;
setSentIds((prev) => new Set(prev).add(id));
window.setTimeout(() => {
setSentIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
}, 3000);
}, []);
const deliver = useCallback(
async (text: string, imageUrl?: string) => {
setNotice(null);
setPending({ text, imageUrl, state: 'sending' });
const sent = await sendNative(text, imageUrl);
if (sent) {
markSent(sent.id);
setPending(null);
} else {
setPending((current) => (current ? { ...current, state: 'failed' } : current));
}
},
[sendNative, markSent],
);
const handleSend = useCallback(async () => {
const body = draft.trim();
if (!body) return;
if (!body || native.busy) return;
setDraft('');
await native.send(body);
}, [draft, native]);
await deliver(body);
}, [draft, native.busy, deliver]);
const retrySend = useCallback(async () => {
if (!pending || pending.state !== 'failed') return;
await deliver(pending.text, pending.imageUrl);
}, [pending, deliver]);
const onPickImage = useCallback(
async (file: File | undefined) => {
@ -58,12 +153,12 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
if (!url) throw new Error('upload returned no url');
const caption = draft.trim();
setDraft('');
await native.send(caption, url);
await deliver(caption, url);
} catch {
// The hook surfaces nothing on failure; keep the composer usable.
setNotice(t('cordnUi.uploadFailed'));
}
},
[uploadFile, draft, native],
[uploadFile, draft, deliver, t],
);
const copyRef = useCallback(async () => {
@ -77,35 +172,93 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
}
}, [native.groupRef]);
const copyInvite = useCallback(async () => {
try {
await navigator.clipboard.writeText(inviteUrl);
setCopiedLink(true);
window.setTimeout(() => setCopiedLink(false), 1500);
} catch {
// clipboard blocked — the link is visible to select.
}
}, [inviteUrl]);
const checkNow = useCallback(async () => {
if (checkingRef.current) return false;
checkingRef.current = true;
try {
return await checkInviteNative();
} finally {
checkingRef.current = false;
}
}, [checkInviteNative]);
const handleRequestJoin = useCallback(async () => {
setNotice(null);
await native.requestJoin();
setNotice(t('cordnUi.requested'));
}, [native, t]);
const requested = await native.requestJoin();
if (!requested) return;
setAwaiting(true);
}, [native]);
const handleCheckInvite = useCallback(async () => {
if (native.busy) return;
setNotice(null);
const joined = await native.checkInvite();
if (!joined) setNotice(t('cordnUi.noInvite'));
}, [native, t]);
const joined = await checkNow();
if (joined) {
setAwaiting(false);
return;
}
if (!awaiting) setNotice(t('cordnUi.noInvite'));
}, [checkNow, awaiting, t, native.busy]);
// Poll the coordinator while a join request is pending. Backs off from 10s to
// 30s so a founder who approves later is picked up without a manual tap.
// Interactive signers (Amber) are excluded — polling would pop their signer.
useEffect(() => {
if (!awaiting || native.group || !native.ready || native.interactiveHintVisible) return;
let cancelled = false;
let timer: number | undefined;
let delay = 10_000;
const tick = async () => {
if (cancelled) return;
const joined = await checkNow();
if (cancelled) return;
if (joined) {
setAwaiting(false);
setNotice(null);
return;
}
delay = Math.min(30_000, Math.round(delay * 1.5));
timer = window.setTimeout(() => void tick(), delay);
};
timer = window.setTimeout(() => void tick(), delay);
return () => {
cancelled = true;
if (timer !== undefined) window.clearTimeout(timer);
};
}, [awaiting, native.group, native.ready, native.interactiveHintVisible, checkNow]);
// "Join our crew chat" invite. The chat needs the person to be a crew member
// first, so the link just drops them on this crew's private-chat tab with the
// "Ask to join" flow ready. Uses the OS share sheet where available.
const shareInvite = useCallback(async () => {
const url = `${window.location.origin}/#/crew/${crew.pubkey}/${crew.identifier}?tab=mls`;
try {
if (navigator.share) {
await navigator.share({ title: crew.name, text: t('cordnUi.shareInviteText', { crew: crew.name }), url });
await navigator.share({
title: crew.name,
text: t('cordnUi.shareInviteText', { crew: crew.name }),
url: inviteUrl,
});
return;
}
await navigator.clipboard.writeText(url);
await navigator.clipboard.writeText(inviteUrl);
setNotice(t('cordnUi.inviteCopied'));
window.setTimeout(() => setNotice(null), 2500);
} catch {
// share cancelled or clipboard blocked — nothing to do
}
}, [crew, t]);
}, [crew.name, inviteUrl, t]);
if (!user) {
return <Notice icon={<Lock className="h-5 w-5" />} text={t('cordnUi.notLoggedIn')} />;
@ -118,29 +271,86 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
}
const status = native.ready ? t('cordnUi.ready') : t('cordnUi.connecting');
const steps = canManage
? [
t('cordnUi.onboardingFounder1'),
t('cordnUi.onboardingFounder2'),
t('cordnUi.onboardingFounder3'),
]
: [
t('cordnUi.onboardingMember1'),
t('cordnUi.onboardingMember2'),
t('cordnUi.onboardingMember3'),
];
const roleOf = (pubkey: string): MemberRole =>
pubkey === crew.pubkey ? 'founder' : crew.admins.includes(pubkey) ? 'admin' : 'member';
return (
<Card className="border-white/10 bg-black/40">
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<CardTitle className="flex items-center gap-2 text-base">
<Lock className="h-4 w-4 text-teal-400" /> {t('cordnUi.title')}
</CardTitle>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center justify-end gap-2">
{native.group && (
<Badge variant="secondary" className="text-[10px]">
{t('cordnUi.memberCount', { count: native.group.members.length })}
</Badge>
)}
<Badge
variant="outline"
className={cn(
'text-[10px]',
native.ready ? 'border-teal-500/40 text-teal-300' : 'text-gray-400',
)}
>
{status}
</Badge>
{native.error ? (
<>
<Badge
variant="outline"
className="gap-1 border-red-500/40 text-[10px] text-red-300"
>
<AlertTriangle className="h-3 w-3" /> {t('cordnUi.unreachable')}
</Badge>
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={native.retry}
aria-label={t('cordnUi.retry')}
>
<RefreshCw className="h-3.5 w-3.5" />
</Button>
</>
) : (
<>
<Badge
variant="outline"
className={cn(
'text-[10px]',
native.ready ? 'border-teal-500/40 text-teal-300' : 'text-gray-400',
)}
>
{status}
</Badge>
{!native.ready && (
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={native.retry}
aria-label={t('cordnUi.retry')}
>
<RefreshCw className="h-3.5 w-3.5" />
</Button>
)}
</>
)}
{native.group && (
<Button
size="sm"
variant="outline"
className="h-7 gap-1 px-2 text-xs"
onClick={() => setInviteOpen(true)}
>
<Share2 className="h-3 w-3" /> {t('cordnUi.shareInvite')}
</Button>
)}
</div>
</div>
<p className="pt-1 text-xs text-gray-500">{t('cordnUi.subtitle')}</p>
@ -148,16 +358,54 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
<CardContent className="space-y-3">
{native.error && (
<div className="rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300">
{t('cordnUi.errorPrefix')}: {native.error}
<div className="rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-200">
<div className="flex flex-wrap items-center justify-between gap-2">
<p>{t('cordnUi.errorUnreachable')}</p>
<Button
size="sm"
variant="outline"
className="h-7 gap-1 border-red-500/30 text-red-200 hover:text-red-100"
onClick={native.retry}
>
<RefreshCw className="h-3 w-3" /> {t('cordnUi.retry')}
</Button>
</div>
<details className="mt-1">
<summary className="cursor-pointer text-[10px] text-red-300/70">
{t('cordnUi.errorDetails')}
</summary>
<p className="mt-1 break-words font-mono text-[10px] text-red-300/70">
{native.error}
</p>
</details>
</div>
)}
{!native.group ? (
<div className="space-y-3 py-4 text-center">
<div className="space-y-4 py-4 text-center">
<p className="text-sm text-gray-400">
{canManage ? t('cordnUi.noGroupAdmin') : t('cordnUi.noGroupMember')}
{canManage
? t('cordnUi.noGroupAdmin')
: native.keyPackageReady
? t('cordnUi.noGroupMember')
: t('cordnUi.setupDeviceBody')}
</p>
<ol className="mx-auto max-w-sm space-y-2 text-left">
{steps.map((step, index) => (
<li key={step} className="flex items-start gap-2 text-xs text-gray-400">
<span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-teal-500/15 text-[10px] font-bold text-teal-300">
{index + 1}
</span>
<span>{step}</span>
</li>
))}
</ol>
<p className="mx-auto max-w-sm text-[11px] text-gray-500">
{t('cordnUi.encryptedNote')}
</p>
<div className="flex flex-wrap items-center justify-center gap-2">
{canManage ? (
<Button
@ -166,17 +414,23 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
onClick={() => void native.createChat()}
className="gap-1.5 bg-teal-500 font-semibold text-black hover:bg-teal-400"
>
<Plus className="h-3.5 w-3.5" /> {t('cordnUi.create')}
{native.busy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Plus className="h-3.5 w-3.5" />
)}
{t('cordnUi.create')}
</Button>
) : (
<>
<Button
size="sm"
disabled={!native.ready || native.busy}
disabled={!native.ready || native.busy || awaiting}
onClick={() => void handleRequestJoin()}
className="gap-1.5 bg-teal-500 font-semibold text-black hover:bg-teal-400"
>
<UserPlus className="h-3.5 w-3.5" /> {t('cordnUi.requestJoin')}
<UserPlus className="h-3.5 w-3.5" />{' '}
{native.keyPackageReady ? t('cordnUi.requestJoin') : t('cordnUi.setupDevice')}
</Button>
<Button
size="sm"
@ -190,18 +444,60 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
</>
)}
</div>
{notice && (
<p className="text-center text-xs text-teal-300">{notice}</p>
{awaiting && (
<p className="flex items-center justify-center gap-1.5 text-xs text-teal-300">
{native.interactiveHintVisible ? (
<>
<RefreshCw className="h-3.5 w-3.5" /> {t('cordnUi.waitingApprovalManual')}
</>
) : (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t('cordnUi.waitingApproval')}
</>
)}
</p>
)}
{notice && <p className="text-center text-xs text-teal-300">{notice}</p>}
<AdvancedPanel
groupRef={native.groupRef}
coordinatorPubkey={native.coordinator.serverPubkey}
keyPackageReady={native.keyPackageReady}
copied={copied}
onCopyRef={() => void copyRef()}
/>
</div>
) : (
<>
<details className="group rounded-md border border-white/10 bg-black/20 px-2.5 py-2">
<summary className="flex cursor-pointer list-none items-center gap-1.5 text-xs text-gray-400">
<Users className="h-3.5 w-3.5 text-teal-400" />
{t('cordnUi.members')}
<span className="text-gray-600">· {native.group.members.length}</span>
<ChevronDown className="ml-auto h-3 w-3 transition-transform group-open:rotate-180" />
</summary>
<div className="mt-2 space-y-2">
{native.group.members.map((member) => (
<MemberRow
key={member.pubkey}
pubkey={member.pubkey}
role={roleOf(member.pubkey)}
isSelf={member.pubkey === user.pubkey}
/>
))}
</div>
</details>
<div
ref={scrollRef}
className="max-h-80 space-y-2 overflow-y-auto rounded-md border border-white/10 bg-black/30 p-3"
>
{native.messages.length === 0 ? (
<p className="py-6 text-center text-xs text-gray-500">{t('cordnUi.placeholder')}</p>
{native.messages.length === 0 && !pending ? (
<p className="py-6 text-center text-xs text-gray-500">
{t('cordnUi.placeholder')}
</p>
) : (
native.messages.map((m) => (
<div
@ -209,8 +505,17 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
className={cn('flex flex-col gap-0.5', m.direction === 'outbound' && 'items-end')}
>
<div className="flex items-center gap-2 text-[10px] uppercase tracking-wide text-gray-500">
<span>{m.sender === user.pubkey ? t('cordnUi.you') : shortNpub(m.sender)}</span>
<span>{formatDistanceToNow(new Date(m.createdAt * 1000), { addSuffix: true })}</span>
<span>
{m.sender === user.pubkey ? t('cordnUi.you') : shortNpub(m.sender)}
</span>
<span>
{formatDistanceToNow(new Date(m.createdAt * 1000), { addSuffix: true })}
</span>
{m.direction === 'outbound' && sentIds.has(m.id) && (
<span className="flex items-center gap-0.5 text-teal-400">
<Check className="h-3 w-3" /> {t('cordnUi.sent')}
</span>
)}
</div>
<div
className={cn(
@ -228,11 +533,58 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
className="max-h-60 w-full rounded-lg object-cover"
/>
)}
{m.content && <div className="whitespace-pre-wrap break-words">{m.content}</div>}
{m.content && (
<div className="whitespace-pre-wrap break-words">{m.content}</div>
)}
</div>
</div>
))
)}
{pending && (
<div className="flex flex-col items-end gap-1">
<div className="flex items-center gap-2 text-[10px] uppercase tracking-wide text-gray-500">
<span>{t('cordnUi.you')}</span>
<span className={cn(pending.state === 'failed' && 'text-red-300')}>
{pending.state === 'sending'
? t('cordnUi.sending')
: t('cordnUi.sendFailed')}
</span>
</div>
<div className="max-w-[85%] space-y-1 overflow-hidden rounded-2xl bg-teal-500/20 px-3 py-1.5 text-sm text-teal-100 opacity-80">
{pending.imageUrl && (
<img
src={pending.imageUrl}
alt=""
className="max-h-60 w-full rounded-lg object-cover"
/>
)}
{pending.text && (
<div className="whitespace-pre-wrap break-words">{pending.text}</div>
)}
</div>
{pending.state === 'failed' && (
<div className="flex items-center gap-1">
<Button
size="sm"
variant="outline"
className="h-7 gap-1 px-2 text-xs"
onClick={() => void retrySend()}
>
<RefreshCw className="h-3 w-3" /> {t('cordnUi.retry')}
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 gap-1 px-2 text-xs text-gray-400"
onClick={() => setPending(null)}
>
<X className="h-3 w-3" /> {t('cordnUi.dismiss')}
</Button>
</div>
)}
</div>
)}
</div>
<div className="flex items-center gap-2">
@ -253,7 +605,11 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
onClick={() => fileRef.current?.click()}
aria-label={t('cordnUi.attachImage')}
>
{uploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <ImagePlus className="h-4 w-4" />}
{uploading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ImagePlus className="h-4 w-4" />
)}
</Button>
<Input
value={draft}
@ -273,7 +629,11 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
onClick={() => void handleSend()}
aria-label={t('cordnUi.send')}
>
<Send className="h-4 w-4" />
{native.busy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
<Button
size="icon"
@ -285,69 +645,50 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
</Button>
</div>
{native.groupRef && (
<div className="flex items-center gap-2 text-[11px] text-gray-500">
<span className="shrink-0">{t('cordnUi.groupRef')}</span>
<code className="min-w-0 flex-1 truncate rounded bg-black/40 px-1.5 py-0.5 text-gray-400">
{native.groupRef}
</code>
<Button
size="sm"
variant="ghost"
className="h-7 gap-1 px-2 text-xs"
onClick={() => void copyRef()}
>
{copied ? <Check className="h-3 w-3 text-teal-400" /> : <Copy className="h-3 w-3" />}
{copied ? t('cordnUi.copied') : t('cordnUi.copyRef')}
</Button>
</div>
)}
{notice && <p className="text-xs text-teal-300">{notice}</p>}
{native.interactiveHintVisible && (
<p className="text-[11px] text-gray-500">{t('cordnUi.interactiveHint')}</p>
)}
<AdvancedPanel
groupRef={native.groupRef}
coordinatorPubkey={native.coordinator.serverPubkey}
keyPackageReady={native.keyPackageReady || !!native.group}
copied={copied}
onCopyRef={() => void copyRef()}
/>
{canManage && (
<div className="space-y-2 rounded-md border border-white/10 bg-black/20 p-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-300">{t('cordnUi.requests')}</span>
<div className="flex items-center gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => void shareInvite()}
className="h-7 gap-1 text-xs"
>
<Share2 className="h-3 w-3" /> {t('cordnUi.shareInvite')}
</Button>
<Button
size="sm"
variant="ghost"
disabled={native.busy}
onClick={() => void native.loadRequests()}
className="h-7 gap-1 text-xs"
>
<RefreshCw className="h-3 w-3" /> {t('cordnUi.checkRequests')}
</Button>
</div>
<span className="text-xs font-medium text-gray-300">
{t('cordnUi.requests')}
</span>
<Button
size="sm"
variant="ghost"
disabled={native.busy}
onClick={() => void native.loadRequests()}
className="h-7 gap-1 text-xs"
>
<RefreshCw className="h-3 w-3" /> {t('cordnUi.checkRequests')}
</Button>
</div>
{native.requests.length === 0 ? (
<p className="px-1 text-[11px] text-gray-500">
{native.group.members.length <= 1 ? t('cordnUi.inviteHint') : t('cordnUi.noRequests')}
{native.group.members.length <= 1
? t('cordnUi.inviteHint')
: t('cordnUi.noRequests')}
</p>
) : (
native.requests.map((req) => (
<div key={`${req.pk}-${req.kp_ref}`} className="flex items-center justify-between gap-2">
<span className="truncate text-xs text-gray-300">{shortNpub(req.pk)}</span>
<Button
size="sm"
disabled={native.busy}
onClick={() => void native.approve(req.pk)}
className="h-7 gap-1 text-xs"
>
<UserPlus className="h-3 w-3" /> {t('cordnUi.approve')}
</Button>
</div>
<RequestRow
key={`${req.pk}-${req.kp_ref}`}
pubkey={req.pk}
disabled={native.busy}
onApprove={() => void native.approve(req.pk)}
/>
))
)}
</div>
@ -355,10 +696,186 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
</>
)}
</CardContent>
<Dialog open={inviteOpen} onOpenChange={setInviteOpen}>
<DialogContent className="max-w-sm border-white/10 bg-zinc-950">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<QrCode className="h-4 w-4 text-teal-400" /> {t('cordnUi.qrTitle')}
</DialogTitle>
<DialogDescription>{t('cordnUi.qrHint')}</DialogDescription>
</DialogHeader>
<div className="flex justify-center rounded-xl bg-white p-3">
<QRCodeCanvas value={inviteUrl} size={220} level="M" />
</div>
<code className="block break-all rounded bg-black/50 px-2 py-1.5 text-[10px] text-gray-400">
{inviteUrl}
</code>
<div className="flex flex-wrap gap-2">
<Button size="sm" onClick={() => void shareInvite()} className="flex-1 gap-1.5">
<Share2 className="h-3.5 w-3.5" /> {t('cordnUi.shareInvite')}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => void copyInvite()}
className="flex-1 gap-1.5"
>
{copiedLink ? (
<Check className="h-3.5 w-3.5 text-teal-400" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
{copiedLink ? t('cordnUi.copied') : t('cordnUi.copyRef')}
</Button>
</div>
</DialogContent>
</Dialog>
</Card>
);
}
function AdvancedPanel({
groupRef,
coordinatorPubkey,
keyPackageReady,
copied,
onCopyRef,
}: {
groupRef: string | null;
coordinatorPubkey: string;
keyPackageReady: boolean;
copied: boolean;
onCopyRef: () => void;
}) {
const { t } = useTranslation();
return (
<details className="group rounded-md border border-white/10 bg-black/20 px-2.5 py-2 text-left">
<summary className="flex cursor-pointer list-none items-center gap-1.5 text-[11px] text-gray-500">
<ChevronDown className="h-3 w-3 transition-transform group-open:rotate-180" />
{t('cordnUi.advanced')}
</summary>
<div className="space-y-2 pt-2">
<div className="flex items-center justify-between gap-2 text-[11px] text-gray-500">
<span>{t('cordnUi.coordinator')}</span>
<code className="truncate rounded bg-black/40 px-1.5 py-0.5 text-gray-400">
{shortNpub(coordinatorPubkey)}
</code>
</div>
<div className="flex items-center justify-between gap-2 text-[11px] text-gray-500">
<span>{t('cordnUi.keyPackage')}</span>
<span className={keyPackageReady ? 'text-teal-300' : 'text-amber-300'}>
{keyPackageReady ? t('cordnUi.keyPackageReady') : t('cordnUi.keyPackageMissing')}
</span>
</div>
{groupRef && (
<div className="flex items-center gap-2 text-[11px] text-gray-500">
<span className="shrink-0">{t('cordnUi.groupRef')}</span>
<code className="min-w-0 flex-1 truncate rounded bg-black/40 px-1.5 py-0.5 text-gray-400">
{groupRef}
</code>
<Button
size="sm"
variant="ghost"
className="h-7 gap-1 px-2 text-xs"
onClick={onCopyRef}
>
{copied ? (
<Check className="h-3 w-3 text-teal-400" />
) : (
<Copy className="h-3 w-3" />
)}
{copied ? t('cordnUi.copied') : t('cordnUi.copyRef')}
</Button>
</div>
)}
</div>
</details>
);
}
function PersonLabel({ pubkey, isSelf }: { pubkey: string; isSelf?: boolean }) {
const { t } = useTranslation();
const { data } = useAuthor(pubkey);
const name = data?.metadata?.display_name || data?.metadata?.name || shortNpub(pubkey);
const picture = data?.metadata?.picture;
const initials = name.replace(/^@/, '').slice(0, 2).toUpperCase();
return (
<div className="flex min-w-0 items-center gap-2">
<Avatar className="h-6 w-6">
<AvatarImage src={picture} alt={name} />
<AvatarFallback className="bg-teal-900/40 text-[9px] font-bold text-teal-300">
{initials}
</AvatarFallback>
</Avatar>
<span className="truncate text-xs text-gray-300">
{name}
{isSelf && <span className="text-gray-500"> ({t('cordnUi.you')})</span>}
</span>
</div>
);
}
function MemberRow({
pubkey,
role,
isSelf,
}: {
pubkey: string;
role: MemberRole;
isSelf: boolean;
}) {
const { t } = useTranslation();
const label =
role === 'founder'
? t('cordnUi.roleFounder')
: role === 'admin'
? t('cordnUi.roleAdmin')
: t('cordnUi.roleMember');
return (
<div className="flex items-center justify-between gap-2">
<PersonLabel pubkey={pubkey} isSelf={isSelf} />
<Badge
variant="outline"
className={cn(
'shrink-0 text-[9px]',
role === 'founder' && 'border-teal-500/40 text-teal-300',
role === 'admin' && 'border-amber-500/40 text-amber-300',
role === 'member' && 'text-gray-400',
)}
>
{label}
</Badge>
</div>
);
}
function RequestRow({
pubkey,
disabled,
onApprove,
}: {
pubkey: string;
disabled: boolean;
onApprove: () => void;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center justify-between gap-2">
<PersonLabel pubkey={pubkey} />
<Button
size="sm"
disabled={disabled}
onClick={onApprove}
className="h-7 shrink-0 gap-1 text-xs"
>
<UserPlus className="h-3 w-3" /> {t('cordnUi.approve')}
</Button>
</div>
);
}
function Notice({ icon, text }: { icon: ReactNode; text: string }) {
return (
<Card className="border-dashed border-white/10 bg-black/40">

View file

@ -54,25 +54,32 @@ export interface UseCordnNativeResult {
messages: CordnChatMessage[];
busy: boolean;
error: string | null;
/** Send a group message (optionally an image URL, encrypted in the payload). */
send: (text: string, imageUrl?: string) => Promise<void>;
/**
* Send a group message (optionally an image URL, encrypted in the payload).
* Returns the stored message on success, or `null` when the send failed.
*/
send: (text: string, imageUrl?: string) => Promise<CordnChatMessage | null>;
/** Re-fetch messages (and adopt any new commits). */
refresh: () => Promise<void>;
/** Founder/admin: create the crew's MLS group. */
createChat: () => Promise<void>;
/** Non-admin: ask the coordinator to be added. */
requestJoin: () => Promise<void>;
createChat: () => Promise<boolean>;
/** Non-admin: ask the coordinator to be added (publishes a key package). */
requestJoin: () => Promise<boolean>;
/** Non-admin: collect a waiting welcome and join. */
checkInvite: () => Promise<boolean>;
/** Founder/admin: pending join requests for this group. */
requests: JoinRequest[];
loadRequests: () => Promise<void>;
/** Founder/admin: add a requester (by pubkey) and deliver a welcome. */
approve: (identifier: string) => Promise<void>;
approve: (identifier: string) => Promise<boolean>;
/** True for interactive signers (Amber) — manual refresh/send, no polling. */
interactiveHintVisible: boolean;
/** Shareable `cordn1…` reference for the crew's group (once known). */
groupRef: string | null;
/** True when this device is set up: it holds the group or a published key package. */
keyPackageReady: boolean;
/** Re-open the session after a connection error (the Retry button). */
retry: () => void;
}
const POLL_MS = 6000;
@ -115,9 +122,17 @@ export function useCordnNative({
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [groupRef, setGroupRef] = useState<string | null>(null);
const [keyPackageReady, setKeyPackageReady] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
const interactive = isInteractiveSigner(user, logins);
const retry = useCallback(() => {
setError(null);
setReady(false);
setReloadKey((n) => n + 1);
}, []);
// Stable primitives for the session effect — depending on the `user` object
// itself would re-open the session on every render if its identity churns.
const pubkey = user?.pubkey ?? null;
@ -127,6 +142,7 @@ export function useCordnNative({
useEffect(() => {
if (!enabled || !pubkey || !signer || !coordinator || !groupId) return;
let cancelled = false;
setError(null);
const session = import('@/lib/cordn/native').then((mod) => {
if (!cancelled) {
try {
@ -158,6 +174,10 @@ export function useCordnNative({
const found = opened.listGroups().find((g) => g.gid === groupId) ?? null;
setGroup(found);
setMessages(found ? opened.messages(found.gid) : []);
setKeyPackageReady(
!!found ||
opened.listKeyPackages().some((kp) => kp.publishedAt !== undefined && !kp.consumed),
);
setReady(true);
})
.catch((err) => {
@ -169,9 +189,10 @@ export function useCordnNative({
sessionRef.current = null;
setReady(false);
setGroupRef(null);
setKeyPackageReady(false);
if (s) void s.disconnect();
};
}, [enabled, pubkey, signer, coordinator, groupId]);
}, [enabled, pubkey, signer, coordinator, groupId, reloadKey]);
const refresh = useCallback(async () => {
const session = sessionRef.current;
@ -213,31 +234,40 @@ export function useCordnNative({
async (text: string, imageUrl?: string) => {
const body = text.trim();
const session = sessionRef.current;
if ((!body && !imageUrl) || !session || !group) return;
await run(async () => {
await session.send(group.gid, body, imageUrl);
if ((!body && !imageUrl) || !session || !group) return null;
const stored = await run(async () => {
const message = await session.send(group.gid, body, imageUrl);
setMessages(session.messages(group.gid));
return message;
});
return stored ?? null;
},
[group, run],
);
const createChat = useCallback(async () => {
const session = sessionRef.current;
if (!session || !crew || !groupId) return;
await run(async () => {
const created = await session.createGroup({ groupId, name: crew.name });
setGroup(created);
setMessages(session.messages(created.gid));
if (!session || !crew || !groupId) return false;
const created = await run(async () => {
const groupInfo = await session.createGroup({ groupId, name: crew.name });
setGroup(groupInfo);
setMessages(session.messages(groupInfo.gid));
return true;
});
return created === true;
}, [crew, groupId, run]);
const requestJoin = useCallback(async () => {
const session = sessionRef.current;
if (!session || !groupId) return;
await run(async () => {
if (!session || !groupId) return false;
const requested = await run(async () => {
await session.requestJoin(groupId);
setKeyPackageReady(
session.listKeyPackages().some((kp) => kp.publishedAt !== undefined && !kp.consumed),
);
return true;
});
return requested === true;
}, [groupId, run]);
const checkInvite = useCallback(async () => {
@ -246,10 +276,23 @@ export function useCordnNative({
const joined = await run(async () => {
const welcomes = await session.fetchWelcomes();
if (welcomes.length === 0) return false;
const joinedGroup = await session.acceptWelcome(welcomes[0], crew.name);
setGroup(joinedGroup);
setMessages(session.messages(joinedGroup.gid));
return true;
// A signed-in account can have welcomes for several crews. Accept each
// one (so it isn't processed again) but only adopt the group that belongs
// to this crew — otherwise opening crew A could show crew B's chat.
for (const welcome of welcomes) {
try {
const joinedGroup = await session.acceptWelcome(welcome, crew.name);
if (joinedGroup.gid === groupId) {
setGroup(joinedGroup);
setMessages(session.messages(joinedGroup.gid));
setKeyPackageReady(true);
return true;
}
} catch {
// Welcome for a key package this device no longer has — skip it.
}
}
return false;
});
return joined ?? false;
}, [crew, groupId, run]);
@ -266,12 +309,14 @@ export function useCordnNative({
const approve = useCallback(
async (identifier: string) => {
const session = sessionRef.current;
if (!session || !group) return;
await run(async () => {
if (!session || !group) return false;
const approved = await run(async () => {
await session.addMemberByIdentifier(group.gid, identifier);
const pending = await session.fetchJoinRequests(group.gid);
setRequests(pending);
return true;
});
return approved === true;
},
[group, run],
);
@ -294,6 +339,8 @@ export function useCordnNative({
approve,
interactiveHintVisible: interactive,
groupRef,
keyPackageReady,
retry,
};
}

View file

@ -944,7 +944,37 @@ const batch1 = {
"bannerCta": "Configura-ho",
"ready": "Connectat · MLS",
"errorPrefix": "No s'ha pogut connectar amb el coordinador",
"interactiveHint": "El teu signador demana aprovació a cada acció: fes servir Actualitza i Envia quan calgui."
"interactiveHint": "El teu signador demana aprovació a cada acció: fes servir Actualitza i Envia quan calgui.",
setupDevice: "Configura aquest dispositiu",
setupDeviceBody: "Això dóna a aquest dispositiu una clau pròpia per al xat privat. Un fundador l’aprova una vegada.",
waitingApproval: "S’està esperant que el fundador ho aprovi…",
waitingApprovalManual: "S’està esperant el fundador. Toca “Comprova si hi ha invitació” quan ho aprovi.",
onboardingFounder1: "Inicia el xat privat.",
onboardingFounder2: "Aprova els membres que demanin d’unir-s’hi.",
onboardingFounder3: "Xateja en privat amb la teva colla.",
onboardingMember1: "Demana d'unir-t'hi",
onboardingMember2: "El fundador aprova el teu dispositiu.",
onboardingMember3: "Ja hi ets: els missatges van xifrats.",
encryptedNote: "Només ho poden llegir els membres de la colla; el coordinador només retransmet missatges xifrats.",
advanced: "Avançat",
coordinator: "Coordinador",
keyPackage: "Paquet de claus",
keyPackageReady: "A punt en aquest dispositiu",
keyPackageMissing: "No configurat en aquest dispositiu",
members: "Membres",
roleFounder: "Fundador",
roleAdmin: "Administrador",
roleMember: "Membre",
qrTitle: "Escaneja per unir-te al xat privat",
qrHint: "Els membres de la colla ho escanegen amb la càmera del mòbil. L’enllaç obre la pestanya Xat privat.",
sendFailed: "No s’ha pogut enviar: toca Reintenta.",
sending: "S’està enviant…",
sent: "Enviat",
retry: "Reintenta",
errorUnreachable: "No s’ha pogut connectar amb el xat privat. Comprova la connexió.",
errorDetails: "Detalls",
unreachable: "Inabastable",
dismiss: "Tanca",
},
"trustUi": {
"alertsTitle": "Alertes de la xarxa",

View file

@ -322,7 +322,37 @@ const drift = {
"bannerCta": "Nastavit",
"ready": "Připojeno · MLS",
"errorPrefix": "Koordinátora se nepodařilo kontaktovat",
"interactiveHint": "Tvůj signer žádá o schválení u každé akce — podle potřeby použij Obnovit a Poslat."
"interactiveHint": "Tvůj signer žádá o schválení u každé akce — podle potřeby použij Obnovit a Poslat.",
setupDevice: "Nastavit toto zařízení",
setupDeviceBody: "Toto zařízení dostane vlastní klíč pro soukromý chat. Zakladatel ho jednou schválí.",
waitingApproval: "Čeká se na schválení zakladatelem…",
waitingApprovalManual: "Čeká se na zakladatele. Až schválí, klepni na „Zkontrolovat pozvánku“.",
onboardingFounder1: "Zahaj soukromý chat.",
onboardingFounder2: "Schvaluj členy, kteří požádají o připojení.",
onboardingFounder3: "Chatuj soukromě se svou crew.",
onboardingMember1: "Požádat o připojení",
onboardingMember2: "Zakladatel schválí tvé zařízení.",
onboardingMember3: "Jsi uvnitř — zprávy jsou šifrované.",
encryptedNote: "Číst to mohou jen členové crew; koordinátor pouze přenáší šifrované zprávy.",
advanced: "Pokročilé",
coordinator: "Koordinátor",
keyPackage: "Balíček klíčů",
keyPackageReady: "Připraveno na tomto zařízení",
keyPackageMissing: "Není nastaveno na tomto zařízení",
members: "Členové",
roleFounder: "Zakladatel",
roleAdmin: "Administrátor",
roleMember: "Člen",
qrTitle: "Naskenuj a připoj se k soukromému chatu",
qrHint: "Členové crew to naskenují kamerou telefonu. Odkaz otevře kartu Soukromý chat.",
sendFailed: "Nepodařilo se odeslat — klepni na Zkusit znovu.",
sending: "Odesílání…",
sent: "Odesláno",
retry: "Zkusit znovu",
errorUnreachable: "Soukromý chat se nepodařilo kontaktovat. Zkontroluj připojení.",
errorDetails: "Podrobnosti",
unreachable: "Nedostupné",
dismiss: "Zavřít",
},
translateUi: {
title: "Překlad zpráv",

View file

@ -325,6 +325,36 @@ const drift = {
ready: "Verbunden · MLS",
errorPrefix: "Koordinator konnte nicht erreicht werden",
interactiveHint: "Dein Signer fragt bei jeder Aktion nach Bestätigung – nutze Aktualisieren und Senden nach Bedarf.",
setupDevice: "Dieses Gerät einrichten",
setupDeviceBody: "Dieses Gerät erhält einen eigenen Schlüssel für den privaten Chat. Ein Gründer genehmigt ihn einmal.",
waitingApproval: "Warte auf die Genehmigung durch den Gründer…",
waitingApprovalManual: "Warte auf den Gründer. Tippe nach der Genehmigung auf „Nach Einladung suchen“.",
onboardingFounder1: "Starte den privaten Chat.",
onboardingFounder2: "Genehmige Mitglieder, die um Beitritt bitten.",
onboardingFounder3: "Chatte privat mit deiner Crew.",
onboardingMember1: "Um Beitritt bitten",
onboardingMember2: "Der Gründer genehmigt dein Gerät.",
onboardingMember3: "Du bist dabei – Nachrichten sind verschlüsselt.",
encryptedNote: "Nur Crew-Mitglieder können das lesen; der Koordinator leitet nur verschlüsselte Nachrichten weiter.",
advanced: "Erweitert",
coordinator: "Koordinator",
keyPackage: "Schlüsselpaket",
keyPackageReady: "Auf diesem Gerät bereit",
keyPackageMissing: "Auf diesem Gerät nicht eingerichtet",
members: "Mitglieder",
roleFounder: "Gründer",
roleAdmin: "Admin",
roleMember: "Mitglied",
qrTitle: "Scannen und dem privaten Chat beitreten",
qrHint: "Crew-Mitglieder scannen das mit der Handykamera. Der Link öffnet den Tab „Privater Chat“.",
sendFailed: "Senden fehlgeschlagen – tippe auf Wiederholen.",
sending: "Senden…",
sent: "Gesendet",
retry: "Wiederholen",
errorUnreachable: "Der private Chat ist nicht erreichbar. Prüfe deine Verbindung.",
errorDetails: "Details",
unreachable: "Nicht erreichbar",
dismiss: "Schließen",
},
translateUi: {
title: "Nachrichtenübersetzung",

View file

@ -327,6 +327,36 @@ const drift = {
ready: "Συνδεδεμένο · MLS",
errorPrefix: "Δεν ήταν δυνατή η πρόσβαση στον συντονιστή",
interactiveHint: "Ο signer σου ζητά έγκριση για κάθε ενέργεια — χρησιμοποίησε Ανανέωση και Αποστολή όπως χρειάζεται.",
setupDevice: "Ρύθμιση αυτής της συσκευής",
setupDeviceBody: "Αυτό δίνει στη συσκευή το δικό της κλειδί για την ιδιωτική συνομιλία. Ένας ιδρυτής το εγκρίνει μία φορά.",
waitingApproval: "Αναμονή έγκρισης από τον ιδρυτή…",
waitingApprovalManual: "Αναμονή για τον ιδρυτή. Πατήστε «Έλεγχος πρόσκλησης» αφού εγκρίνει.",
onboardingFounder1: "Ξεκινήστε την ιδιωτική συνομιλία.",
onboardingFounder2: "Εγκρίνετε τα μέλη που ζητούν συμμετοχή.",
onboardingFounder3: "Συνομιλήστε ιδιωτικά με το crew σας.",
onboardingMember1: "Ζήτα συμμετοχή",
onboardingMember2: "Ο ιδρυτής εγκρίνει τη συσκευή σας.",
onboardingMember3: "Είστε μέσα — τα μηνύματα είναι κρυπτογραφημένα.",
encryptedNote: "Μόνο τα μέλη του crew μπορούν να το διαβάσουν· ο συντονιστής απλώς προωθεί κρυπτογραφημένα μηνύματα.",
advanced: "Για προχωρημένους",
coordinator: "Συντονιστής",
keyPackage: "Πακέτο κλειδιών",
keyPackageReady: "Έτοιμο σε αυτή τη συσκευή",
keyPackageMissing: "Δεν έχει ρυθμιστεί σε αυτή τη συσκευή",
members: "Μέλη",
roleFounder: "Ιδρυτής",
roleAdmin: "Διαχειριστής",
roleMember: "Μέλος",
qrTitle: "Σαρώστε για να μπείτε στην ιδιωτική συνομιλία",
qrHint: "Τα μέλη του crew το σαρώνουν με την κάμερα του κινητού. Ο σύνδεσμος ανοίγει την καρτέλα Ιδιωτική συνομιλία.",
sendFailed: "Δεν στάλθηκε — πατήστε Επανάληψη.",
sending: "Αποστολή…",
sent: "Στάλθηκε",
retry: "Επανάληψη",
errorUnreachable: "Δεν ήταν δυνατή η πρόσβαση στην ιδιωτική συνομιλία. Ελέγξτε τη σύνδεσή σας.",
errorDetails: "Λεπτομέρειες",
unreachable: "Μη προσβάσιμο",
dismiss: "Κλείσιμο",
},
translateUi: {
title: "Μετάφραση μηνυμάτων",

View file

@ -944,7 +944,37 @@ const batch1 = {
bannerCta: "Konfiguratu",
ready: "Konektatuta · MLS",
errorPrefix: "Ezin izan da koordinatzailera iritsi",
interactiveHint: "Zure sinatzaileak ekintza bakoitzean onarpena eskatzen du — erabili Freskatu eta Bidali behar duzunean."
interactiveHint: "Zure sinatzaileak ekintza bakoitzean onarpena eskatzen du — erabili Freskatu eta Bidali behar duzunean.",
setupDevice: "Konfiguratu gailu hau",
setupDeviceBody: "Honek gailu honi txat pribaturako bere gakoa ematen dio. Sortzaile batek behin onartzen du.",
waitingApproval: "Sortzailearen onarpenaren zain…",
waitingApprovalManual: "Sortzailearen zain. Onartu ondoren, sakatu “Egiaztatu gonbidapena”.",
onboardingFounder1: "Hasi txat pribatua.",
onboardingFounder2: "Onartu sartzea eskatzen duten kideak.",
onboardingFounder3: "Txateatu pribatuan zure taldearekin.",
onboardingMember1: "Eskatu sartzea",
onboardingMember2: "Sortzaileak zure gailua onartzen du.",
onboardingMember3: "Barruan zaude — mezuak zifratuta daude.",
encryptedNote: "Taldeko kideek soilik irakur dezakete hau; koordinatzaileak mezu zifratuak soilik birbidaltzen ditu.",
advanced: "Aurreratua",
coordinator: "Koordinatzailea",
keyPackage: "Gako-paketea",
keyPackageReady: "Prest gailu honetan",
keyPackageMissing: "Ez dago konfiguratuta gailu honetan",
members: "Kideak",
roleFounder: "Sortzailea",
roleAdmin: "Administratzailea",
roleMember: "Kidea",
qrTitle: "Eskaneatu txat pribatura sartzeko",
qrHint: "Taldeko kideek telefonoaren kamerarekin eskaneatzen dute. Estekak Txat pribatua fitxa irekitzen du.",
sendFailed: "Ezin izan da bidali — sakatu Berriro saiatu.",
sending: "Bidaltzen…",
sent: "Bidalita",
retry: "Berriro saiatu",
errorUnreachable: "Ezin izan da txat pribatura iritsi. Egiaztatu zure konexioa.",
errorDetails: "Xehetasunak",
unreachable: "Ezin iritsi",
dismiss: "Itxi",
},
trustUi: {
alertsTitle: "Sareko alertak",

View file

@ -325,6 +325,36 @@ const drift = {
ready: "Connecté · MLS",
errorPrefix: "Impossible de joindre le coordinateur",
interactiveHint: "Votre signataire demande une approbation à chaque action — utilisez Actualiser et Envoyer au besoin.",
setupDevice: "Configurer cet appareil",
setupDeviceBody: "Cet appareil obtient sa propre clé pour le chat privé. Un fondateur l’approuve une fois.",
waitingApproval: "En attente de l’approbation du fondateur…",
waitingApprovalManual: "En attente du fondateur. Touchez « Vérifier l’invitation » après son approbation.",
onboardingFounder1: "Lancez le chat privé.",
onboardingFounder2: "Approuvez les membres qui demandent à rejoindre.",
onboardingFounder3: "Discutez en privé avec votre Crew.",
onboardingMember1: "Demander à rejoindre",
onboardingMember2: "Le fondateur approuve votre appareil.",
onboardingMember3: "Vous y êtes — les messages sont chiffrés.",
encryptedNote: "Seuls les membres de la Crew peuvent le lire ; le coordinateur ne relaie que des messages chiffrés.",
advanced: "Avancé",
coordinator: "Coordinateur",
keyPackage: "Paquet de clés",
keyPackageReady: "Prêt sur cet appareil",
keyPackageMissing: "Non configuré sur cet appareil",
members: "Membres",
roleFounder: "Fondateur",
roleAdmin: "Admin",
roleMember: "Membre",
qrTitle: "Scannez pour rejoindre le chat privé",
qrHint: "Les membres de la Crew le scannent avec l’appareil photo de leur téléphone. Le lien ouvre l’onglet Chat privé.",
sendFailed: "Échec de l’envoi — touchez Réessayer.",
sending: "Envoi…",
sent: "Envoyé",
retry: "Réessayer",
errorUnreachable: "Impossible de joindre le chat privé. Vérifiez votre connexion.",
errorDetails: "Détails",
unreachable: "Injoignable",
dismiss: "Fermer",
},
translateUi: {
title: "Traduction des messages",

View file

@ -944,7 +944,37 @@ const batch1 = {
bannerCta: "Configurar",
ready: "Conectado · MLS",
errorPrefix: "Non se puido conectar co coordinador",
interactiveHint: "O teu firmante pide aprobación en cada acción: usa Actualizar e Enviar cando faga falta."
interactiveHint: "O teu firmante pide aprobación en cada acción: usa Actualizar e Enviar cando faga falta.",
setupDevice: "Configurar este dispositivo",
setupDeviceBody: "Isto dálle a este dispositivo unha chave propia para o chat privado. Un fundador aprobaa unha vez.",
waitingApproval: "Agardando a aprobación do fundador…",
waitingApprovalManual: "Agardando polo fundador. Toca «Comprobar se hai invitación» cando aprobe.",
onboardingFounder1: "Inicia o chat privado.",
onboardingFounder2: "Aproba os membros que pidan acceder.",
onboardingFounder3: "Chatea en privado coa túa crew.",
onboardingMember1: "Pedir acceso",
onboardingMember2: "O fundador aproba o teu dispositivo.",
onboardingMember3: "Xa estás dentro: as mensaxes van cifradas.",
encryptedNote: "Só os membros da crew poden ler isto; o coordinador só retransmite mensaxes cifradas.",
advanced: "Avanzado",
coordinator: "Coordinador",
keyPackage: "Paquete de chaves",
keyPackageReady: "Listo neste dispositivo",
keyPackageMissing: "Sen configurar neste dispositivo",
members: "Membros",
roleFounder: "Fundador",
roleAdmin: "Administrador",
roleMember: "Membro",
qrTitle: "Escanea para entrar no chat privado",
qrHint: "Os membros da crew escanéano coa cámara do móbil. A ligazón abre a pestana Chat privado.",
sendFailed: "Non se puido enviar: toca Reintentar.",
sending: "Enviando…",
sent: "Enviado",
retry: "Reintentar",
errorUnreachable: "Non se puido conectar co chat privado. Comproba a túa conexión.",
errorDetails: "Detalles",
unreachable: "Inaccesible",
dismiss: "Pechar",
},
trustUi: {
alertsTitle: "Avisos da rede",

View file

@ -944,7 +944,37 @@ const batch1 = {
"bannerCta": "सेट करें",
"ready": "कनेक्टेड · MLS",
"errorPrefix": "कोऑर्डिनेटर तक नहीं पहुँच सके",
"interactiveHint": "आपका साइनर हर क्रिया पर स्वीकृति माँगता है — ज़रूरत के अनुसार रीफ़्रेश और भेजें का उपयोग करें।"
"interactiveHint": "आपका साइनर हर क्रिया पर स्वीकृति माँगता है — ज़रूरत के अनुसार रीफ़्रेश और भेजें का उपयोग करें।",
setupDevice: "इस डिवाइस को सेट करें",
setupDeviceBody: "यह इस डिवाइस को निजी चैट के लिए अपनी कुंजी देता है। एक संस्थापक इसे एक बार स्वीकृत करता है।",
waitingApproval: "संस्थापक की स्वीकृति की प्रतीक्षा हो रही है…",
waitingApprovalManual: "संस्थापक की प्रतीक्षा है। स्वीकृति के बाद “निमंत्रण जाँचें” टैप करें।",
onboardingFounder1: "निजी चैट शुरू करें।",
onboardingFounder2: "शामिल होने का अनुरोध करने वाले सदस्यों को स्वीकृत करें।",
onboardingFounder3: "अपने क्रू के साथ निजी में चैट करें।",
onboardingMember1: "शामिल होने का अनुरोध करें",
onboardingMember2: "संस्थापक आपका डिवाइस स्वीकृत करता है।",
onboardingMember3: "आप अंदर हैं — संदेश एन्क्रिप्टेड हैं।",
encryptedNote: "इसे केवल क्रू सदस्य ही पढ़ सकते हैं; कोऑर्डिनेटर केवल एन्क्रिप्टेड संदेश आगे भेजता है।",
advanced: "उन्नत",
coordinator: "कोऑर्डिनेटर",
keyPackage: "कुंजी पैकेज",
keyPackageReady: "इस डिवाइस पर तैयार",
keyPackageMissing: "इस डिवाइस पर सेट नहीं है",
members: "सदस्य",
roleFounder: "संस्थापक",
roleAdmin: "एडमिन",
roleMember: "सदस्य",
qrTitle: "निजी चैट में शामिल होने के लिए स्कैन करें",
qrHint: "क्रू सदस्य इसे फ़ोन कैमरे से स्कैन करते हैं। लिंक निजी चैट टैब खोलता है।",
sendFailed: "भेजा नहीं जा सका — रीट्राई टैप करें।",
sending: "भेजा जा रहा है…",
sent: "भेज दिया",
retry: "रीट्राई",
errorUnreachable: "निजी चैट तक नहीं पहुँच सके। अपना कनेक्शन जाँचें।",
errorDetails: "विवरण",
unreachable: "पहुँच से बाहर",
dismiss: "बंद करें",
},
"trustUi": {
"alertsTitle": "नेटवर्क अलर्ट",

View file

@ -319,6 +319,36 @@ const driftStrings = {
ready: 'Terhubung · MLS',
errorPrefix: 'Tidak dapat menjangkau koordinator',
interactiveHint: 'Penandatanganmu meminta persetujuan untuk setiap tindakan — gunakan Muat ulang dan Kirim sesuai kebutuhan.',
setupDevice: "Siapkan perangkat ini",
setupDeviceBody: "Ini memberi perangkat ini kunci sendiri untuk chat privat. Seorang pendiri menyetujuinya sekali.",
waitingApproval: "Menunggu persetujuan pendiri…",
waitingApprovalManual: "Menunggu pendiri. Ketuk “Periksa undangan” setelah mereka menyetujui.",
onboardingFounder1: "Mulai chat privat.",
onboardingFounder2: "Setujui anggota yang minta gabung.",
onboardingFounder3: "Ngobrol secara privat dengan crew-mu.",
onboardingMember1: 'Minta gabung',
onboardingMember2: "Pendiri menyetujui perangkatmu.",
onboardingMember3: "Kamu sudah masuk — pesan terenkripsi.",
encryptedNote: "Hanya anggota crew yang bisa membacanya; koordinator hanya meneruskan pesan terenkripsi.",
advanced: "Lanjutan",
coordinator: "Koordinator",
keyPackage: "Paket kunci",
keyPackageReady: "Siap di perangkat ini",
keyPackageMissing: "Belum disiapkan di perangkat ini",
members: "Anggota",
roleFounder: "Pendiri",
roleAdmin: "Admin",
roleMember: "Anggota",
qrTitle: "Pindai untuk bergabung ke chat privat",
qrHint: "Anggota crew memindai ini dengan kamera ponsel. Tautan membuka tab Chat privat.",
sendFailed: "Gagal mengirim — ketuk Coba lagi.",
sending: "Mengirim…",
sent: "Terkirim",
retry: "Coba lagi",
errorUnreachable: "Tidak dapat menjangkau chat privat. Periksa koneksimu.",
errorDetails: "Detail",
unreachable: "Tidak terjangkau",
dismiss: "Tutup",
},
translateUi: {
title: 'Terjemahan pesan',

View file

@ -325,6 +325,36 @@ const drift = {
ready: "Connesso · MLS",
errorPrefix: "Impossibile raggiungere il coordinatore",
interactiveHint: "Il tuo signer chiede l'approvazione a ogni azione — usa Aggiorna e Invia secondo necessità.",
setupDevice: "Configura questo dispositivo",
setupDeviceBody: "Questo assegna al dispositivo una chiave propria per la chat privata. Un fondatore la approva una volta.",
waitingApproval: "In attesa dell’approvazione del fondatore…",
waitingApprovalManual: "In attesa del fondatore. Tocca «Controlla l’invito» dopo l’approvazione.",
onboardingFounder1: "Avvia la chat privata.",
onboardingFounder2: "Approva i membri che chiedono di unirsi.",
onboardingFounder3: "Chatta in privato con la tua crew.",
onboardingMember1: "Chiedi di unirti",
onboardingMember2: "Il fondatore approva il tuo dispositivo.",
onboardingMember3: "Sei dentro — i messaggi sono cifrati.",
encryptedNote: "Solo i membri della crew possono leggerlo; il coordinatore inoltra solo messaggi cifrati.",
advanced: "Avanzate",
coordinator: "Coordinatore",
keyPackage: "Pacchetto di chiavi",
keyPackageReady: "Pronto su questo dispositivo",
keyPackageMissing: "Non configurato su questo dispositivo",
members: "Membri",
roleFounder: "Fondatore",
roleAdmin: "Admin",
roleMember: "Membro",
qrTitle: "Scansiona per unirti alla chat privata",
qrHint: "I membri della crew lo scansionano con la fotocamera del telefono. Il link apre la scheda Chat privata.",
sendFailed: "Invio non riuscito — tocca Riprova.",
sending: "Invio…",
sent: "Inviato",
retry: "Riprova",
errorUnreachable: "Impossibile raggiungere la chat privata. Controlla la connessione.",
errorDetails: "Dettagli",
unreachable: "Non raggiungibile",
dismiss: "Chiudi",
},
translateUi: {
title: "Traduzione dei messaggi",

View file

@ -335,6 +335,36 @@ const drift: Dict = {
ready: '接続済み · MLS',
errorPrefix: 'コーディネーターに接続できませんでした',
interactiveHint: '署名者は各操作で承認を求めます — 必要に応じて更新と送信を使ってください。',
setupDevice: "このデバイスを設定",
setupDeviceBody: "このデバイス専用のキーをプライベートチャットに付与します。創設者が一度承認します。",
waitingApproval: "創設者の承認を待っています…",
waitingApprovalManual: "創設者を待っています。承認されたら「招待を確認」をタップしてください。",
onboardingFounder1: "プライベートチャットを開始しましょう。",
onboardingFounder2: "参加を希望するメンバーを承認しましょう。",
onboardingFounder3: "クルーとプライベートにチャットしましょう。",
onboardingMember1: '参加をリクエスト',
onboardingMember2: "創設者があなたのデバイスを承認します。",
onboardingMember3: "参加しました — メッセージは暗号化されています。",
encryptedNote: "読めるのはクルーメンバーだけです。コーディネーターは暗号化されたメッセージを中継するだけです。",
advanced: "詳細設定",
coordinator: "コーディネーター",
keyPackage: "キーパッケージ",
keyPackageReady: "このデバイスで準備完了",
keyPackageMissing: "このデバイスでは未設定",
members: "メンバー",
roleFounder: "創設者",
roleAdmin: "管理者",
roleMember: "メンバー",
qrTitle: "スキャンしてプライベートチャットに参加",
qrHint: "クルーメンバーがスマートフォンのカメラでスキャンします。リンクからプライベートチャットタブが開きます。",
sendFailed: "送信できませんでした — 「再試行」をタップしてください。",
sending: "送信中…",
sent: "送信済み",
retry: "再試行",
errorUnreachable: "プライベートチャットに接続できませんでした。接続を確認してください。",
errorDetails: "詳細",
unreachable: "接続不可",
dismiss: "閉じる",
},
translateUi: {
title: 'メッセージの翻訳',

View file

@ -335,6 +335,36 @@ const drift: Dict = {
ready: '연결됨 · MLS',
errorPrefix: '코디네이터에 연결할 수 없습니다',
interactiveHint: '서명자가 각 작업마다 승인을 요청합니다 — 필요에 따라 새로고침과 보내기를 사용하세요.',
setupDevice: "이 기기 설정",
setupDeviceBody: "이 기기에 비공개 채팅용 키를 부여합니다. 창립자가 한 번 승인합니다.",
waitingApproval: "창립자의 승인을 기다리는 중…",
waitingApprovalManual: "창립자를 기다리는 중입니다. 승인되면 “초대 확인”을 누르세요.",
onboardingFounder1: "비공개 채팅을 시작하세요.",
onboardingFounder2: "참여를 요청한 멤버를 승인하세요.",
onboardingFounder3: "크루와 비공개로 대화하세요.",
onboardingMember1: '참여 요청',
onboardingMember2: "창립자가 기기를 승인합니다.",
onboardingMember3: "참여했습니다 — 메시지는 암호화됩니다.",
encryptedNote: "크루 멤버만 읽을 수 있으며, 코디네이터는 암호화된 메시지만 중계합니다.",
advanced: "고급",
coordinator: "코디네이터",
keyPackage: "키 패키지",
keyPackageReady: "이 기기에서 준비됨",
keyPackageMissing: "이 기기에서 설정되지 않음",
members: "멤버",
roleFounder: "창립자",
roleAdmin: "관리자",
roleMember: "멤버",
qrTitle: "스캔하여 비공개 채팅 참여",
qrHint: "크루 멤버가 휴대폰 카메라로 스캔합니다. 링크를 열면 비공개 채팅 탭으로 이동합니다.",
sendFailed: "보내지 못했습니다 — 다시 시도를 누르세요.",
sending: "보내는 중…",
sent: "보냄",
retry: "다시 시도",
errorUnreachable: "비공개 채팅에 연결할 수 없습니다. 연결을 확인하세요.",
errorDetails: "세부정보",
unreachable: "연결 불가",
dismiss: "닫기",
},
translateUi: {
title: '메시지 번역',

View file

@ -45,6 +45,40 @@ const cordnUi = {
errorPrefix: 'Couldn’t reach the coordinator',
interactiveHint:
'Your signer asks for approval on each action — use Refresh and Send as needed.',
setupDevice: 'Set up this device',
setupDeviceBody:
'This gives this device its own key for the private chat. A founder approves it once.',
waitingApproval: 'Waiting for the founder to approve…',
waitingApprovalManual:
'Waiting for the founder. Tap “Check for invite” after they approve.',
onboardingFounder1: 'Start the private chat.',
onboardingFounder2: 'Approve members who ask to join.',
onboardingFounder3: 'Chat privately with your crew.',
onboardingMember1: 'Ask to join.',
onboardingMember2: 'The founder approves your device.',
onboardingMember3: 'You’re in — messages are encrypted.',
encryptedNote:
'Only crew members can read this; the coordinator only relays encrypted messages.',
advanced: 'Advanced',
coordinator: 'Coordinator',
keyPackage: 'Key package',
keyPackageReady: 'Ready on this device',
keyPackageMissing: 'Not set up on this device',
members: 'Members',
roleFounder: 'Founder',
roleAdmin: 'Admin',
roleMember: 'Member',
qrTitle: 'Scan to join the private chat',
qrHint:
'Crew members scan this with their phone camera. The link opens the Private chat tab.',
sendFailed: 'Couldn’t send — tap Retry.',
sending: 'Sending…',
sent: 'Sent',
retry: 'Retry',
errorUnreachable: 'Couldn’t reach the private chat. Check your connection.',
errorDetails: 'Details',
unreachable: 'Unreachable',
dismiss: 'Dismiss',
},
es: {
title: 'Chat privado',
@ -87,6 +121,40 @@ const cordnUi = {
errorPrefix: 'No se pudo contactar con el coordinador',
interactiveHint:
'Tu firmante pide aprobación en cada acción: usa Actualizar y Enviar cuando haga falta.',
setupDevice: 'Configurar este dispositivo',
setupDeviceBody:
'Esto da a este dispositivo su propia clave para el chat privado. Un fundador lo aprueba una vez.',
waitingApproval: 'Esperando a que el fundador lo apruebe…',
waitingApprovalManual:
'Esperando al fundador. Pulsa «Buscar invitación» cuando lo apruebe.',
onboardingFounder1: 'Crea el chat privado.',
onboardingFounder2: 'Aprueba a los miembros que pidan entrar.',
onboardingFounder3: 'Habla en privado con tu peña.',
onboardingMember1: 'Pide entrar.',
onboardingMember2: 'El fundador aprueba tu dispositivo.',
onboardingMember3: 'Ya estás dentro: los mensajes van cifrados.',
encryptedNote:
'Solo los miembros de la peña pueden leerlo; el coordinador solo retransmite mensajes cifrados.',
advanced: 'Avanzado',
coordinator: 'Coordinador',
keyPackage: 'Paquete de claves',
keyPackageReady: 'Listo en este dispositivo',
keyPackageMissing: 'Sin configurar en este dispositivo',
members: 'Miembros',
roleFounder: 'Fundador',
roleAdmin: 'Admin',
roleMember: 'Miembro',
qrTitle: 'Escanea para entrar al chat privado',
qrHint:
'Los miembros de la peña pueden escanearlo con la cámara del móvil. El enlace abre la pestaña del chat privado.',
sendFailed: 'No se pudo enviar: pulsa Reintentar.',
sending: 'Enviando…',
sent: 'Enviado',
retry: 'Reintentar',
errorUnreachable: 'No se pudo conectar con el chat privado. Comprueba tu conexión.',
errorDetails: 'Detalles',
unreachable: 'Sin conexión',
dismiss: 'Descartar',
},
};

View file

@ -322,7 +322,37 @@ const drift = {
"bannerCta": "Instellen",
"ready": "Verbonden · MLS",
"errorPrefix": "Kon de coördinator niet bereiken",
"interactiveHint": "Je signer vraagt goedkeuring bij elke actie — gebruik Vernieuwen en Verzenden zoals nodig."
"interactiveHint": "Je signer vraagt goedkeuring bij elke actie — gebruik Vernieuwen en Verzenden zoals nodig.",
setupDevice: "Dit apparaat instellen",
setupDeviceBody: "Dit geeft dit apparaat een eigen sleutel voor de privéchat. Een oprichter keurt die één keer goed.",
waitingApproval: "Wachten op goedkeuring door de oprichter…",
waitingApprovalManual: "Wachten op de oprichter. Tik na goedkeuring op “Op uitnodiging controleren”.",
onboardingFounder1: "Start de privéchat.",
onboardingFounder2: "Keur leden goed die willen deelnemen.",
onboardingFounder3: "Chat privé met je crew.",
onboardingMember1: "Vragen om deel te nemen",
onboardingMember2: "De oprichter keurt je apparaat goed.",
onboardingMember3: "Je doet mee — berichten zijn versleuteld.",
encryptedNote: "Alleen crewleden kunnen dit lezen; de coördinator geeft alleen versleutelde berichten door.",
advanced: "Geavanceerd",
coordinator: "Coördinator",
keyPackage: "Sleutelpakket",
keyPackageReady: "Klaar op dit apparaat",
keyPackageMissing: "Niet ingesteld op dit apparaat",
members: "Leden",
roleFounder: "Oprichter",
roleAdmin: "Admin",
roleMember: "Lid",
qrTitle: "Scan om deel te nemen aan de privéchat",
qrHint: "Crewleden scannen dit met de camera van hun telefoon. De link opent het tabblad Privéchat.",
sendFailed: "Verzenden mislukt — tik op Opnieuw.",
sending: "Verzenden…",
sent: "Verzonden",
retry: "Opnieuw",
errorUnreachable: "Kon de privéchat niet bereiken. Controleer je verbinding.",
errorDetails: "Details",
unreachable: "Onbereikbaar",
dismiss: "Sluiten",
},
translateUi: {
title: "Berichtvertaling",

View file

@ -322,7 +322,37 @@ const drift = {
"bannerCta": "Skonfiguruj",
"ready": "Połączono · MLS",
"errorPrefix": "Nie udało się połączyć z koordynatorem",
"interactiveHint": "Twój signer prosi o zatwierdzenie każdej akcji — używaj Odśwież i Wyślij w razie potrzeby."
"interactiveHint": "Twój signer prosi o zatwierdzenie każdej akcji — używaj Odśwież i Wyślij w razie potrzeby.",
setupDevice: "Skonfiguruj to urządzenie",
setupDeviceBody: "To daje temu urządzeniu własny klucz do prywatnego czatu. Założyciel zatwierdza go raz.",
waitingApproval: "Czekanie na zatwierdzenie przez założyciela…",
waitingApprovalManual: "Czekanie na założyciela. Po zatwierdzeniu dotknij „Sprawdź zaproszenie”.",
onboardingFounder1: "Rozpocznij prywatny czat.",
onboardingFounder2: "Zatwierdzaj członków, którzy proszą o dołączenie.",
onboardingFounder3: "Rozmawiaj prywatnie ze swoim crew.",
onboardingMember1: "Poproś o dołączenie",
onboardingMember2: "Założyciel zatwierdza twoje urządzenie.",
onboardingMember3: "Jesteś w środku — wiadomości są szyfrowane.",
encryptedNote: "Mogą to czytać tylko członkowie crew; koordynator przekazuje wyłącznie zaszyfrowane wiadomości.",
advanced: "Zaawansowane",
coordinator: "Koordynator",
keyPackage: "Pakiet kluczy",
keyPackageReady: "Gotowe na tym urządzeniu",
keyPackageMissing: "Nieskonfigurowane na tym urządzeniu",
members: "Członkowie",
roleFounder: "Założyciel",
roleAdmin: "Administrator",
roleMember: "Członek",
qrTitle: "Zeskanuj, aby dołączyć do prywatnego czatu",
qrHint: "Członkowie crew skanują to aparatem telefonu. Link otwiera kartę Prywatny czat.",
sendFailed: "Nie udało się wysłać — dotknij Ponów.",
sending: "Wysyłanie…",
sent: "Wysłano",
retry: "Ponów",
errorUnreachable: "Nie udało się połączyć z prywatnym czatem. Sprawdź połączenie.",
errorDetails: "Szczegóły",
unreachable: "Nieosiągalne",
dismiss: "Zamknij",
},
translateUi: {
title: "Tłumaczenie wiadomości",

View file

@ -325,6 +325,36 @@ const drift = {
ready: "Conectado · MLS",
errorPrefix: "Não foi possível alcançar o coordenador",
interactiveHint: "Seu signer pede aprovação a cada ação — use Atualizar e Enviar conforme necessário.",
setupDevice: "Configurar este dispositivo",
setupDeviceBody: "Isto dá a este dispositivo uma chave própria para o chat privado. Um fundador aprova uma vez.",
waitingApproval: "Aguardando aprovação do fundador…",
waitingApprovalManual: "Aguardando o fundador. Toque em “Verificar convite” depois que ele aprovar.",
onboardingFounder1: "Inicie o chat privado.",
onboardingFounder2: "Aprove os membros que pedirem para entrar.",
onboardingFounder3: "Converse em particular com sua crew.",
onboardingMember1: "Pedir para entrar",
onboardingMember2: "O fundador aprova seu dispositivo.",
onboardingMember3: "Você entrou — as mensagens são criptografadas.",
encryptedNote: "Apenas membros da crew podem ler isto; o coordenador só repassa mensagens criptografadas.",
advanced: "Avançado",
coordinator: "Coordenador",
keyPackage: "Pacote de chaves",
keyPackageReady: "Pronto neste dispositivo",
keyPackageMissing: "Não configurado neste dispositivo",
members: "Membros",
roleFounder: "Fundador",
roleAdmin: "Admin",
roleMember: "Membro",
qrTitle: "Escaneie para entrar no chat privado",
qrHint: "Os membros da crew escaneiam com a câmera do celular. O link abre a aba Chat privado.",
sendFailed: "Não foi possível enviar — toque em Tentar novamente.",
sending: "Enviando…",
sent: "Enviado",
retry: "Tentar novamente",
errorUnreachable: "Não foi possível alcançar o chat privado. Verifique sua conexão.",
errorDetails: "Detalhes",
unreachable: "Inacessível",
dismiss: "Fechar",
},
translateUi: {
title: "Tradução de mensagens",

View file

@ -322,7 +322,37 @@ const drift = {
"bannerCta": "Configurează",
"ready": "Conectat · MLS",
"errorPrefix": "Coordonatorul nu a putut fi contactat",
"interactiveHint": "Signerul tău cere aprobare la fiecare acțiune — folosește Reîmprospătează și Trimite după nevoie."
"interactiveHint": "Signerul tău cere aprobare la fiecare acțiune — folosește Reîmprospătează și Trimite după nevoie.",
setupDevice: "Configurează acest dispozitiv",
setupDeviceBody: "Asta îi dă acestui dispozitiv o cheie proprie pentru chatul privat. Un fondator o aprobă o dată.",
waitingApproval: "Se așteaptă aprobarea fondatorului…",
waitingApprovalManual: "Se așteaptă fondatorul. Apasă „Verifică invitația” după ce aprobă.",
onboardingFounder1: "Începe chatul privat.",
onboardingFounder2: "Aprobă membrii care cer să se alăture.",
onboardingFounder3: "Discută în privat cu crew-ul tău.",
onboardingMember1: "Cere să te alături",
onboardingMember2: "Fondatorul îți aprobă dispozitivul.",
onboardingMember3: "Ai intrat — mesajele sunt criptate.",
encryptedNote: "Doar membrii crew-ului pot citi asta; coordonatorul doar retransmite mesaje criptate.",
advanced: "Avansat",
coordinator: "Coordonator",
keyPackage: "Pachet de chei",
keyPackageReady: "Pregătit pe acest dispozitiv",
keyPackageMissing: "Neconfigurat pe acest dispozitiv",
members: "Membri",
roleFounder: "Fondator",
roleAdmin: "Admin",
roleMember: "Membru",
qrTitle: "Scanează pentru a intra în chatul privat",
qrHint: "Membrii crew-ului scanează cu camera telefonului. Linkul deschide fila Chat privat.",
sendFailed: "Trimiterea a eșuat — apasă Reîncearcă.",
sending: "Se trimite…",
sent: "Trimis",
retry: "Reîncearcă",
errorUnreachable: "Chatul privat nu a putut fi contactat. Verifică conexiunea.",
errorDetails: "Detalii",
unreachable: "Inaccesibil",
dismiss: "Închide",
},
translateUi: {
title: "Traducerea mesajelor",

View file

@ -327,6 +327,36 @@ const drift = {
ready: "Подключено · MLS",
errorPrefix: "Не удалось связаться с координатором",
interactiveHint: "Ваш подписант запрашивает подтверждение для каждого действия — используйте «Обновить» и «Отправить» по необходимости.",
setupDevice: "Настроить это устройство",
setupDeviceBody: "Устройство получит собственный ключ для приватного чата. Основатель одобрит его один раз.",
waitingApproval: "Ожидание одобрения основателя…",
waitingApprovalManual: "Ожидание основателя. После одобрения нажмите «Проверить приглашение».",
onboardingFounder1: "Запустите приватный чат.",
onboardingFounder2: "Одобряйте участников, которые просят присоединиться.",
onboardingFounder3: "Общайтесь приватно со своим Crew.",
onboardingMember1: "Попросить присоединиться",
onboardingMember2: "Основатель одобрит ваше устройство.",
onboardingMember3: "Вы внутри — сообщения зашифрованы.",
encryptedNote: "Читать могут только участники Crew; координатор лишь передаёт зашифрованные сообщения.",
advanced: "Дополнительно",
coordinator: "Координатор",
keyPackage: "Пакет ключей",
keyPackageReady: "Готово на этом устройстве",
keyPackageMissing: "Не настроено на этом устройстве",
members: "Участники",
roleFounder: "Основатель",
roleAdmin: "Администратор",
roleMember: "Участник",
qrTitle: "Отсканируйте, чтобы войти в приватный чат",
qrHint: "Участники Crew сканируют код камерой телефона. Ссылка открывает вкладку «Приватный чат».",
sendFailed: "Не удалось отправить — нажмите «Повторить».",
sending: "Отправка…",
sent: "Отправлено",
retry: "Повторить",
errorUnreachable: "Не удалось связаться с приватным чатом. Проверьте подключение.",
errorDetails: "Подробности",
unreachable: "Недоступно",
dismiss: "Закрыть",
},
translateUi: {
title: "Перевод сообщений",

View file

@ -327,6 +327,36 @@ const drift = {
ready: "Ansluten · MLS",
errorPrefix: "Kunde inte nå koordinatorn",
interactiveHint: "Din signerare begär godkännande för varje åtgärd — använd Uppdatera och Skicka vid behov.",
setupDevice: "Ställ in den här enheten",
setupDeviceBody: "Det här ger enheten en egen nyckel för den privata chatten. En grundare godkänner den en gång.",
waitingApproval: "Väntar på grundarens godkännande…",
waitingApprovalManual: "Väntar på grundaren. Tryck på ”Kontrollera inbjudan” när de har godkänt.",
onboardingFounder1: "Starta den privata chatten.",
onboardingFounder2: "Godkänn medlemmar som ber att få gå med.",
onboardingFounder3: "Chatta privat med ditt crew.",
onboardingMember1: "Be om att gå med",
onboardingMember2: "Grundaren godkänner din enhet.",
onboardingMember3: "Du är med — meddelandena är krypterade.",
encryptedNote: "Bara crewmedlemmar kan läsa det här; koordinatorn vidarebefordrar bara krypterade meddelanden.",
advanced: "Avancerat",
coordinator: "Koordinator",
keyPackage: "Nyckelpaket",
keyPackageReady: "Redo på den här enheten",
keyPackageMissing: "Inte inställt på den här enheten",
members: "Medlemmar",
roleFounder: "Grundare",
roleAdmin: "Admin",
roleMember: "Medlem",
qrTitle: "Skanna för att gå med i den privata chatten",
qrHint: "Crewmedlemmar skannar det här med telefonkameran. Länken öppnar fliken Privat chatt.",
sendFailed: "Kunde inte skicka — tryck på Försök igen.",
sending: "Skickar…",
sent: "Skickat",
retry: "Försök igen",
errorUnreachable: "Kunde inte nå den privata chatten. Kontrollera din anslutning.",
errorDetails: "Detaljer",
unreachable: "Kan inte nås",
dismiss: "Stäng",
},
translateUi: {
title: "Meddelandeöversättning",

View file

@ -319,6 +319,36 @@ const driftStrings = {
ready: 'เชื่อมต่อแล้ว · MLS',
errorPrefix: 'เชื่อมต่อ coordinator ไม่ได้',
interactiveHint: 'ตัวลงนามของคุณขออนุมัติทุกครั้งที่ทำรายการ — ใช้รีเฟรชและส่งตามต้องการ',
setupDevice: "ตั้งค่าอุปกรณ์นี้",
setupDeviceBody: "การทำเช่นนี้จะให้กุญแจเฉพาะแก่อุปกรณ์นี้สำหรับแชทส่วนตัว ผู้ก่อตั้งจะอนุมัติหนึ่งครั้ง",
waitingApproval: "กำลังรอผู้ก่อตั้งอนุมัติ…",
waitingApprovalManual: "กำลังรอผู้ก่อตั้ง แตะ “ตรวจสอบคำเชิญ” หลังจากอนุมัติแล้ว",
onboardingFounder1: "เริ่มแชทส่วนตัว",
onboardingFounder2: "อนุมัติสมาชิกที่ขอเข้าร่วม",
onboardingFounder3: "พูดคุยส่วนตัวกับ crew ของคุณ",
onboardingMember1: 'ขอเข้าร่วม',
onboardingMember2: "ผู้ก่อตั้งจะอนุมัติอุปกรณ์ของคุณ",
onboardingMember3: "คุณเข้าร่วมแล้ว — ข้อความถูกเข้ารหัส",
encryptedNote: "เฉพาะสมาชิก crew เท่านั้นที่อ่านได้ coordinator เพียงส่งต่อข้อความที่เข้ารหัส",
advanced: "ขั้นสูง",
coordinator: "coordinator",
keyPackage: "แพ็กเกจคีย์",
keyPackageReady: "พร้อมบนอุปกรณ์นี้",
keyPackageMissing: "ยังไม่ได้ตั้งค่าบนอุปกรณ์นี้",
members: "สมาชิก",
roleFounder: "ผู้ก่อตั้ง",
roleAdmin: "แอดมิน",
roleMember: "สมาชิก",
qrTitle: "สแกนเพื่อเข้าร่วมแชทส่วนตัว",
qrHint: "สมาชิก crew สแกนด้วยกล้องโทรศัพท์ ลิงก์จะเปิดแท็บแชทส่วนตัว",
sendFailed: "ส่งไม่สำเร็จ — แตะลองใหม่",
sending: "กำลังส่ง…",
sent: "ส่งแล้ว",
retry: "ลองใหม่",
errorUnreachable: "เชื่อมต่อแชทส่วนตัวไม่ได้ ตรวจสอบการเชื่อมต่อของคุณ",
errorDetails: "รายละเอียด",
unreachable: "เชื่อมต่อไม่ได้",
dismiss: "ปิด",
},
translateUi: {
title: 'การแปลข้อความ',

View file

@ -319,6 +319,36 @@ const driftStrings = {
ready: 'Bağlandı · MLS',
errorPrefix: 'Koordinatöre ulaşılamadı',
interactiveHint: "İmzalayıcın her işlem için onay ister — gerektiğinde Yenile ve Gönder'i kullan.",
setupDevice: "Bu cihazı kur",
setupDeviceBody: "Bu, cihaza özel sohbet için kendi anahtarını verir. Bir kurucu bunu bir kez onaylar.",
waitingApproval: "Kurucunun onaylaması bekleniyor…",
waitingApprovalManual: "Kurucu bekleniyor. Onayladıktan sonra “Davetiye kontrol et”e dokun.",
onboardingFounder1: "Özel sohbeti başlat.",
onboardingFounder2: "Katılmak isteyen üyeleri onayla.",
onboardingFounder3: "Crew’inle özel olarak sohbet et.",
onboardingMember1: 'Katılmayı iste',
onboardingMember2: "Kurucu cihazını onaylar.",
onboardingMember3: "İçerdesin — mesajlar şifreli.",
encryptedNote: "Bunu yalnızca crew üyeleri okuyabilir; koordinatör yalnızca şifreli mesajları iletir.",
advanced: "Gelişmiş",
coordinator: "Koordinatör",
keyPackage: "Anahtar paketi",
keyPackageReady: "Bu cihazda hazır",
keyPackageMissing: "Bu cihazda ayarlanmadı",
members: "Üyeler",
roleFounder: "Kurucu",
roleAdmin: "Yönetici",
roleMember: "Üye",
qrTitle: "Özel sohbete katılmak için tara",
qrHint: "Crew üyeleri bunu telefon kamerasıyla tarar. Bağlantı Özel sohbet sekmesini açar.",
sendFailed: "Gönderilemedi — Yeniden dene’ye dokun.",
sending: "Gönderiliyor…",
sent: "Gönderildi",
retry: "Yeniden dene",
errorUnreachable: "Özel sohbete ulaşılamadı. Bağlantını kontrol et.",
errorDetails: "Ayrıntılar",
unreachable: "Ulaşılamıyor",
dismiss: "Kapat",
},
translateUi: {
title: 'Mesaj çevirisi',

View file

@ -327,6 +327,36 @@ const drift = {
ready: "Підключено · MLS",
errorPrefix: "Не вдалося зв'язатися з координатором",
interactiveHint: "Ваш підписувач запитує підтвердження для кожної дії — використовуйте «Оновити» та «Надіслати» за потреби.",
setupDevice: "Налаштувати цей пристрій",
setupDeviceBody: "Це дає пристрою власний ключ для приватного чату. Засновник схвалить його один раз.",
waitingApproval: "Очікування схвалення засновника…",
waitingApprovalManual: "Очікування засновника. Після схвалення натисніть «Перевірити запрошення».",
onboardingFounder1: "Запустіть приватний чат.",
onboardingFounder2: "Схвалюйте учасників, які просять приєднатися.",
onboardingFounder3: "Спілкуйтеся приватно зі своєю командою.",
onboardingMember1: "Попросити приєднатися",
onboardingMember2: "Засновник схвалить ваш пристрій.",
onboardingMember3: "Ви всередині — повідомлення зашифровані.",
encryptedNote: "Читати можуть лише учасники команди; координатор лише передає зашифровані повідомлення.",
advanced: "Додатково",
coordinator: "Координатор",
keyPackage: "Пакет ключів",
keyPackageReady: "Готово на цьому пристрої",
keyPackageMissing: "Не налаштовано на цьому пристрої",
members: "Учасники",
roleFounder: "Засновник",
roleAdmin: "Адміністратор",
roleMember: "Учасник",
qrTitle: "Відскануйте, щоб увійти до приватного чату",
qrHint: "Учасники команди сканують код камерою телефона. Посилання відкриває вкладку «Приватний чат».",
sendFailed: "Не вдалося надіслати — натисніть «Повторити».",
sending: "Надсилання…",
sent: "Надіслано",
retry: "Повторити",
errorUnreachable: "Не вдалося зв’язатися з приватним чатом. Перевірте з’єднання.",
errorDetails: "Подробиці",
unreachable: "Недоступно",
dismiss: "Закрити",
},
translateUi: {
title: "Переклад повідомлень",

View file

@ -319,6 +319,36 @@ const driftStrings = {
ready: 'Đã kết nối · MLS',
errorPrefix: 'Không thể kết nối tới điều phối viên',
interactiveHint: 'Trình ký của bạn yêu cầu phê duyệt mỗi hành động — dùng Làm mới và Gửi khi cần.',
setupDevice: "Thiết lập thiết bị này",
setupDeviceBody: "Thao tác này cấp cho thiết bị một khóa riêng cho chat riêng tư. Người sáng lập phê duyệt một lần.",
waitingApproval: "Đang chờ người sáng lập phê duyệt…",
waitingApprovalManual: "Đang chờ người sáng lập. Nhấn “Kiểm tra lời mời” sau khi họ phê duyệt.",
onboardingFounder1: "Bắt đầu chat riêng tư.",
onboardingFounder2: "Phê duyệt thành viên xin tham gia.",
onboardingFounder3: "Trò chuyện riêng với crew của bạn.",
onboardingMember1: 'Xin tham gia',
onboardingMember2: "Người sáng lập phê duyệt thiết bị của bạn.",
onboardingMember3: "Bạn đã vào — tin nhắn được mã hóa.",
encryptedNote: "Chỉ thành viên crew mới đọc được; điều phối viên chỉ chuyển tiếp tin nhắn đã mã hóa.",
advanced: "Nâng cao",
coordinator: "Điều phối viên",
keyPackage: "Gói khóa",
keyPackageReady: "Sẵn sàng trên thiết bị này",
keyPackageMissing: "Chưa thiết lập trên thiết bị này",
members: "Thành viên",
roleFounder: "Người sáng lập",
roleAdmin: "Quản trị viên",
roleMember: "Thành viên",
qrTitle: "Quét để tham gia chat riêng tư",
qrHint: "Thành viên crew quét mã bằng camera điện thoại. Liên kết mở tab Chat riêng tư.",
sendFailed: "Không gửi được — nhấn Thử lại.",
sending: "Đang gửi…",
sent: "Đã gửi",
retry: "Thử lại",
errorUnreachable: "Không thể kết nối tới chat riêng tư. Kiểm tra kết nối của bạn.",
errorDetails: "Chi tiết",
unreachable: "Không thể kết nối",
dismiss: "Đóng",
},
translateUi: {
title: 'Dịch tin nhắn',

View file

@ -335,6 +335,36 @@ const drift: Dict = {
ready: '已连接 · MLS',
errorPrefix: '无法连接协调器',
interactiveHint: '你的签名器会为每个操作请求批准——按需使用刷新和发送。',
setupDevice: "设置此设备",
setupDeviceBody: "为此设备分配私密聊天专用的密钥。创始人批准一次即可。",
waitingApproval: "正在等待创始人批准…",
waitingApprovalManual: "正在等待创始人。批准后请点击“检查邀请”。",
onboardingFounder1: "开始私密聊天。",
onboardingFounder2: "批准请求加入的成员。",
onboardingFounder3: "与你的 Crew 私密聊天。",
onboardingMember1: '请求加入',
onboardingMember2: "创始人会批准你的设备。",
onboardingMember3: "你已加入——消息已加密。",
encryptedNote: "只有 Crew 成员可以阅读;协调器只转发加密消息。",
advanced: "高级",
coordinator: "协调器",
keyPackage: "密钥包",
keyPackageReady: "已在此设备上就绪",
keyPackageMissing: "尚未在此设备上设置",
members: "成员",
roleFounder: "创始人",
roleAdmin: "管理员",
roleMember: "成员",
qrTitle: "扫描加入私密聊天",
qrHint: "Crew 成员用手机摄像头扫描。链接会打开“私密聊天”标签页。",
sendFailed: "发送失败——点击重试。",
sending: "发送中…",
sent: "已发送",
retry: "重试",
errorUnreachable: "无法连接私密聊天。请检查网络连接。",
errorDetails: "详情",
unreachable: "无法连接",
dismiss: "关闭",
},
translateUi: {
title: '消息翻译',

View file

@ -335,6 +335,36 @@ const drift: Dict = {
ready: '已連線 · MLS',
errorPrefix: '無法連線協調器',
interactiveHint: '你的簽章器會為每個操作要求核准——視需要使用重新整理和傳送。',
setupDevice: "設定此裝置",
setupDeviceBody: "為此裝置分配私密聊天專用的金鑰。創始人核准一次即可。",
waitingApproval: "正在等待創始人核准…",
waitingApprovalManual: "正在等待創始人。核准後請點「檢查邀請」。",
onboardingFounder1: "開始私密聊天。",
onboardingFounder2: "核准要求加入的成員。",
onboardingFounder3: "與你的 Crew 私密聊天。",
onboardingMember1: '要求加入',
onboardingMember2: "創始人會核准你的裝置。",
onboardingMember3: "你已加入——訊息已加密。",
encryptedNote: "只有 Crew 成員可以閱讀;協調器只轉送加密訊息。",
advanced: "進階",
coordinator: "協調器",
keyPackage: "金鑰包",
keyPackageReady: "已在此裝置上就緒",
keyPackageMissing: "尚未在此裝置上設定",
members: "成員",
roleFounder: "創始人",
roleAdmin: "管理員",
roleMember: "成員",
qrTitle: "掃描加入私密聊天",
qrHint: "Crew 成員用手機相機掃描。連結會開啟「私密聊天」分頁。",
sendFailed: "傳送失敗——點重試。",
sending: "傳送中…",
sent: "已傳送",
retry: "重試",
errorUnreachable: "無法連線私密聊天。請檢查網路連線。",
errorDetails: "詳細資料",
unreachable: "無法連線",
dismiss: "關閉",
},
translateUi: {
title: '訊息翻譯',

View file

@ -42,6 +42,7 @@ import { useTranslation } from '@/contexts/language';
import { useCrew } from '@/hooks/useCrews';
import { useCrewFeed } from '@/hooks/useCrewFeed';
import { useCordnGroupExists } from '@/hooks/useCordnNative';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { genUserName } from '@/lib/genUserName';
@ -49,7 +50,7 @@ import { SKATE_CREW_KIND, SHOW_CREW_TREASURY, CREW_LOOKING_FOR_LABELS, type Crew
import { CREW_LOOKING_FOR_KEYS } from '@/lib/labelKeys';
import { sanitizeUrl, urlDomain } from '@/lib/sanitizeUrl';
import NotFound from './NotFound';
import { Users, MapPin, ArrowLeft, Calendar, Globe, Instagram, Edit, MessageSquare, Zap, ShieldCheck, Music, Lock } from 'lucide-react';
import { Users, MapPin, ArrowLeft, Calendar, Globe, Instagram, Edit, MessageSquare, Zap, ShieldCheck, Music, Lock, X } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
export default function CrewDetailPage() {
@ -75,6 +76,10 @@ export default function CrewDetailPage() {
// already holds the group — used to default the tab and to nudge the founder.
const crewGid = crew ? `${SKATE_CREW_KIND}:${crew.pubkey}:${crew.identifier}` : '';
const nativeGroupExists = useCordnGroupExists(user?.pubkey, crewGid, activeTab);
const [cordnBannerDismissed, setCordnBannerDismissed] = useLocalStorage(
`skatespots:cordn-banner-dismissed:${pubkey ?? ''}/${identifier ?? ''}`,
false,
);
useEffect(() => {
if (nativeGroupExists && !tabTouchedRef.current) setActiveTab('mls');
}, [nativeGroupExists]);
@ -296,29 +301,42 @@ export default function CrewDetailPage() {
)}
{/* Founder/admin nudge: no private (MLS) chat yet. */}
{canManage && nativeGroupExists === false && activeTab !== 'mls' && (
<Card className="border-teal-500/30 bg-teal-500/5">
<CardContent className="flex flex-wrap items-center justify-between gap-3 py-3">
<div className="flex items-center gap-3 text-sm">
<Lock className="h-4 w-4 shrink-0 text-teal-400" />
<div>
<div className="font-medium text-gray-200">{t('cordnUi.bannerTitle')}</div>
<div className="text-xs text-gray-500">{t('cordnUi.bannerBody')}</div>
</div>
</div>
<Button
size="sm"
onClick={() => {
tabTouchedRef.current = true;
setActiveTab('mls');
}}
className="gap-1.5 bg-teal-500 font-semibold text-black hover:bg-teal-400"
{canManage &&
nativeGroupExists === false &&
activeTab !== 'mls' &&
!cordnBannerDismissed && (
<Card className="relative border-teal-500/30 bg-teal-500/5">
<button
type="button"
onClick={() => setCordnBannerDismissed(true)}
aria-label={t('cordnUi.dismiss')}
className="absolute right-2 top-2 rounded-full p-1 text-gray-500 transition-colors hover:bg-white/10 hover:text-gray-300"
>
{t('cordnUi.bannerCta')}
</Button>
</CardContent>
</Card>
)}
<X className="h-3.5 w-3.5" />
</button>
<CardContent className="flex flex-wrap items-center justify-between gap-3 py-3 pr-8">
<div className="flex items-center gap-3 text-sm">
<Lock className="h-4 w-4 shrink-0 text-teal-400" />
<div>
<div className="font-medium text-gray-200">
{t('cordnUi.bannerTitle')}
</div>
<div className="text-xs text-gray-500">{t('cordnUi.bannerBody')}</div>
</div>
</div>
<Button
size="sm"
onClick={() => {
tabTouchedRef.current = true;
setActiveTab('mls');
}}
className="gap-1.5 bg-teal-500 font-semibold text-black hover:bg-teal-400"
>
{t('cordnUi.bannerCta')}
</Button>
</CardContent>
</Card>
)}
{/* Activity — one clean tabbed surface instead of three stacked cards */}
<Tabs