feat(cordn): polish private chat onboarding, polling and invites
This commit is contained in:
parent
1fc91d1192
commit
690c9c93bd
28 changed files with 1519 additions and 149 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "Μετάφραση μηνυμάτων",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "नेटवर्क अलर्ट",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: 'メッセージの翻訳',
|
||||
|
|
|
|||
|
|
@ -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: '메시지 번역',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "Перевод сообщений",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: 'การแปลข้อความ',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: "Переклад повідомлень",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: '消息翻译',
|
||||
|
|
|
|||
|
|
@ -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: '訊息翻譯',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue