feat(cordn): device QR, member removal, message reply; patch deps

This commit is contained in:
SkateSpots 2026-10-28 09:14:03 +07:00
parent ec5e63663b
commit 630780753f
34 changed files with 1388 additions and 533 deletions

1100
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -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"
}
}

View 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();

View file

@ -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>
);
}

View file

@ -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,

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -357,6 +357,17 @@ const drift = {
errorDetails: "Λεπτομέρειες",
unreachable: "Μη προσβάσιμο",
dismiss: "Κλείσιμο",
linkDevice: "Σύνδεση αυτής της συσκευής",
linkDeviceTitle: "Σύνδεση άλλης συσκευής",
linkDeviceHint: "Σάρωσε αυτόν τον κώδικα σε μια συσκευή που βρίσκεται ήδη στην αίθουσα.",
linkDeviceNote: "Ο κώδικας περιέχει ένα πακέτο δημόσιων κλειδιών. Το ιδιωτικό σου κλειδί μένει στη νέα συσκευή.",
waitingDevice: "Αναμονή για την άλλη συσκευή…",
addDevice: "Προσθήκη συσκευής",
deviceLinked: "Η συσκευή προστέθηκε.",
deviceLinkFailed: "Δεν ήταν δυνατή η προσθήκη αυτής της συσκευής.",
removeMemberConfirm: "Αφαίρεση του {{name}} από την ιδιωτική συνομιλία; Αυτή η συσκευή θα χάσει την πρόσβαση.",
removeMemberFailed: "Δεν ήταν δυνατή η αφαίρεση αυτού του μέλους.",
reply: "Απάντηση",
},
communityUi: {
link: "Κοινότητα",

View file

@ -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",

View file

@ -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é",

View file

@ -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",

View file

@ -975,6 +975,17 @@ const batch1 = {
errorDetails: "विवरण",
unreachable: "पहुँच से बाहर",
dismiss: "बंद करें",
linkDevice: "इस डिवाइस को लिंक करें",
linkDeviceTitle: "दूसरा डिवाइस लिंक करें",
linkDeviceHint: "यह कोड उस डिवाइस पर स्कैन करें जो पहले से रूम में है।",
linkDeviceNote: "कोड में सार्वजनिक कुंजी पैकेज है। आपकी निजी कुंजी नए डिवाइस पर ही रहती है।",
waitingDevice: "दूसरे डिवाइस की प्रतीक्षा हो रही है…",
addDevice: "डिवाइस जोड़ें",
deviceLinked: "डिवाइस जोड़ दिया गया।",
deviceLinkFailed: "वह डिवाइस जोड़ा नहीं जा सका।",
removeMemberConfirm: "{{name}} को निजी चैट से हटाएँ? वह डिवाइस एक्सेस खो देगा।",
removeMemberFailed: "वह सदस्य हटाया नहीं जा सका।",
reply: "जवाब दें",
},
communityUi: {
"link": "कम्युनिटी",

View file

@ -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",

View file

@ -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",

View file

@ -365,6 +365,17 @@ const drift: Dict = {
errorDetails: "詳細",
unreachable: "接続不可",
dismiss: "閉じる",
linkDevice: "このデバイスをリンク",
linkDeviceTitle: "別のデバイスをリンク",
linkDeviceHint: "すでにルームに参加しているデバイスでこのコードをスキャンしてください。",
linkDeviceNote: "コードには公開鍵パッケージが含まれます。秘密鍵は新しいデバイスに残ります。",
waitingDevice: "もう一方のデバイスを待っています…",
addDevice: "デバイスを追加",
deviceLinked: "デバイスを追加しました。",
deviceLinkFailed: "そのデバイスを追加できませんでした。",
removeMemberConfirm: "{{name}} をプライベートチャットから削除しますか?そのデバイスはアクセスできなくなります。",
removeMemberFailed: "そのメンバーを削除できませんでした。",
reply: "返信",
},
communityUi: {
link: "コミュニティ",

View file

@ -365,6 +365,17 @@ const drift: Dict = {
errorDetails: "세부정보",
unreachable: "연결 불가",
dismiss: "닫기",
linkDevice: "이 기기 연결",
linkDeviceTitle: "다른 기기 연결",
linkDeviceHint: "이미 방에 있는 기기에서 이 코드를 스캔하세요.",
linkDeviceNote: "코드에는 공개 키 패키지가 포함되어 있습니다. 비공개 키는 새 기기에 남습니다.",
waitingDevice: "다른 기기를 기다리는 중…",
addDevice: "기기 추가",
deviceLinked: "기기가 추가되었습니다.",
deviceLinkFailed: "해당 기기를 추가하지 못했습니다.",
removeMemberConfirm: "비공개 채팅에서 {{name}}을(를) 제거할까요? 해당 기기는 더 이상 접근할 수 없습니다.",
removeMemberFailed: "해당 멤버를 제거하지 못했습니다.",
reply: "답장",
},
communityUi: {
link: "커뮤니티",

View file

@ -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',
},
};

View file

@ -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",

View file

@ -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ść",

View file

@ -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",

View file

@ -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",

View file

@ -357,6 +357,17 @@ const drift = {
errorDetails: "Подробности",
unreachable: "Недоступно",
dismiss: "Закрыть",
linkDevice: "Привязать это устройство",
linkDeviceTitle: "Привязать другое устройство",
linkDeviceHint: "Отсканируйте этот код на устройстве, которое уже в комнате.",
linkDeviceNote: "Код содержит пакет открытых ключей. Ваш закрытый ключ останется на новом устройстве.",
waitingDevice: "Ожидание другого устройства…",
addDevice: "Добавить устройство",
deviceLinked: "Устройство добавлено.",
deviceLinkFailed: "Не удалось добавить это устройство.",
removeMemberConfirm: "Удалить {{name}} из приватного чата? Это устройство потеряет доступ.",
removeMemberFailed: "Не удалось удалить этого участника.",
reply: "Ответить",
},
communityUi: {
link: "Сообщество",

View file

@ -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",

View file

@ -349,6 +349,17 @@ const driftStrings = {
errorDetails: "รายละเอียด",
unreachable: "เชื่อมต่อไม่ได้",
dismiss: "ปิด",
linkDevice: "เชื่อมโยงอุปกรณ์นี้",
linkDeviceTitle: "เชื่อมโยงอุปกรณ์อื่น",
linkDeviceHint: "สแกนรหัสนี้บนอุปกรณ์ที่อยู่ในห้องแล้ว",
linkDeviceNote: "รหัสนี้มีแพ็กเกจคีย์สาธารณะ คีย์ส่วนตัวของคุณจะอยู่บนอุปกรณ์ใหม่เท่านั้น",
waitingDevice: "กำลังรออุปกรณ์อื่น…",
addDevice: "เพิ่มอุปกรณ์",
deviceLinked: "เพิ่มอุปกรณ์แล้ว",
deviceLinkFailed: "ไม่สามารถเพิ่มอุปกรณ์นั้นได้",
removeMemberConfirm: "ต้องการนำ {{name}} ออกจากแชทส่วนตัวหรือไม่ อุปกรณ์นั้นจะสูญเสียการเข้าถึง",
removeMemberFailed: "ไม่สามารถนำสมาชิกนั้นออกได้",
reply: "ตอบกลับ",
},
communityUi: {
link: "ชุมชน",

View file

@ -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",

View file

@ -357,6 +357,17 @@ const drift = {
errorDetails: "Подробиці",
unreachable: "Недоступно",
dismiss: "Закрити",
linkDevice: "Прив’язати цей пристрій",
linkDeviceTitle: "Прив’язати інший пристрій",
linkDeviceHint: "Відскануйте цей код на пристрої, який уже є в кімнаті.",
linkDeviceNote: "Код містить пакет відкритих ключів. Ваш закритий ключ залишиться на новому пристрої.",
waitingDevice: "Очікування іншого пристрою…",
addDevice: "Додати пристрій",
deviceLinked: "Пристрій додано.",
deviceLinkFailed: "Не вдалося додати цей пристрій.",
removeMemberConfirm: "Видалити {{name}} з приватного чату? Цей пристрій втратить доступ.",
removeMemberFailed: "Не вдалося видалити цього учасника.",
reply: "Відповісти",
},
communityUi: {
link: "Спільнота",

View file

@ -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",

View file

@ -365,6 +365,17 @@ const drift: Dict = {
errorDetails: "详情",
unreachable: "无法连接",
dismiss: "关闭",
linkDevice: "关联此设备",
linkDeviceTitle: "关联另一台设备",
linkDeviceHint: "请在已在房间中的设备上扫描此代码。",
linkDeviceNote: "此代码包含公钥包。你的私钥会留在新设备上。",
waitingDevice: "正在等待另一台设备…",
addDevice: "添加设备",
deviceLinked: "设备已添加。",
deviceLinkFailed: "无法添加该设备。",
removeMemberConfirm: "要将 {{name}} 移出私密聊天吗?该设备将失去访问权限。",
removeMemberFailed: "无法移除该成员。",
reply: "回复",
},
communityUi: {
link: "社区",

View file

@ -365,6 +365,17 @@ const drift: Dict = {
errorDetails: "詳細資料",
unreachable: "無法連線",
dismiss: "關閉",
linkDevice: "連結此裝置",
linkDeviceTitle: "連結另一台裝置",
linkDeviceHint: "請在已在房間中的裝置上掃描此代碼。",
linkDeviceNote: "此代碼包含公鑰包。你的私鑰會留在新裝置上。",
waitingDevice: "正在等待另一台裝置…",
addDevice: "新增裝置",
deviceLinked: "已新增裝置。",
deviceLinkFailed: "無法新增該裝置。",
removeMemberConfirm: "要將 {{name}} 移出私密聊天嗎?該裝置將失去存取權限。",
removeMemberFailed: "無法移除該成員。",
reply: "回覆",
},
communityUi: {
link: "社群",

View 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();
});
});

View 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;
}
}

View file

@ -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;

View file

@ -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;