feat(cordn): device QR, member removal, message reply; patch deps
This commit is contained in:
parent
ec5e63663b
commit
630780753f
34 changed files with 1388 additions and 533 deletions
1100
package-lock.json
generated
1100
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -179,6 +179,8 @@
|
|||
"web-push": "^3.6.7"
|
||||
},
|
||||
"overrides": {
|
||||
"@tanstack/query-core": "$@tanstack/react-query"
|
||||
"@tanstack/query-core": "$@tanstack/react-query",
|
||||
"protobufjs": "7.6.3",
|
||||
"sharp": "0.35.5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
81
scripts/cordn-benchmark.ts
Normal file
81
scripts/cordn-benchmark.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* Live Cordn native benchmark — send/decrypt/state-size on the real coordinator.
|
||||
*
|
||||
* npx tsx scripts/cordn-benchmark.ts
|
||||
* CORDN_BENCH_N=50 npx tsx scripts/cordn-benchmark.ts
|
||||
*
|
||||
* Creates a throwaway two-member group, warms up, measures N encrypted sends
|
||||
* and N decrypting fetches, and reports the persisted snapshot size (the same
|
||||
* shape IndexedDB stores). No user data or secrets are logged.
|
||||
*/
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { CordnSession, MemoryCordnStore, type CordnSnapshot, type CordnStore } from '../src/lib/cordn/native/session';
|
||||
|
||||
const N = Math.max(5, Math.min(Number(process.env.CORDN_BENCH_N ?? 30), 100));
|
||||
const coordinator = {
|
||||
serverPubkey: '02816a05b1d4e70a7df9c712ef6f7272774269d65bd8a2f262ce0330d0885693',
|
||||
relays: ['wss://relay.skate-spots.org'],
|
||||
};
|
||||
|
||||
class MeasuringStore implements CordnStore {
|
||||
private inner = new MemoryCordnStore();
|
||||
lastBytes = 0;
|
||||
async load(): Promise<CordnSnapshot | null> {
|
||||
return this.inner.load();
|
||||
}
|
||||
async save(snapshot: CordnSnapshot): Promise<void> {
|
||||
this.lastBytes = JSON.stringify(snapshot).length;
|
||||
await this.inner.save(snapshot);
|
||||
}
|
||||
async clear(): Promise<void> {
|
||||
await this.inner.clear();
|
||||
}
|
||||
}
|
||||
|
||||
const aStore = new MeasuringStore();
|
||||
const bStore = new MeasuringStore();
|
||||
const a = await CordnSession.open({ coordinator, store: aStore });
|
||||
const b = await CordnSession.open({ coordinator, store: bStore });
|
||||
await a.generateKeyPackage('a');
|
||||
const bk = await b.generateKeyPackage('b');
|
||||
await a.publishKeyPackage('a');
|
||||
await b.publishKeyPackage('b');
|
||||
const gid = `bench-${Date.now()}`;
|
||||
const group = await a.createGroup({ groupId: gid, name: 'Benchmark' });
|
||||
const { welcomeBase64 } = await a.addMember(group.gid, bk.keyPackage);
|
||||
const bGroup = await b.joinGroup({ welcomeBase64, keyPackage: bk, name: 'Benchmark' });
|
||||
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await a.send(gid, `warmup ${i}`);
|
||||
await b.fetch(gid);
|
||||
}
|
||||
|
||||
const sendStart = performance.now();
|
||||
for (let i = 0; i < N; i += 1) await a.send(gid, `bench message ${i}`);
|
||||
const sendMs = performance.now() - sendStart;
|
||||
|
||||
const fetchStart = performance.now();
|
||||
const received = await b.fetch(bGroup.gid);
|
||||
const fetchMs = performance.now() - fetchStart;
|
||||
|
||||
const sendPer = sendMs / N;
|
||||
const fetchPer = fetchMs / N;
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
messages: N,
|
||||
sendMs: Math.round(sendMs),
|
||||
sendPerMessageMs: Math.round(sendPer * 10) / 10,
|
||||
fetchMs: Math.round(fetchMs),
|
||||
fetchPerMessageMs: Math.round(fetchPer * 10) / 10,
|
||||
received: received.length,
|
||||
snapshotBytesA: aStore.lastBytes,
|
||||
snapshotBytesB: bStore.lastBytes,
|
||||
note: 'Memory snapshot shape matches the IndexedDB adapter; network + coordinator included.',
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
await a.disconnect();
|
||||
await b.disconnect();
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
|
|
@ -13,8 +14,11 @@ import {
|
|||
Plus,
|
||||
QrCode,
|
||||
RefreshCw,
|
||||
Reply,
|
||||
ScanLine,
|
||||
Send,
|
||||
Share2,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
Users,
|
||||
X,
|
||||
|
|
@ -33,6 +37,7 @@ import {
|
|||
} from '@/components/ui/dialog';
|
||||
import { QRCodeCanvas } from '@/components/ui/qrcode';
|
||||
import { ImageWithFallback } from '@/components/ImageWithFallback';
|
||||
import { QRScannerModal } from '@/components/wallet/QRScannerModal';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
|
|
@ -143,8 +148,10 @@ export function CordnNativePanel({
|
|||
const sendNative = native.send;
|
||||
const checkInviteNative = native.checkInvite;
|
||||
const [draft, setDraft] = useState('');
|
||||
const [replyTo, setReplyTo] = useState<{ id: string; content: string } | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copiedLink, setCopiedLink] = useState(false);
|
||||
const [copiedDevice, setCopiedDevice] = useState(false);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [awaiting, setAwaiting] = useState(() =>
|
||||
user?.pubkey ? readPendingJoin(user.pubkey, native.groupId) : false,
|
||||
|
|
@ -152,6 +159,10 @@ export function CordnNativePanel({
|
|||
const [pending, setPending] = useState<PendingMessage | null>(null);
|
||||
const [sentIds, setSentIds] = useState<Set<string>>(() => new Set());
|
||||
const [inviteOpen, setInviteOpen] = useState(false);
|
||||
const [deviceLinkOpen, setDeviceLinkOpen] = useState(false);
|
||||
const [deviceLinkCode, setDeviceLinkCode] = useState<string | null>(null);
|
||||
const [deviceLinkPending, setDeviceLinkPending] = useState(false);
|
||||
const [scannerOpen, setScannerOpen] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const checkingRef = useRef(false);
|
||||
|
|
@ -180,6 +191,9 @@ export function CordnNativePanel({
|
|||
if (native.group && awaiting) {
|
||||
if (user?.pubkey) clearPendingJoin(user.pubkey, native.groupId);
|
||||
setAwaiting(false);
|
||||
setDeviceLinkOpen(false);
|
||||
setDeviceLinkCode(null);
|
||||
setDeviceLinkPending(false);
|
||||
}
|
||||
}, [native.group, awaiting, user?.pubkey, native.groupId]);
|
||||
|
||||
|
|
@ -259,6 +273,25 @@ export function CordnNativePanel({
|
|||
}, 3000);
|
||||
}, []);
|
||||
|
||||
const withReply = useCallback(
|
||||
(text: string) =>
|
||||
replyTo
|
||||
? `> ${replyTo.content.replace(/\n/g, '\n> ')}\n\n${text}`
|
||||
: text,
|
||||
[replyTo],
|
||||
);
|
||||
|
||||
const copyMessage = useCallback(async (text: string) => {
|
||||
if (!text) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setNotice(t('common.copied'));
|
||||
window.setTimeout(() => setNotice(null), 1500);
|
||||
} catch {
|
||||
// clipboard blocked
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const deliver = useCallback(
|
||||
async (text: string, imageUrl?: string) => {
|
||||
setNotice(null);
|
||||
|
|
@ -278,8 +311,16 @@ export function CordnNativePanel({
|
|||
const body = draft.trim();
|
||||
if (!body || native.busy) return;
|
||||
setDraft('');
|
||||
await deliver(body);
|
||||
}, [draft, native.busy, deliver]);
|
||||
const content = withReply(body);
|
||||
const sent = await native.send(content);
|
||||
if (sent) {
|
||||
markSent(sent.id);
|
||||
setReplyTo(null);
|
||||
setPending(null);
|
||||
} else {
|
||||
setPending({ text: content, state: 'failed' });
|
||||
}
|
||||
}, [draft, native, withReply, markSent]);
|
||||
|
||||
const retrySend = useCallback(async () => {
|
||||
if (!pending || pending.state !== 'failed') return;
|
||||
|
|
@ -295,12 +336,12 @@ export function CordnNativePanel({
|
|||
if (!url) throw new Error('upload returned no url');
|
||||
const caption = draft.trim();
|
||||
setDraft('');
|
||||
await deliver(caption, url);
|
||||
await deliver(withReply(caption), url);
|
||||
} catch {
|
||||
setNotice(t('cordnUi.uploadFailed'));
|
||||
}
|
||||
},
|
||||
[uploadFile, draft, deliver, t],
|
||||
[uploadFile, draft, deliver, withReply, t],
|
||||
);
|
||||
|
||||
const copyRef = useCallback(async () => {
|
||||
|
|
@ -324,6 +365,39 @@ export function CordnNativePanel({
|
|||
}
|
||||
}, [inviteUrl]);
|
||||
|
||||
const openDeviceLink = useCallback(async () => {
|
||||
setDeviceLinkOpen(true);
|
||||
setDeviceLinkCode(null);
|
||||
setDeviceLinkPending(true);
|
||||
setNotice(null);
|
||||
const code = await native.createDeviceLink();
|
||||
if (!code) {
|
||||
setDeviceLinkPending(false);
|
||||
setNotice(t('cordnUi.deviceLinkFailed'));
|
||||
return;
|
||||
}
|
||||
setDeviceLinkCode(code);
|
||||
if (user?.pubkey) writePendingJoin(user.pubkey, native.groupId);
|
||||
setAwaiting(true);
|
||||
}, [native, user?.pubkey, t]);
|
||||
|
||||
const copyDeviceLink = useCallback(async () => {
|
||||
if (!deviceLinkCode) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(deviceLinkCode);
|
||||
setCopiedDevice(true);
|
||||
window.setTimeout(() => setCopiedDevice(false), 1500);
|
||||
} catch {
|
||||
// clipboard blocked — the code is visible
|
||||
}
|
||||
}, [deviceLinkCode]);
|
||||
|
||||
const addScannedDevice = useCallback(async (raw: string) => {
|
||||
setNotice(null);
|
||||
const ok = await native.addDevice(raw);
|
||||
setNotice(ok ? t('cordnUi.deviceLinked') : t('cordnUi.deviceLinkFailed'));
|
||||
}, [native, t]);
|
||||
|
||||
const checkNow = useCallback(async () => {
|
||||
if (checkingRef.current) return false;
|
||||
checkingRef.current = true;
|
||||
|
|
@ -584,11 +658,24 @@ export function CordnNativePanel({
|
|||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!native.ready || native.busy}
|
||||
onClick={() => void openDeviceLink()}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<QrCode className="h-3.5 w-3.5" /> {t('cordnUi.linkDevice')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{awaiting && (
|
||||
<p className="flex items-center justify-center gap-1.5 text-xs text-teal-300">
|
||||
{native.interactiveHintVisible ? (
|
||||
{deviceLinkPending ? (
|
||||
<>
|
||||
<QrCode className="h-3.5 w-3.5" /> {t('cordnUi.waitingDevice')}
|
||||
</>
|
||||
) : native.interactiveHintVisible ? (
|
||||
<>
|
||||
<RefreshCw className="h-3.5 w-3.5" /> {t('cordnUi.waitingApprovalManual')}
|
||||
</>
|
||||
|
|
@ -622,16 +709,40 @@ export function CordnNativePanel({
|
|||
<div className="mt-2 space-y-2">
|
||||
{native.group.members.map((member) => (
|
||||
<MemberRow
|
||||
key={member.pubkey}
|
||||
key={`${member.pubkey}-${member.leafIndex}`}
|
||||
pubkey={member.pubkey}
|
||||
role={roleOf(member.pubkey)}
|
||||
isSelf={member.pubkey === user.pubkey}
|
||||
founderLabel={copy.owner}
|
||||
botPubkey={botPubkey}
|
||||
botName={copy.botName}
|
||||
canRemove={canManage && member.pubkey !== user.pubkey && member.pubkey !== crew.pubkey}
|
||||
onRemove={() => {
|
||||
if (!window.confirm(t('cordnUi.removeMemberConfirm', { name: shortNpub(member.pubkey) }))) return;
|
||||
void native.removeMember(member.leafIndex).then((ok) => {
|
||||
if (!ok) setNotice(t('cordnUi.removeMemberFailed'));
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap justify-end gap-2">
|
||||
{!isCommunity && user.pubkey === crew.pubkey && (
|
||||
<Button asChild size="sm" variant="ghost" className="h-7 gap-1 px-2 text-xs">
|
||||
<Link to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}>
|
||||
<Users className="h-3 w-3" /> {t('socialUi.editCrew.title')}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 gap-1 px-2 text-xs"
|
||||
onClick={() => setScannerOpen(true)}
|
||||
>
|
||||
<ScanLine className="h-3 w-3" /> {t('cordnUi.addDevice')}
|
||||
</Button>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div
|
||||
|
|
@ -660,6 +771,26 @@ export function CordnNativePanel({
|
|||
<Check className="h-3 w-3" /> {t('cordnUi.sent')}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyMessage(m.content)}
|
||||
className="rounded p-0.5 text-gray-500 hover:bg-white/10 hover:text-gray-200"
|
||||
aria-label={t('common.copy')}
|
||||
title={t('common.copy')}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReplyTo({ id: m.id, content: m.content })}
|
||||
className="rounded p-0.5 text-gray-500 hover:bg-white/10 hover:text-gray-200"
|
||||
aria-label={t('cordnUi.reply')}
|
||||
title={t('cordnUi.reply')}
|
||||
>
|
||||
<Reply className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -731,6 +862,23 @@ export function CordnNativePanel({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{replyTo && (
|
||||
<div className="flex items-start gap-2 rounded-md border-l-2 border-teal-400 bg-teal-500/10 px-2 py-1 text-xs text-gray-300">
|
||||
<Reply className="mt-0.5 h-3 w-3 shrink-0 text-teal-300" />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{t('cordnUi.reply')}: {replyTo.content || t('cordnUi.attachImage')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReplyTo(null)}
|
||||
className="shrink-0 rounded p-0.5 text-gray-500 hover:text-gray-200"
|
||||
aria-label={t('common.cancel')}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={fileRef}
|
||||
|
|
@ -877,6 +1025,64 @@ export function CordnNativePanel({
|
|||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={deviceLinkOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDeviceLinkOpen(open);
|
||||
if (!open) setDeviceLinkCode(null);
|
||||
}}
|
||||
>
|
||||
<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.linkDeviceTitle')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t('cordnUi.linkDeviceHint')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{deviceLinkCode ? (
|
||||
<>
|
||||
<div className="flex justify-center rounded-xl bg-white p-3">
|
||||
<QRCodeCanvas value={deviceLinkCode} size={240} level="L" />
|
||||
</div>
|
||||
<code className="block max-h-20 break-all overflow-y-auto rounded bg-black/50 px-2 py-1.5 text-[10px] text-gray-400">
|
||||
{deviceLinkCode}
|
||||
</code>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={() => void copyDeviceLink()} className="flex-1 gap-1.5">
|
||||
{copiedDevice ? (
|
||||
<Check className="h-3.5 w-3.5 text-teal-400" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{copiedDevice ? t('cordnUi.copied') : t('cordnUi.copyRef')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDeviceLinkOpen(false);
|
||||
setDeviceLinkCode(null);
|
||||
}}
|
||||
>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-xs text-gray-400">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> {t('common.loading')}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[11px] text-gray-500">{t('cordnUi.linkDeviceNote')}</p>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<QRScannerModal
|
||||
isOpen={scannerOpen}
|
||||
onClose={() => setScannerOpen(false)}
|
||||
onResult={(result) => void addScannedDevice(result.value)}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -1022,6 +1228,8 @@ function MemberRow({
|
|||
founderLabel,
|
||||
botPubkey,
|
||||
botName,
|
||||
canRemove,
|
||||
onRemove,
|
||||
}: {
|
||||
pubkey: string;
|
||||
role: MemberRole;
|
||||
|
|
@ -1029,6 +1237,8 @@ function MemberRow({
|
|||
founderLabel: string;
|
||||
botPubkey?: string;
|
||||
botName?: string;
|
||||
canRemove?: boolean;
|
||||
onRemove?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const label =
|
||||
|
|
@ -1040,6 +1250,7 @@ function MemberRow({
|
|||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<PersonLabel pubkey={pubkey} isSelf={isSelf} botPubkey={botPubkey} botName={botName} />
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
|
|
@ -1051,6 +1262,19 @@ function MemberRow({
|
|||
>
|
||||
{label}
|
||||
</Badge>
|
||||
{canRemove && onRemove && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 text-gray-500 hover:bg-red-500/10 hover:text-red-300"
|
||||
onClick={onRemove}
|
||||
aria-label={t('common.remove')}
|
||||
title={t('common.remove')}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import type {
|
|||
} from '@/lib/cordn/native';
|
||||
import { isInteractiveSigner, isPassiveSigner } from '@/lib/signerCapabilities';
|
||||
import type { CordnRoom } from '@/lib/cordn/room';
|
||||
import { decodeDeviceLink, encodeDeviceLink } from '@/lib/cordn/deviceLink';
|
||||
|
||||
/** Resolved coordinator endpoint for the native (ContextVM) client. */
|
||||
export interface NativeCoordinator {
|
||||
|
|
@ -74,6 +75,12 @@ export interface UseCordnNativeResult {
|
|||
loadRequestsQuiet: () => Promise<void>;
|
||||
/** Founder/admin: add a requester (by pubkey) and deliver a welcome. */
|
||||
approve: (identifier: string) => Promise<boolean>;
|
||||
/** Founder/admin: remove a member leaf (device) from the group. */
|
||||
removeMember: (leafIndex: number) => Promise<boolean>;
|
||||
/** New device: publish a key package and return the QR payload. */
|
||||
createDeviceLink: () => Promise<string | null>;
|
||||
/** Existing member: scan a new-device payload and add it. */
|
||||
addDevice: (raw: string) => Promise<boolean>;
|
||||
/** True for interactive signers (Amber) — manual refresh/send, no polling. */
|
||||
interactiveHintVisible: boolean;
|
||||
/** True for nsec/bunker: safe to poll while the private tab is hidden. */
|
||||
|
|
@ -326,6 +333,59 @@ export function useCordnNative({
|
|||
}
|
||||
}, [groupId]);
|
||||
|
||||
const removeMember = useCallback(
|
||||
async (leafIndex: number) => {
|
||||
const session = sessionRef.current;
|
||||
if (!session || !group) return false;
|
||||
const removed = await run(async () => {
|
||||
const next = await session.removeMember(group.gid, leafIndex);
|
||||
setGroup(next);
|
||||
setMessages(session.messages(group.gid));
|
||||
return true;
|
||||
});
|
||||
return removed === true;
|
||||
},
|
||||
[group, run],
|
||||
);
|
||||
|
||||
const createDeviceLink = useCallback(async () => {
|
||||
const session = sessionRef.current;
|
||||
if (!session || !groupId || !coordinator) return null;
|
||||
const code = await run(async () => {
|
||||
const keyPackage = await session.publishKeyPackage(`device-${Date.now()}`);
|
||||
return encodeDeviceLink({
|
||||
gid: groupId,
|
||||
kpRef: keyPackage.keyPackageRef,
|
||||
pubkey: session.pubkey,
|
||||
coordinatorPubkey: coordinator.serverPubkey,
|
||||
relays: coordinator.relays,
|
||||
});
|
||||
});
|
||||
return code ?? null;
|
||||
}, [groupId, coordinator, run]);
|
||||
|
||||
const addDevice = useCallback(
|
||||
async (raw: string) => {
|
||||
const session = sessionRef.current;
|
||||
const link = decodeDeviceLink(raw);
|
||||
if (!session || !group || !coordinator || !link) return false;
|
||||
if (link.gid !== groupId || link.coordinatorPubkey !== coordinator.serverPubkey) return false;
|
||||
const added = await run(async () => {
|
||||
try {
|
||||
await session.addMemberByIdentifier(group.gid, link.kpRef);
|
||||
} catch {
|
||||
await session.addMemberByIdentifier(group.gid, link.pubkey);
|
||||
}
|
||||
const next = session.listGroups().find((g) => g.gid === group.gid) ?? group;
|
||||
setGroup(next);
|
||||
setMessages(session.messages(group.gid));
|
||||
return true;
|
||||
});
|
||||
return added === true;
|
||||
},
|
||||
[group, groupId, coordinator, run],
|
||||
);
|
||||
|
||||
const approve = useCallback(
|
||||
async (identifier: string) => {
|
||||
const session = sessionRef.current;
|
||||
|
|
@ -358,6 +418,9 @@ export function useCordnNative({
|
|||
loadRequests,
|
||||
loadRequestsQuiet,
|
||||
approve,
|
||||
removeMember,
|
||||
createDeviceLink,
|
||||
addDevice,
|
||||
interactiveHintVisible: interactive,
|
||||
passiveSigner,
|
||||
groupRef,
|
||||
|
|
|
|||
|
|
@ -975,6 +975,17 @@ const batch1 = {
|
|||
errorDetails: "Detalls",
|
||||
unreachable: "Inabastable",
|
||||
dismiss: "Tanca",
|
||||
linkDevice: "Enllaça aquest dispositiu",
|
||||
linkDeviceTitle: "Enllaça un altre dispositiu",
|
||||
linkDeviceHint: "Escaneja aquest codi en un dispositiu que ja sigui a la sala.",
|
||||
linkDeviceNote: "El codi conté un paquet de claus públiques. La teva clau privada es queda al dispositiu nou.",
|
||||
waitingDevice: "S’està esperant l’altre dispositiu…",
|
||||
addDevice: "Afegeix un dispositiu",
|
||||
deviceLinked: "Dispositiu afegit.",
|
||||
deviceLinkFailed: "No s’ha pogut afegir aquest dispositiu.",
|
||||
removeMemberConfirm: "Vols treure {{name}} del xat privat? Aquest dispositiu perdrà l’accés.",
|
||||
removeMemberFailed: "No s’ha pogut treure aquest membre.",
|
||||
reply: "Respon",
|
||||
},
|
||||
communityUi: {
|
||||
"link": "Comunitat",
|
||||
|
|
|
|||
|
|
@ -353,6 +353,17 @@ const drift = {
|
|||
errorDetails: "Podrobnosti",
|
||||
unreachable: "Nedostupné",
|
||||
dismiss: "Zavřít",
|
||||
linkDevice: "Propojit toto zařízení",
|
||||
linkDeviceTitle: "Propojit jiné zařízení",
|
||||
linkDeviceHint: "Naskenujte tento kód na zařízení, které už je v místnosti.",
|
||||
linkDeviceNote: "Kód obsahuje balíček veřejných klíčů. Váš soukromý klíč zůstává na novém zařízení.",
|
||||
waitingDevice: "Čekání na druhé zařízení…",
|
||||
addDevice: "Přidat zařízení",
|
||||
deviceLinked: "Zařízení přidáno.",
|
||||
deviceLinkFailed: "Toto zařízení se nepodařilo přidat.",
|
||||
removeMemberConfirm: "Odebrat {{name}} ze soukromého chatu? Toto zařízení ztratí přístup.",
|
||||
removeMemberFailed: "Tohoto člena se nepodařilo odebrat.",
|
||||
reply: "Odpovědět",
|
||||
},
|
||||
communityUi: {
|
||||
"link": "Komunita",
|
||||
|
|
|
|||
|
|
@ -355,6 +355,17 @@ const drift = {
|
|||
errorDetails: "Details",
|
||||
unreachable: "Nicht erreichbar",
|
||||
dismiss: "Schließen",
|
||||
linkDevice: "Dieses Gerät verknüpfen",
|
||||
linkDeviceTitle: "Weiteres Gerät verknüpfen",
|
||||
linkDeviceHint: "Scanne diesen Code auf einem Gerät, das bereits im Raum ist.",
|
||||
linkDeviceNote: "Der Code enthält ein öffentliches Schlüsselpaket. Dein privater Schlüssel bleibt auf dem neuen Gerät.",
|
||||
waitingDevice: "Warte auf das andere Gerät…",
|
||||
addDevice: "Gerät hinzufügen",
|
||||
deviceLinked: "Gerät hinzugefügt.",
|
||||
deviceLinkFailed: "Dieses Gerät konnte nicht hinzugefügt werden.",
|
||||
removeMemberConfirm: "{{name}} aus dem privaten Chat entfernen? Dieses Gerät verliert den Zugriff.",
|
||||
removeMemberFailed: "Dieses Mitglied konnte nicht entfernt werden.",
|
||||
reply: "Antworten",
|
||||
},
|
||||
communityUi: {
|
||||
link: "Community",
|
||||
|
|
|
|||
|
|
@ -357,6 +357,17 @@ const drift = {
|
|||
errorDetails: "Λεπτομέρειες",
|
||||
unreachable: "Μη προσβάσιμο",
|
||||
dismiss: "Κλείσιμο",
|
||||
linkDevice: "Σύνδεση αυτής της συσκευής",
|
||||
linkDeviceTitle: "Σύνδεση άλλης συσκευής",
|
||||
linkDeviceHint: "Σάρωσε αυτόν τον κώδικα σε μια συσκευή που βρίσκεται ήδη στην αίθουσα.",
|
||||
linkDeviceNote: "Ο κώδικας περιέχει ένα πακέτο δημόσιων κλειδιών. Το ιδιωτικό σου κλειδί μένει στη νέα συσκευή.",
|
||||
waitingDevice: "Αναμονή για την άλλη συσκευή…",
|
||||
addDevice: "Προσθήκη συσκευής",
|
||||
deviceLinked: "Η συσκευή προστέθηκε.",
|
||||
deviceLinkFailed: "Δεν ήταν δυνατή η προσθήκη αυτής της συσκευής.",
|
||||
removeMemberConfirm: "Αφαίρεση του {{name}} από την ιδιωτική συνομιλία; Αυτή η συσκευή θα χάσει την πρόσβαση.",
|
||||
removeMemberFailed: "Δεν ήταν δυνατή η αφαίρεση αυτού του μέλους.",
|
||||
reply: "Απάντηση",
|
||||
},
|
||||
communityUi: {
|
||||
link: "Κοινότητα",
|
||||
|
|
|
|||
|
|
@ -975,6 +975,17 @@ const batch1 = {
|
|||
errorDetails: "Xehetasunak",
|
||||
unreachable: "Ezin iritsi",
|
||||
dismiss: "Itxi",
|
||||
linkDevice: "Estekatu gailu hau",
|
||||
linkDeviceTitle: "Estekatu beste gailu bat",
|
||||
linkDeviceHint: "Eskaneatu kode hau jada gelan dagoen gailu batean.",
|
||||
linkDeviceNote: "Kodeak gako publikoen paketea dauka. Zure gako pribatua gailu berrian geratzen da.",
|
||||
waitingDevice: "Beste gailuaren zain…",
|
||||
addDevice: "Gehitu gailu bat",
|
||||
deviceLinked: "Gailua gehituta.",
|
||||
deviceLinkFailed: "Ezin izan da gailu hori gehitu.",
|
||||
removeMemberConfirm: "{{name}} txat pribatutik kendu? Gailu horrek sarbidea galduko du.",
|
||||
removeMemberFailed: "Ezin izan da kide hori kendu.",
|
||||
reply: "Erantzun",
|
||||
},
|
||||
communityUi: {
|
||||
link: "Komunitatea",
|
||||
|
|
|
|||
|
|
@ -355,6 +355,17 @@ const drift = {
|
|||
errorDetails: "Détails",
|
||||
unreachable: "Injoignable",
|
||||
dismiss: "Fermer",
|
||||
linkDevice: "Lier cet appareil",
|
||||
linkDeviceTitle: "Lier un autre appareil",
|
||||
linkDeviceHint: "Scannez ce code sur un appareil déjà présent dans le salon.",
|
||||
linkDeviceNote: "Le code contient un paquet de clés publiques. Votre clé privée reste sur le nouvel appareil.",
|
||||
waitingDevice: "En attente de l’autre appareil…",
|
||||
addDevice: "Ajouter un appareil",
|
||||
deviceLinked: "Appareil ajouté.",
|
||||
deviceLinkFailed: "Impossible d’ajouter cet appareil.",
|
||||
removeMemberConfirm: "Retirer {{name}} du chat privé ? Cet appareil perdra l’accès.",
|
||||
removeMemberFailed: "Impossible de retirer ce membre.",
|
||||
reply: "Répondre",
|
||||
},
|
||||
communityUi: {
|
||||
link: "Communauté",
|
||||
|
|
|
|||
|
|
@ -975,6 +975,17 @@ const batch1 = {
|
|||
errorDetails: "Detalles",
|
||||
unreachable: "Inaccesible",
|
||||
dismiss: "Pechar",
|
||||
linkDevice: "Vincular este dispositivo",
|
||||
linkDeviceTitle: "Vincular outro dispositivo",
|
||||
linkDeviceHint: "Escanea este código nun dispositivo que xa estea na sala.",
|
||||
linkDeviceNote: "O código contén un paquete de chaves públicas. A túa chave privada queda no novo dispositivo.",
|
||||
waitingDevice: "Agardando polo outro dispositivo…",
|
||||
addDevice: "Engadir un dispositivo",
|
||||
deviceLinked: "Dispositivo engadido.",
|
||||
deviceLinkFailed: "Non se puido engadir ese dispositivo.",
|
||||
removeMemberConfirm: "Quitar {{name}} do chat privado? Ese dispositivo perderá o acceso.",
|
||||
removeMemberFailed: "Non se puido quitar ese membro.",
|
||||
reply: "Responder",
|
||||
},
|
||||
communityUi: {
|
||||
link: "Comunidade",
|
||||
|
|
|
|||
|
|
@ -975,6 +975,17 @@ const batch1 = {
|
|||
errorDetails: "विवरण",
|
||||
unreachable: "पहुँच से बाहर",
|
||||
dismiss: "बंद करें",
|
||||
linkDevice: "इस डिवाइस को लिंक करें",
|
||||
linkDeviceTitle: "दूसरा डिवाइस लिंक करें",
|
||||
linkDeviceHint: "यह कोड उस डिवाइस पर स्कैन करें जो पहले से रूम में है।",
|
||||
linkDeviceNote: "कोड में सार्वजनिक कुंजी पैकेज है। आपकी निजी कुंजी नए डिवाइस पर ही रहती है।",
|
||||
waitingDevice: "दूसरे डिवाइस की प्रतीक्षा हो रही है…",
|
||||
addDevice: "डिवाइस जोड़ें",
|
||||
deviceLinked: "डिवाइस जोड़ दिया गया।",
|
||||
deviceLinkFailed: "वह डिवाइस जोड़ा नहीं जा सका।",
|
||||
removeMemberConfirm: "{{name}} को निजी चैट से हटाएँ? वह डिवाइस एक्सेस खो देगा।",
|
||||
removeMemberFailed: "वह सदस्य हटाया नहीं जा सका।",
|
||||
reply: "जवाब दें",
|
||||
},
|
||||
communityUi: {
|
||||
"link": "कम्युनिटी",
|
||||
|
|
|
|||
|
|
@ -349,6 +349,17 @@ const driftStrings = {
|
|||
errorDetails: "Detail",
|
||||
unreachable: "Tidak terjangkau",
|
||||
dismiss: "Tutup",
|
||||
linkDevice: "Tautkan perangkat ini",
|
||||
linkDeviceTitle: "Tautkan perangkat lain",
|
||||
linkDeviceHint: "Pindai kode ini di perangkat yang sudah ada di ruangan.",
|
||||
linkDeviceNote: "Kode ini berisi paket kunci publik. Kunci pribadi kamu tetap di perangkat baru.",
|
||||
waitingDevice: "Menunggu perangkat lain…",
|
||||
addDevice: "Tambah perangkat",
|
||||
deviceLinked: "Perangkat ditambahkan.",
|
||||
deviceLinkFailed: "Tidak dapat menambahkan perangkat itu.",
|
||||
removeMemberConfirm: "Hapus {{name}} dari chat privat? Perangkat itu akan kehilangan akses.",
|
||||
removeMemberFailed: "Tidak dapat menghapus anggota itu.",
|
||||
reply: "Balas",
|
||||
},
|
||||
communityUi: {
|
||||
link: "Komunitas",
|
||||
|
|
|
|||
|
|
@ -355,6 +355,17 @@ const drift = {
|
|||
errorDetails: "Dettagli",
|
||||
unreachable: "Non raggiungibile",
|
||||
dismiss: "Chiudi",
|
||||
linkDevice: "Collega questo dispositivo",
|
||||
linkDeviceTitle: "Collega un altro dispositivo",
|
||||
linkDeviceHint: "Scansiona questo codice su un dispositivo già presente nella stanza.",
|
||||
linkDeviceNote: "Il codice contiene un pacchetto di chiavi pubbliche. La tua chiave privata resta sul nuovo dispositivo.",
|
||||
waitingDevice: "In attesa dell’altro dispositivo…",
|
||||
addDevice: "Aggiungi un dispositivo",
|
||||
deviceLinked: "Dispositivo aggiunto.",
|
||||
deviceLinkFailed: "Impossibile aggiungere quel dispositivo.",
|
||||
removeMemberConfirm: "Rimuovere {{name}} dalla chat privata? Quel dispositivo perderà l’accesso.",
|
||||
removeMemberFailed: "Impossibile rimuovere quel membro.",
|
||||
reply: "Rispondi",
|
||||
},
|
||||
communityUi: {
|
||||
link: "Community",
|
||||
|
|
|
|||
|
|
@ -365,6 +365,17 @@ const drift: Dict = {
|
|||
errorDetails: "詳細",
|
||||
unreachable: "接続不可",
|
||||
dismiss: "閉じる",
|
||||
linkDevice: "このデバイスをリンク",
|
||||
linkDeviceTitle: "別のデバイスをリンク",
|
||||
linkDeviceHint: "すでにルームに参加しているデバイスでこのコードをスキャンしてください。",
|
||||
linkDeviceNote: "コードには公開鍵パッケージが含まれます。秘密鍵は新しいデバイスに残ります。",
|
||||
waitingDevice: "もう一方のデバイスを待っています…",
|
||||
addDevice: "デバイスを追加",
|
||||
deviceLinked: "デバイスを追加しました。",
|
||||
deviceLinkFailed: "そのデバイスを追加できませんでした。",
|
||||
removeMemberConfirm: "{{name}} をプライベートチャットから削除しますか?そのデバイスはアクセスできなくなります。",
|
||||
removeMemberFailed: "そのメンバーを削除できませんでした。",
|
||||
reply: "返信",
|
||||
},
|
||||
communityUi: {
|
||||
link: "コミュニティ",
|
||||
|
|
|
|||
|
|
@ -365,6 +365,17 @@ const drift: Dict = {
|
|||
errorDetails: "세부정보",
|
||||
unreachable: "연결 불가",
|
||||
dismiss: "닫기",
|
||||
linkDevice: "이 기기 연결",
|
||||
linkDeviceTitle: "다른 기기 연결",
|
||||
linkDeviceHint: "이미 방에 있는 기기에서 이 코드를 스캔하세요.",
|
||||
linkDeviceNote: "코드에는 공개 키 패키지가 포함되어 있습니다. 비공개 키는 새 기기에 남습니다.",
|
||||
waitingDevice: "다른 기기를 기다리는 중…",
|
||||
addDevice: "기기 추가",
|
||||
deviceLinked: "기기가 추가되었습니다.",
|
||||
deviceLinkFailed: "해당 기기를 추가하지 못했습니다.",
|
||||
removeMemberConfirm: "비공개 채팅에서 {{name}}을(를) 제거할까요? 해당 기기는 더 이상 접근할 수 없습니다.",
|
||||
removeMemberFailed: "해당 멤버를 제거하지 못했습니다.",
|
||||
reply: "답장",
|
||||
},
|
||||
communityUi: {
|
||||
link: "커뮤니티",
|
||||
|
|
|
|||
|
|
@ -79,6 +79,19 @@ const cordnUi = {
|
|||
errorDetails: 'Details',
|
||||
unreachable: 'Unreachable',
|
||||
dismiss: 'Dismiss',
|
||||
linkDevice: 'Link this device',
|
||||
linkDeviceTitle: 'Link another device',
|
||||
linkDeviceHint: 'Scan this code on a device that is already in the room.',
|
||||
linkDeviceNote:
|
||||
'The code contains a public key package. Your private key stays on the new device.',
|
||||
waitingDevice: 'Waiting for the other device…',
|
||||
addDevice: 'Add a device',
|
||||
deviceLinked: 'Device added.',
|
||||
deviceLinkFailed: 'Couldn’t add that device.',
|
||||
removeMemberConfirm:
|
||||
'Remove {{name}} from the private chat? That device will lose access.',
|
||||
removeMemberFailed: 'Couldn’t remove that member.',
|
||||
reply: 'Reply',
|
||||
},
|
||||
es: {
|
||||
title: 'Chat privado',
|
||||
|
|
@ -155,6 +168,19 @@ const cordnUi = {
|
|||
errorDetails: 'Detalles',
|
||||
unreachable: 'Sin conexión',
|
||||
dismiss: 'Descartar',
|
||||
linkDevice: 'Vincular este dispositivo',
|
||||
linkDeviceTitle: 'Vincular otro dispositivo',
|
||||
linkDeviceHint: 'Escanea este código en un dispositivo que ya esté en la sala.',
|
||||
linkDeviceNote:
|
||||
'El código contiene un paquete de claves público. Tu clave privada se queda en el dispositivo nuevo.',
|
||||
waitingDevice: 'Esperando al otro dispositivo…',
|
||||
addDevice: 'Añadir un dispositivo',
|
||||
deviceLinked: 'Dispositivo añadido.',
|
||||
deviceLinkFailed: 'No se pudo añadir ese dispositivo.',
|
||||
removeMemberConfirm:
|
||||
'¿Quitar a {{name}} del chat privado? Ese dispositivo perderá el acceso.',
|
||||
removeMemberFailed: 'No se pudo quitar a ese miembro.',
|
||||
reply: 'Responder',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -353,6 +353,17 @@ const drift = {
|
|||
errorDetails: "Details",
|
||||
unreachable: "Onbereikbaar",
|
||||
dismiss: "Sluiten",
|
||||
linkDevice: "Dit apparaat koppelen",
|
||||
linkDeviceTitle: "Een ander apparaat koppelen",
|
||||
linkDeviceHint: "Scan deze code op een apparaat dat al in de ruimte is.",
|
||||
linkDeviceNote: "De code bevat een openbaar sleutelpakket. Je privésleutel blijft op het nieuwe apparaat.",
|
||||
waitingDevice: "Wachten op het andere apparaat…",
|
||||
addDevice: "Apparaat toevoegen",
|
||||
deviceLinked: "Apparaat toegevoegd.",
|
||||
deviceLinkFailed: "Kon dat apparaat niet toevoegen.",
|
||||
removeMemberConfirm: "{{name}} uit de privéchat verwijderen? Dat apparaat verliest de toegang.",
|
||||
removeMemberFailed: "Kon dat lid niet verwijderen.",
|
||||
reply: "Antwoorden",
|
||||
},
|
||||
communityUi: {
|
||||
"link": "Community",
|
||||
|
|
|
|||
|
|
@ -353,6 +353,17 @@ const drift = {
|
|||
errorDetails: "Szczegóły",
|
||||
unreachable: "Nieosiągalne",
|
||||
dismiss: "Zamknij",
|
||||
linkDevice: "Połącz to urządzenie",
|
||||
linkDeviceTitle: "Połącz inne urządzenie",
|
||||
linkDeviceHint: "Zeskanuj ten kod na urządzeniu, które jest już w pokoju.",
|
||||
linkDeviceNote: "Kod zawiera pakiet kluczy publicznych. Twój klucz prywatny pozostaje na nowym urządzeniu.",
|
||||
waitingDevice: "Oczekiwanie na drugie urządzenie…",
|
||||
addDevice: "Dodaj urządzenie",
|
||||
deviceLinked: "Dodano urządzenie.",
|
||||
deviceLinkFailed: "Nie udało się dodać tego urządzenia.",
|
||||
removeMemberConfirm: "Usunąć {{name}} z prywatnego czatu? To urządzenie utraci dostęp.",
|
||||
removeMemberFailed: "Nie udało się usunąć tego członka.",
|
||||
reply: "Odpowiedz",
|
||||
},
|
||||
communityUi: {
|
||||
"link": "Społeczność",
|
||||
|
|
|
|||
|
|
@ -355,6 +355,17 @@ const drift = {
|
|||
errorDetails: "Detalhes",
|
||||
unreachable: "Inacessível",
|
||||
dismiss: "Fechar",
|
||||
linkDevice: "Vincular este dispositivo",
|
||||
linkDeviceTitle: "Vincular outro dispositivo",
|
||||
linkDeviceHint: "Escaneie este código em um dispositivo que já esteja na sala.",
|
||||
linkDeviceNote: "O código contém um pacote de chaves públicas. Sua chave privada permanece no novo dispositivo.",
|
||||
waitingDevice: "Aguardando o outro dispositivo…",
|
||||
addDevice: "Adicionar um dispositivo",
|
||||
deviceLinked: "Dispositivo adicionado.",
|
||||
deviceLinkFailed: "Não foi possível adicionar esse dispositivo.",
|
||||
removeMemberConfirm: "Remover {{name}} do chat privado? Esse dispositivo perderá o acesso.",
|
||||
removeMemberFailed: "Não foi possível remover esse membro.",
|
||||
reply: "Responder",
|
||||
},
|
||||
communityUi: {
|
||||
link: "Comunidade",
|
||||
|
|
|
|||
|
|
@ -353,6 +353,17 @@ const drift = {
|
|||
errorDetails: "Detalii",
|
||||
unreachable: "Inaccesibil",
|
||||
dismiss: "Închide",
|
||||
linkDevice: "Conectează acest dispozitiv",
|
||||
linkDeviceTitle: "Conectează alt dispozitiv",
|
||||
linkDeviceHint: "Scanează acest cod pe un dispozitiv care este deja în cameră.",
|
||||
linkDeviceNote: "Codul conține un pachet de chei publice. Cheia ta privată rămâne pe dispozitivul nou.",
|
||||
waitingDevice: "Se așteaptă celălalt dispozitiv…",
|
||||
addDevice: "Adaugă un dispozitiv",
|
||||
deviceLinked: "Dispozitiv adăugat.",
|
||||
deviceLinkFailed: "Nu s-a putut adăuga acel dispozitiv.",
|
||||
removeMemberConfirm: "Elimini {{name}} din chatul privat? Dispozitivul respectiv va pierde accesul.",
|
||||
removeMemberFailed: "Nu s-a putut elimina acel membru.",
|
||||
reply: "Răspunde",
|
||||
},
|
||||
communityUi: {
|
||||
"link": "Comunitate",
|
||||
|
|
|
|||
|
|
@ -357,6 +357,17 @@ const drift = {
|
|||
errorDetails: "Подробности",
|
||||
unreachable: "Недоступно",
|
||||
dismiss: "Закрыть",
|
||||
linkDevice: "Привязать это устройство",
|
||||
linkDeviceTitle: "Привязать другое устройство",
|
||||
linkDeviceHint: "Отсканируйте этот код на устройстве, которое уже в комнате.",
|
||||
linkDeviceNote: "Код содержит пакет открытых ключей. Ваш закрытый ключ останется на новом устройстве.",
|
||||
waitingDevice: "Ожидание другого устройства…",
|
||||
addDevice: "Добавить устройство",
|
||||
deviceLinked: "Устройство добавлено.",
|
||||
deviceLinkFailed: "Не удалось добавить это устройство.",
|
||||
removeMemberConfirm: "Удалить {{name}} из приватного чата? Это устройство потеряет доступ.",
|
||||
removeMemberFailed: "Не удалось удалить этого участника.",
|
||||
reply: "Ответить",
|
||||
},
|
||||
communityUi: {
|
||||
link: "Сообщество",
|
||||
|
|
|
|||
|
|
@ -357,6 +357,17 @@ const drift = {
|
|||
errorDetails: "Detaljer",
|
||||
unreachable: "Kan inte nås",
|
||||
dismiss: "Stäng",
|
||||
linkDevice: "Länka den här enheten",
|
||||
linkDeviceTitle: "Länka en annan enhet",
|
||||
linkDeviceHint: "Skanna den här koden på en enhet som redan är i rummet.",
|
||||
linkDeviceNote: "Koden innehåller ett paket med publika nycklar. Din privata nyckel stannar på den nya enheten.",
|
||||
waitingDevice: "Väntar på den andra enheten…",
|
||||
addDevice: "Lägg till en enhet",
|
||||
deviceLinked: "Enhet tillagd.",
|
||||
deviceLinkFailed: "Det gick inte att lägga till den enheten.",
|
||||
removeMemberConfirm: "Ta bort {{name}} från den privata chatten? Enheten förlorar åtkomsten.",
|
||||
removeMemberFailed: "Det gick inte att ta bort den medlemmen.",
|
||||
reply: "Svara",
|
||||
},
|
||||
communityUi: {
|
||||
link: "Community",
|
||||
|
|
|
|||
|
|
@ -349,6 +349,17 @@ const driftStrings = {
|
|||
errorDetails: "รายละเอียด",
|
||||
unreachable: "เชื่อมต่อไม่ได้",
|
||||
dismiss: "ปิด",
|
||||
linkDevice: "เชื่อมโยงอุปกรณ์นี้",
|
||||
linkDeviceTitle: "เชื่อมโยงอุปกรณ์อื่น",
|
||||
linkDeviceHint: "สแกนรหัสนี้บนอุปกรณ์ที่อยู่ในห้องแล้ว",
|
||||
linkDeviceNote: "รหัสนี้มีแพ็กเกจคีย์สาธารณะ คีย์ส่วนตัวของคุณจะอยู่บนอุปกรณ์ใหม่เท่านั้น",
|
||||
waitingDevice: "กำลังรออุปกรณ์อื่น…",
|
||||
addDevice: "เพิ่มอุปกรณ์",
|
||||
deviceLinked: "เพิ่มอุปกรณ์แล้ว",
|
||||
deviceLinkFailed: "ไม่สามารถเพิ่มอุปกรณ์นั้นได้",
|
||||
removeMemberConfirm: "ต้องการนำ {{name}} ออกจากแชทส่วนตัวหรือไม่ อุปกรณ์นั้นจะสูญเสียการเข้าถึง",
|
||||
removeMemberFailed: "ไม่สามารถนำสมาชิกนั้นออกได้",
|
||||
reply: "ตอบกลับ",
|
||||
},
|
||||
communityUi: {
|
||||
link: "ชุมชน",
|
||||
|
|
|
|||
|
|
@ -349,6 +349,17 @@ const driftStrings = {
|
|||
errorDetails: "Ayrıntılar",
|
||||
unreachable: "Ulaşılamıyor",
|
||||
dismiss: "Kapat",
|
||||
linkDevice: "Bu cihazı bağla",
|
||||
linkDeviceTitle: "Başka bir cihaz bağla",
|
||||
linkDeviceHint: "Bu kodu odada zaten bulunan bir cihazda tara.",
|
||||
linkDeviceNote: "Kod bir açık anahtar paketi içerir. Özel anahtarın yeni cihazda kalır.",
|
||||
waitingDevice: "Diğer cihaz bekleniyor…",
|
||||
addDevice: "Cihaz ekle",
|
||||
deviceLinked: "Cihaz eklendi.",
|
||||
deviceLinkFailed: "O cihaz eklenemedi.",
|
||||
removeMemberConfirm: "{{name}} özel sohbetten çıkarılsın mı? O cihaz erişimini kaybedecek.",
|
||||
removeMemberFailed: "O üye çıkarılamadı.",
|
||||
reply: "Yanıtla",
|
||||
},
|
||||
communityUi: {
|
||||
link: "Topluluk",
|
||||
|
|
|
|||
|
|
@ -357,6 +357,17 @@ const drift = {
|
|||
errorDetails: "Подробиці",
|
||||
unreachable: "Недоступно",
|
||||
dismiss: "Закрити",
|
||||
linkDevice: "Прив’язати цей пристрій",
|
||||
linkDeviceTitle: "Прив’язати інший пристрій",
|
||||
linkDeviceHint: "Відскануйте цей код на пристрої, який уже є в кімнаті.",
|
||||
linkDeviceNote: "Код містить пакет відкритих ключів. Ваш закритий ключ залишиться на новому пристрої.",
|
||||
waitingDevice: "Очікування іншого пристрою…",
|
||||
addDevice: "Додати пристрій",
|
||||
deviceLinked: "Пристрій додано.",
|
||||
deviceLinkFailed: "Не вдалося додати цей пристрій.",
|
||||
removeMemberConfirm: "Видалити {{name}} з приватного чату? Цей пристрій втратить доступ.",
|
||||
removeMemberFailed: "Не вдалося видалити цього учасника.",
|
||||
reply: "Відповісти",
|
||||
},
|
||||
communityUi: {
|
||||
link: "Спільнота",
|
||||
|
|
|
|||
|
|
@ -349,6 +349,17 @@ const driftStrings = {
|
|||
errorDetails: "Chi tiết",
|
||||
unreachable: "Không thể kết nối",
|
||||
dismiss: "Đóng",
|
||||
linkDevice: "Liên kết thiết bị này",
|
||||
linkDeviceTitle: "Liên kết thiết bị khác",
|
||||
linkDeviceHint: "Quét mã này trên thiết bị đã có trong phòng.",
|
||||
linkDeviceNote: "Mã chứa gói khóa công khai. Khóa riêng tư của bạn vẫn ở trên thiết bị mới.",
|
||||
waitingDevice: "Đang chờ thiết bị kia…",
|
||||
addDevice: "Thêm thiết bị",
|
||||
deviceLinked: "Đã thêm thiết bị.",
|
||||
deviceLinkFailed: "Không thể thêm thiết bị đó.",
|
||||
removeMemberConfirm: "Xóa {{name}} khỏi chat riêng tư? Thiết bị đó sẽ mất quyền truy cập.",
|
||||
removeMemberFailed: "Không thể xóa thành viên đó.",
|
||||
reply: "Trả lời",
|
||||
},
|
||||
communityUi: {
|
||||
link: "Cộng đồng",
|
||||
|
|
|
|||
|
|
@ -365,6 +365,17 @@ const drift: Dict = {
|
|||
errorDetails: "详情",
|
||||
unreachable: "无法连接",
|
||||
dismiss: "关闭",
|
||||
linkDevice: "关联此设备",
|
||||
linkDeviceTitle: "关联另一台设备",
|
||||
linkDeviceHint: "请在已在房间中的设备上扫描此代码。",
|
||||
linkDeviceNote: "此代码包含公钥包。你的私钥会留在新设备上。",
|
||||
waitingDevice: "正在等待另一台设备…",
|
||||
addDevice: "添加设备",
|
||||
deviceLinked: "设备已添加。",
|
||||
deviceLinkFailed: "无法添加该设备。",
|
||||
removeMemberConfirm: "要将 {{name}} 移出私密聊天吗?该设备将失去访问权限。",
|
||||
removeMemberFailed: "无法移除该成员。",
|
||||
reply: "回复",
|
||||
},
|
||||
communityUi: {
|
||||
link: "社区",
|
||||
|
|
|
|||
|
|
@ -365,6 +365,17 @@ const drift: Dict = {
|
|||
errorDetails: "詳細資料",
|
||||
unreachable: "無法連線",
|
||||
dismiss: "關閉",
|
||||
linkDevice: "連結此裝置",
|
||||
linkDeviceTitle: "連結另一台裝置",
|
||||
linkDeviceHint: "請在已在房間中的裝置上掃描此代碼。",
|
||||
linkDeviceNote: "此代碼包含公鑰包。你的私鑰會留在新裝置上。",
|
||||
waitingDevice: "正在等待另一台裝置…",
|
||||
addDevice: "新增裝置",
|
||||
deviceLinked: "已新增裝置。",
|
||||
deviceLinkFailed: "無法新增該裝置。",
|
||||
removeMemberConfirm: "要將 {{name}} 移出私密聊天嗎?該裝置將失去存取權限。",
|
||||
removeMemberFailed: "無法移除該成員。",
|
||||
reply: "回覆",
|
||||
},
|
||||
communityUi: {
|
||||
link: "社群",
|
||||
|
|
|
|||
26
src/lib/cordn/deviceLink.test.ts
Normal file
26
src/lib/cordn/deviceLink.test.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { decodeDeviceLink, encodeDeviceLink } from './deviceLink';
|
||||
|
||||
const link = {
|
||||
gid: 'skatespots:community',
|
||||
kpRef: 'kp_ref_123',
|
||||
pubkey: 'a'.repeat(64),
|
||||
coordinatorPubkey: 'b'.repeat(64),
|
||||
relays: ['wss://relay.skate-spots.org'],
|
||||
};
|
||||
|
||||
describe('deviceLink', () => {
|
||||
it('round-trips a valid link', () => {
|
||||
expect(decodeDeviceLink(encodeDeviceLink(link))).toEqual(link);
|
||||
});
|
||||
|
||||
it('rejects malformed or unsafe links', () => {
|
||||
expect(decodeDeviceLink('')).toBeNull();
|
||||
expect(decodeDeviceLink('https://example.com')).toBeNull();
|
||||
expect(decodeDeviceLink('cordn-device:not-base64')).toBeNull();
|
||||
const bad = encodeDeviceLink({ ...link, pubkey: 'javascript:' });
|
||||
expect(decodeDeviceLink(bad)).toBeNull();
|
||||
const badRelay = encodeDeviceLink({ ...link, relays: ['javascript:alert(1)'] });
|
||||
expect(decodeDeviceLink(badRelay)).toBeNull();
|
||||
});
|
||||
});
|
||||
81
src/lib/cordn/deviceLink.ts
Normal file
81
src/lib/cordn/deviceLink.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* Two-device handoff payload.
|
||||
*
|
||||
* The NEW device publishes a key package to the coordinator and shows this
|
||||
* compact code as a QR. An EXISTING member scans it, consumes the key package
|
||||
* by ref, and the coordinator stores the Welcome for the new device. The
|
||||
* private key never leaves the new device, and the code is short enough for a
|
||||
* normal QR.
|
||||
*/
|
||||
export interface CordnDeviceLink {
|
||||
gid: string;
|
||||
kpRef: string;
|
||||
pubkey: string;
|
||||
coordinatorPubkey: string;
|
||||
relays: string[];
|
||||
}
|
||||
|
||||
const PREFIX = 'cordn-device:';
|
||||
const HEX64 = /^[0-9a-f]{64}$/;
|
||||
|
||||
function b64urlEncode(value: string): string {
|
||||
return btoa(value).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
function b64urlDecode(value: string): string {
|
||||
const padded = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const pad = padded.length % 4 === 0 ? '' : '='.repeat(4 - (padded.length % 4));
|
||||
return atob(padded + pad);
|
||||
}
|
||||
|
||||
export function encodeDeviceLink(link: CordnDeviceLink): string {
|
||||
const json = JSON.stringify({
|
||||
g: link.gid,
|
||||
k: link.kpRef,
|
||||
p: link.pubkey,
|
||||
c: link.coordinatorPubkey,
|
||||
r: link.relays,
|
||||
});
|
||||
return `${PREFIX}${b64urlEncode(json)}`;
|
||||
}
|
||||
|
||||
export function decodeDeviceLink(raw: string | undefined | null): CordnDeviceLink | null {
|
||||
if (!raw || !raw.startsWith(PREFIX)) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(b64urlDecode(raw.slice(PREFIX.length))) as {
|
||||
g?: unknown;
|
||||
k?: unknown;
|
||||
p?: unknown;
|
||||
c?: unknown;
|
||||
r?: unknown;
|
||||
};
|
||||
if (
|
||||
typeof parsed.g !== 'string' ||
|
||||
!parsed.g ||
|
||||
typeof parsed.k !== 'string' ||
|
||||
!parsed.k ||
|
||||
typeof parsed.p !== 'string' ||
|
||||
!HEX64.test(parsed.p) ||
|
||||
typeof parsed.c !== 'string' ||
|
||||
!HEX64.test(parsed.c) ||
|
||||
!Array.isArray(parsed.r) ||
|
||||
parsed.r.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const relays = parsed.r.filter(
|
||||
(relay): relay is string =>
|
||||
typeof relay === 'string' && /^wss?:\/\/[^\s]+$/i.test(relay),
|
||||
);
|
||||
if (relays.length === 0) return null;
|
||||
return {
|
||||
gid: parsed.g,
|
||||
kpRef: parsed.k,
|
||||
pubkey: parsed.p,
|
||||
coordinatorPubkey: parsed.c,
|
||||
relays,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -258,6 +258,29 @@ export async function addMemberToGroup(params: {
|
|||
};
|
||||
}
|
||||
|
||||
/** Remove a member leaf from the group and produce the commit to broadcast. */
|
||||
export async function removeMemberFromGroup(params: {
|
||||
state: ClientState;
|
||||
leafIndex: number;
|
||||
}): Promise<{ newState: ClientState; commitMessageBase64: string }> {
|
||||
const cipherSuite = await getCipherSuite();
|
||||
const result = await createCommit({
|
||||
context: { cipherSuite, authService: unsafeTestingAuthenticationService },
|
||||
state: params.state,
|
||||
ratchetTreeExtension: true,
|
||||
extraProposals: [
|
||||
{
|
||||
proposalType: defaultProposalTypes.remove,
|
||||
remove: { removed: params.leafIndex },
|
||||
},
|
||||
],
|
||||
});
|
||||
return {
|
||||
newState: result.newState,
|
||||
commitMessageBase64: encodeMlsMessage(result.commit),
|
||||
};
|
||||
}
|
||||
|
||||
export async function joinFromWelcome(params: {
|
||||
welcome: Welcome;
|
||||
keyPackage: KeyPackage;
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
joinFromWelcome,
|
||||
createGroupApplicationMessage,
|
||||
processGroupMessage,
|
||||
removeMemberFromGroup,
|
||||
sealForCoordinator,
|
||||
openFromCoordinator,
|
||||
groupIdOf,
|
||||
|
|
@ -397,6 +398,22 @@ export class CordnSession {
|
|||
return this.addMember(gid, memberKeyPackage);
|
||||
}
|
||||
|
||||
/** Remove a member leaf and post the MLS commit. */
|
||||
async removeMember(gid: string, leafIndex: number): Promise<CordnGroupInfo> {
|
||||
const group = this.requireGroup(gid);
|
||||
const { newState, commitMessageBase64 } = await removeMemberFromGroup({
|
||||
state: group.state,
|
||||
leafIndex,
|
||||
});
|
||||
const sealed = await sealForCoordinator({ state: group.state, messageBase64: commitMessageBase64 });
|
||||
const posted = await this.getClient().postGroupMessage(group.gid, sealed);
|
||||
group.posted.add(sealed);
|
||||
group.state = newState;
|
||||
group.cursor = Math.max(group.cursor, posted.cursor);
|
||||
await this.persist();
|
||||
return this.describeGroup(group);
|
||||
}
|
||||
|
||||
/** Join a group from a Welcome produced by {@link addMember}. */
|
||||
async joinGroup(params: {
|
||||
welcomeBase64: string;
|
||||
|
|
|
|||
Loading…
Reference in a new issue