Cordn native: browser probe, group-ref sharing, image messages
- scripts/cordn-browser-probe.mjs: headless-Chromium proof that the built native chunk runs in a real browser — imports the chunk, runs a session against the coordinator (publish kp, create group, send), then reopens from the Dexie/IndexedDB store. Not part of npm test (needs network + Chromium). - Group ref: the panel shows/copies the group's cordn1... ref (encodeGroupRef from the crew coordinate + coordinator), so it can be opened in Staircase/cordn-web. Computed in the hook via the lazy native import. - Images: attach button uploads to Blossom (useUploadFile) and sends the URL inside the encrypted MLS envelope (['image', url]); CordnChatMessage.imageUrl + ImageWithFallback rendering. Covered by the live test. - Unread badge deliberately not built (needs a background signed request). npm test + smoke + browser probe + 3 live scenarios all pass.
This commit is contained in:
parent
383fb07127
commit
a9a5966714
9 changed files with 369 additions and 18 deletions
23
AGENTS.md
23
AGENTS.md
|
|
@ -1360,12 +1360,27 @@ session with **no other memory of this project**. So:
|
||||||
1.9 MB entry) + `components/crew/CordnNativePanel.tsx`, rendered as a **third
|
1.9 MB entry) + `components/crew/CordnNativePanel.tsx`, rendered as a **third
|
||||||
"Private chat" tab** on the crew page (founder/admin create the group + approve
|
"Private chat" tab** on the crew page (founder/admin create the group + approve
|
||||||
requests; members ask to join + check for an invite). Strings in
|
requests; members ask to join + check for an invite). Strings in
|
||||||
`i18n/legacy/cordnUi.ts`. ⚠️ **Not yet exercised in a real browser** (the browser
|
`i18n/legacy/cordnUi.ts`. ⚠️ The **React panel** isn't click-tested (no automated
|
||||||
tooling was disconnected this session) — the Node live tests + boot smoke pass,
|
login+crew flow); the native stack's browser runtime IS — see milestone 2b. For **interactive signers (Amber)**
|
||||||
but the panel's browser runtime (native WebSocket / IndexedDB) is unverified;
|
|
||||||
load the crew tab once before trusting it. For **interactive signers (Amber)**
|
|
||||||
polling is off (each ContextVM request would pop) — manual Refresh/Send, same
|
polling is off (each ContextVM request would pop) — manual Refresh/Send, same
|
||||||
rule as the DM hooks.
|
rule as the DM hooks.
|
||||||
|
**Milestone 2b (2026-10-12):** ① **browser runtime proven** —
|
||||||
|
`node scripts/cordn-browser-probe.mjs` serves `dist/`, drives headless Chromium
|
||||||
|
over CDP (same harness as `smoke-test.mjs`), imports the built native chunk, and
|
||||||
|
runs a real session against the coordinator (publish key package → create group
|
||||||
|
→ send), then reopens from the **Dexie/IndexedDB** store (1 group, 1 message
|
||||||
|
restored). It is NOT in `npm test` (needs network + Chromium). ⚠️ The barrel
|
||||||
|
chunk exports its namespace under a mangled name, so the probe picks
|
||||||
|
`ns.CordnSession ? ns : ns.a`. ② **Group ref sharing** — the panel shows the
|
||||||
|
`cordn1…` ref (`encodeGroupRef({ gid: crew coordinate, coordinatorPubkey,
|
||||||
|
relays })`, re-exported from the native barrel; computed in the hook) with a
|
||||||
|
**Copy** button, so the group can be opened in Staircase/cordn-web. ③ **Images**
|
||||||
|
— the composer has an attach button (`useUploadFile` → Blossom), and the URL
|
||||||
|
rides **inside** the encrypted MLS envelope (`['image', url]` tag),
|
||||||
|
`CordnChatMessage.imageUrl`, rendered with `ImageWithFallback`. Verified in the
|
||||||
|
live test (image URL decrypts to the other member). ⚠️ **Unread badge for the
|
||||||
|
tab is deliberately NOT built** — it needs a background signed request (pops on
|
||||||
|
Amber) or a poll; revisit only if the feature gets real use.
|
||||||
- **Map site-links strip (2026-10-05):** the desktop footer is hidden on the map
|
- **Map site-links strip (2026-10-05):** the desktop footer is hidden on the map
|
||||||
(full-bleed), so `components/MapFooterStrip.tsx` surfaces the same links
|
(full-bleed), so `components/MapFooterStrip.tsx` surfaces the same links
|
||||||
(Newsletter/License/Privacy/Collaborators + "not sponsored by OpenSats") as an
|
(Newsletter/License/Privacy/Collaborators + "not sponsored by OpenSats") as an
|
||||||
|
|
|
||||||
206
scripts/cordn-browser-probe.mjs
Normal file
206
scripts/cordn-browser-probe.mjs
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Cordn-native browser probe.
|
||||||
|
*
|
||||||
|
* The Node live test proves the MLS logic; this proves the **browser runtime**
|
||||||
|
* of the same stack — the built native chunk actually loading in Chromium, then
|
||||||
|
* a real session against the coordinator over a browser WebSocket, with MLS
|
||||||
|
* crypto and the Dexie/IndexedDB store.
|
||||||
|
*
|
||||||
|
* It is deliberately NOT part of `npm test` (it needs network + Chromium):
|
||||||
|
*
|
||||||
|
* node scripts/cordn-browser-probe.mjs
|
||||||
|
*
|
||||||
|
* Exit 0 = the browser stack worked end to end.
|
||||||
|
*/
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { readFile, readdir } from 'node:fs/promises';
|
||||||
|
import { existsSync, rmSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join, extname, normalize } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import net from 'node:net';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import WebSocket from 'ws';
|
||||||
|
|
||||||
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const distDir = join(root, 'dist');
|
||||||
|
const assetsDir = join(distDir, 'assets');
|
||||||
|
const chromePath = process.env.CHROME_PATH || '/snap/bin/chromium';
|
||||||
|
const log = (m) => console.log(`[cordn-probe] ${m}`);
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
// The SkateSpots coordinator (public facts, same as src/lib/coordinator.ts).
|
||||||
|
const COORDINATOR_PUBKEY = '02816a05b1d4e70a7df9c712ef6f7272774269d65bd8a2f262ce0330d0885693';
|
||||||
|
const COORDINATOR_RELAY = 'wss://relay.skate-spots.org';
|
||||||
|
|
||||||
|
if (!existsSync(join(distDir, 'index.html'))) {
|
||||||
|
console.error('[cordn-probe] dist/index.html missing — run `npm run build` first.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (!existsSync(chromePath)) {
|
||||||
|
console.error(`[cordn-probe] Chromium not found at ${chromePath}. Set CHROME_PATH.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the lazy chunk that carries the native Cordn module (the barrel chunk —
|
||||||
|
// it contains the coordinator method strings and the store factory).
|
||||||
|
const assets = (await readdir(assetsDir)).filter((f) => f.endsWith('.js'));
|
||||||
|
let nativeChunk = null;
|
||||||
|
for (const f of assets) {
|
||||||
|
const text = await readFile(join(assetsDir, f), 'utf8');
|
||||||
|
if (text.includes('kp_publish') && text.includes('createCordnStore')) {
|
||||||
|
nativeChunk = f;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!nativeChunk) {
|
||||||
|
console.error('[cordn-probe] could not find the native Cordn chunk in dist/assets (build it, and make sure something imports src/lib/cordn/native).');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
log(`native chunk: ${nativeChunk}`);
|
||||||
|
|
||||||
|
const MIME = {
|
||||||
|
'.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript',
|
||||||
|
'.css': 'text/css', '.json': 'application/json', '.webmanifest': 'application/manifest+json',
|
||||||
|
'.png': 'image/png', '.svg': 'image/svg+xml', '.ico': 'image/x-icon',
|
||||||
|
'.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.wasm': 'application/wasm',
|
||||||
|
};
|
||||||
|
|
||||||
|
const freePort = () => new Promise((res) => {
|
||||||
|
const s = net.createServer();
|
||||||
|
s.on('error', () => res(0));
|
||||||
|
s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => res(p)); });
|
||||||
|
});
|
||||||
|
|
||||||
|
const servePort = await freePort();
|
||||||
|
const server = createServer(async (req, res) => {
|
||||||
|
try {
|
||||||
|
const urlPath = decodeURIComponent((req.url || '/').split('?')[0]);
|
||||||
|
let filePath = join(distDir, normalize(urlPath).replace(/^(\.\.[/\\])+/, ''));
|
||||||
|
if (urlPath === '/' || !extname(filePath)) filePath = join(distDir, 'index.html');
|
||||||
|
let body;
|
||||||
|
try { body = await readFile(filePath); }
|
||||||
|
catch { filePath = join(distDir, 'index.html'); body = await readFile(filePath); }
|
||||||
|
res.writeHead(200, { 'Content-Type': MIME[extname(filePath)] || 'application/octet-stream' });
|
||||||
|
res.end(body);
|
||||||
|
} catch {
|
||||||
|
res.writeHead(500); res.end('error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await new Promise((r) => server.listen(servePort, '127.0.0.1', r));
|
||||||
|
const appUrl = `http://127.0.0.1:${servePort}/`;
|
||||||
|
|
||||||
|
const cdpPort = await freePort();
|
||||||
|
const profileDir = join(tmpdir(), `skatespots-cordn-probe-${process.pid}-${cdpPort}`);
|
||||||
|
const chrome = spawn(chromePath, [
|
||||||
|
'--headless=new', '--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage',
|
||||||
|
'--disable-extensions', '--no-first-run', '--no-default-browser-check',
|
||||||
|
`--user-data-dir=${profileDir}`, `--remote-debugging-port=${cdpPort}`, appUrl,
|
||||||
|
], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||||
|
chrome.stderr.on('data', () => {});
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
try { chrome.kill('SIGKILL'); } catch {}
|
||||||
|
try { server.close(); } catch {}
|
||||||
|
try { rmSync(profileDir, { recursive: true, force: true }); } catch {}
|
||||||
|
};
|
||||||
|
process.on('exit', cleanup);
|
||||||
|
|
||||||
|
async function findPageTarget() {
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
try {
|
||||||
|
const list = await (await fetch(`http://127.0.0.1:${cdpPort}/json`)).json();
|
||||||
|
const p = list.find((t) => t.type === 'page' && t.url.startsWith('http'));
|
||||||
|
if (p) return p;
|
||||||
|
} catch {}
|
||||||
|
await sleep(300);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = await findPageTarget();
|
||||||
|
if (!target) { console.error('[cordn-probe] FAIL: no Chromium page target'); cleanup(); process.exit(1); }
|
||||||
|
|
||||||
|
const ws = new WebSocket(target.webSocketDebuggerUrl, { maxPayload: 64 * 1024 * 1024 });
|
||||||
|
let id = 0; const pend = new Map(); const exceptions = [];
|
||||||
|
ws.on('message', (d) => {
|
||||||
|
const m = JSON.parse(d);
|
||||||
|
if (m.id && pend.has(m.id)) { pend.get(m.id)(m); pend.delete(m.id); return; }
|
||||||
|
if (m.method === 'Runtime.exceptionThrown') {
|
||||||
|
const e = m.params.exceptionDetails;
|
||||||
|
exceptions.push(e.exception?.description || e.text || 'unknown exception');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const send = (method, params = {}) => new Promise((res) => { const i = ++id; pend.set(i, res); ws.send(JSON.stringify({ id: i, method, params })); });
|
||||||
|
await new Promise((r) => ws.on('open', r));
|
||||||
|
await send('Runtime.enable');
|
||||||
|
await send('Page.enable');
|
||||||
|
|
||||||
|
// Wait for the app to mount so the module graph is warm.
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
const kids = (await send('Runtime.evaluate', { expression: 'document.getElementById("root")?.children.length ?? 0', returnByValue: true }))?.result?.result?.value;
|
||||||
|
if (kids > 0) break;
|
||||||
|
await sleep(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
const privateKeyHex = randomBytes(32).toString('hex');
|
||||||
|
const accountKey = `probe-${Date.now()}`;
|
||||||
|
const groupId = `browser-probe-${Date.now()}`;
|
||||||
|
|
||||||
|
const probe = `(async () => {
|
||||||
|
const ns = await import('/assets/${nativeChunk}');
|
||||||
|
const m = ns.CordnSession ? ns : (ns.a || ns.default || ns);
|
||||||
|
const store = m.createCordnStore('${accountKey}');
|
||||||
|
const session = await m.CordnSession.open({
|
||||||
|
coordinator: { serverPubkey: '${COORDINATOR_PUBKEY}', relays: ['${COORDINATOR_RELAY}'] },
|
||||||
|
privateKeyHex: '${privateKeyHex}',
|
||||||
|
store,
|
||||||
|
});
|
||||||
|
await session.generateKeyPackage('probe');
|
||||||
|
await session.publishKeyPackage('probe');
|
||||||
|
const group = await session.createGroup({ groupId: '${groupId}', name: 'Browser probe' });
|
||||||
|
await session.send(group.gid, 'hello from headless chromium');
|
||||||
|
const local = session.messages(group.gid).map((x) => x.content);
|
||||||
|
await session.disconnect();
|
||||||
|
|
||||||
|
// Reopen with the same IndexedDB store: the group must restore.
|
||||||
|
const reopened = await m.CordnSession.open({
|
||||||
|
coordinator: { serverPubkey: '${COORDINATOR_PUBKEY}', relays: ['${COORDINATOR_RELAY}'] },
|
||||||
|
privateKeyHex: '${privateKeyHex}',
|
||||||
|
store: m.createCordnStore('${accountKey}'),
|
||||||
|
});
|
||||||
|
const restored = reopened.listGroups().length;
|
||||||
|
const restoredMsgs = reopened.listGroups()[0] ? reopened.messages(reopened.listGroups()[0].gid).length : 0;
|
||||||
|
await reopened.disconnect();
|
||||||
|
return { ok: true, pubkey: session.pubkey, gid: group.gid, local, restored, restoredMsgs };
|
||||||
|
})()`;
|
||||||
|
|
||||||
|
const result = await send('Runtime.evaluate', { expression: probe, returnByValue: true, awaitPromise: true });
|
||||||
|
const value = result?.result?.result?.value;
|
||||||
|
const thrown = result?.result?.exceptionDetails;
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
|
||||||
|
if (thrown) {
|
||||||
|
console.error('[cordn-probe] FAIL — the browser threw:');
|
||||||
|
console.error(' ' + (thrown.exception?.description || thrown.text));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (exceptions.length > 0) {
|
||||||
|
console.error('[cordn-probe] FAIL — uncaught exceptions in the page:');
|
||||||
|
for (const e of exceptions) console.error(' • ' + e);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (!value?.ok) {
|
||||||
|
console.error('[cordn-probe] FAIL — unexpected result:', JSON.stringify(value));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`PASS — native stack ran in Chromium (${COORDINATOR_RELAY})`);
|
||||||
|
log(` pubkey: ${value.pubkey}`);
|
||||||
|
log(` group: ${value.gid}`);
|
||||||
|
log(` sent locally: ${JSON.stringify(value.local)}`);
|
||||||
|
log(` restored (IDB): ${value.restored} group(s), ${value.restoredMsgs} message(s)`);
|
||||||
|
process.exit(0);
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { KeyRound, Lock, Plus, RefreshCw, Send, UserPlus } from 'lucide-react';
|
import { KeyRound, Lock, Plus, RefreshCw, Send, UserPlus, Copy, Check, ImagePlus, Loader2 } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { ImageWithFallback } from '@/components/ImageWithFallback';
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||||
import { useCordnNative } from '@/hooks/useCordnNative';
|
import { useCordnNative } from '@/hooks/useCordnNative';
|
||||||
import { shortNpub } from '@/lib/coordinator';
|
import { shortNpub } from '@/lib/coordinator';
|
||||||
import { formatDistanceToNow } from '@/lib/dateFnsI18n';
|
import { formatDistanceToNow } from '@/lib/dateFnsI18n';
|
||||||
|
|
@ -30,7 +32,10 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const native = useCordnNative({ crew, enabled: !!user && isMember });
|
const native = useCordnNative({ crew, enabled: !!user && isMember });
|
||||||
const [draft, setDraft] = useState('');
|
const [draft, setDraft] = useState('');
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const { mutateAsync: uploadFile, isPending: uploading } = useUploadFile();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
|
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
|
||||||
|
|
@ -43,6 +48,34 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
|
||||||
await native.send(body);
|
await native.send(body);
|
||||||
}, [draft, native]);
|
}, [draft, native]);
|
||||||
|
|
||||||
|
const onPickImage = useCallback(
|
||||||
|
async (file: File | undefined) => {
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const tags = await uploadFile(file);
|
||||||
|
const url = tags[0]?.[1];
|
||||||
|
if (!url) throw new Error('upload returned no url');
|
||||||
|
const caption = draft.trim();
|
||||||
|
setDraft('');
|
||||||
|
await native.send(caption, url);
|
||||||
|
} catch {
|
||||||
|
// The hook surfaces nothing on failure; keep the composer usable.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[uploadFile, draft, native],
|
||||||
|
);
|
||||||
|
|
||||||
|
const copyRef = useCallback(async () => {
|
||||||
|
if (!native.groupRef) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(native.groupRef);
|
||||||
|
setCopied(true);
|
||||||
|
window.setTimeout(() => setCopied(false), 1500);
|
||||||
|
} catch {
|
||||||
|
// clipboard unavailable — the ref is still visible to select.
|
||||||
|
}
|
||||||
|
}, [native.groupRef]);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return <Notice icon={<Lock className="h-5 w-5" />} text={t('cordnUi.notLoggedIn')} />;
|
return <Notice icon={<Lock className="h-5 w-5" />} text={t('cordnUi.notLoggedIn')} />;
|
||||||
}
|
}
|
||||||
|
|
@ -147,13 +180,21 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'max-w-[85%] whitespace-pre-wrap break-words rounded-2xl px-3 py-1.5 text-sm',
|
'max-w-[85%] space-y-1 overflow-hidden rounded-2xl px-3 py-1.5 text-sm',
|
||||||
m.direction === 'outbound'
|
m.direction === 'outbound'
|
||||||
? 'bg-teal-500/20 text-teal-100'
|
? 'bg-teal-500/20 text-teal-100'
|
||||||
: 'bg-white/10 text-gray-100',
|
: 'bg-white/10 text-gray-100',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{m.content}
|
{m.imageUrl && (
|
||||||
|
<ImageWithFallback
|
||||||
|
src={m.imageUrl}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="max-h-60 w-full rounded-lg object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{m.content && <div className="whitespace-pre-wrap break-words">{m.content}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
|
@ -161,6 +202,25 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
void onPickImage(e.target.files?.[0]);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
disabled={uploading || native.busy}
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
aria-label={t('cordnUi.attachImage')}
|
||||||
|
>
|
||||||
|
{uploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <ImagePlus className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
<Input
|
<Input
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
|
@ -191,6 +251,24 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
{native.interactiveHintVisible && (
|
{native.interactiveHintVisible && (
|
||||||
<p className="text-[11px] text-gray-500">{t('cordnUi.interactiveHint')}</p>
|
<p className="text-[11px] text-gray-500">{t('cordnUi.interactiveHint')}</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -54,8 +54,8 @@ export interface UseCordnNativeResult {
|
||||||
messages: CordnChatMessage[];
|
messages: CordnChatMessage[];
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
/** Send a group message. */
|
/** Send a group message (optionally an image URL, encrypted in the payload). */
|
||||||
send: (text: string) => Promise<void>;
|
send: (text: string, imageUrl?: string) => Promise<void>;
|
||||||
/** Re-fetch messages (and adopt any new commits). */
|
/** Re-fetch messages (and adopt any new commits). */
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
/** Founder/admin: create the crew's MLS group. */
|
/** Founder/admin: create the crew's MLS group. */
|
||||||
|
|
@ -71,6 +71,8 @@ export interface UseCordnNativeResult {
|
||||||
approve: (identifier: string) => Promise<void>;
|
approve: (identifier: string) => Promise<void>;
|
||||||
/** True for interactive signers (Amber) — manual refresh/send, no polling. */
|
/** True for interactive signers (Amber) — manual refresh/send, no polling. */
|
||||||
interactiveHintVisible: boolean;
|
interactiveHintVisible: boolean;
|
||||||
|
/** Shareable `cordn1…` reference for the crew's group (once known). */
|
||||||
|
groupRef: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const POLL_MS = 6000;
|
const POLL_MS = 6000;
|
||||||
|
|
@ -112,6 +114,7 @@ export function useCordnNative({
|
||||||
const [requests, setRequests] = useState<JoinRequest[]>([]);
|
const [requests, setRequests] = useState<JoinRequest[]>([]);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [groupRef, setGroupRef] = useState<string | null>(null);
|
||||||
|
|
||||||
const interactive = isInteractiveSigner(user, logins);
|
const interactive = isInteractiveSigner(user, logins);
|
||||||
|
|
||||||
|
|
@ -124,14 +127,27 @@ export function useCordnNative({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled || !pubkey || !signer || !coordinator || !groupId) return;
|
if (!enabled || !pubkey || !signer || !coordinator || !groupId) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const session = import('@/lib/cordn/native').then(({ CordnSession: NativeSession }) =>
|
const session = import('@/lib/cordn/native').then((mod) => {
|
||||||
NativeSession.open({
|
if (!cancelled) {
|
||||||
|
try {
|
||||||
|
setGroupRef(
|
||||||
|
mod.encodeGroupRef({
|
||||||
|
gid: groupId,
|
||||||
|
coordinatorPubkey: coordinator.serverPubkey,
|
||||||
|
relays: coordinator.relays,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setGroupRef(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mod.CordnSession.open({
|
||||||
coordinator: { serverPubkey: coordinator.serverPubkey, relays: coordinator.relays },
|
coordinator: { serverPubkey: coordinator.serverPubkey, relays: coordinator.relays },
|
||||||
pubkey,
|
pubkey,
|
||||||
signer: signer as unknown as NostrSigner,
|
signer: signer as unknown as NostrSigner,
|
||||||
store: createCordnStore(pubkey),
|
store: createCordnStore(pubkey),
|
||||||
}),
|
});
|
||||||
);
|
});
|
||||||
session
|
session
|
||||||
.then((opened) => {
|
.then((opened) => {
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
|
|
@ -152,6 +168,7 @@ export function useCordnNative({
|
||||||
const s = sessionRef.current;
|
const s = sessionRef.current;
|
||||||
sessionRef.current = null;
|
sessionRef.current = null;
|
||||||
setReady(false);
|
setReady(false);
|
||||||
|
setGroupRef(null);
|
||||||
if (s) void s.disconnect();
|
if (s) void s.disconnect();
|
||||||
};
|
};
|
||||||
}, [enabled, pubkey, signer, coordinator, groupId]);
|
}, [enabled, pubkey, signer, coordinator, groupId]);
|
||||||
|
|
@ -193,12 +210,12 @@ export function useCordnNative({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const send = useCallback(
|
const send = useCallback(
|
||||||
async (text: string) => {
|
async (text: string, imageUrl?: string) => {
|
||||||
const body = text.trim();
|
const body = text.trim();
|
||||||
const session = sessionRef.current;
|
const session = sessionRef.current;
|
||||||
if (!body || !session || !group) return;
|
if ((!body && !imageUrl) || !session || !group) return;
|
||||||
await run(async () => {
|
await run(async () => {
|
||||||
await session.send(group.gid, body);
|
await session.send(group.gid, body, imageUrl);
|
||||||
setMessages(session.messages(group.gid));
|
setMessages(session.messages(group.gid));
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
@ -276,5 +293,6 @@ export function useCordnNative({
|
||||||
loadRequests,
|
loadRequests,
|
||||||
approve,
|
approve,
|
||||||
interactiveHintVisible: interactive,
|
interactiveHintVisible: interactive,
|
||||||
|
groupRef,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,12 @@ const cordnUi = {
|
||||||
noRequests: 'No pending join requests.',
|
noRequests: 'No pending join requests.',
|
||||||
memberCount: '{{count}} in the group',
|
memberCount: '{{count}} in the group',
|
||||||
you: 'You',
|
you: 'You',
|
||||||
|
groupRef: 'Group link',
|
||||||
|
copyRef: 'Copy',
|
||||||
|
copied: 'Copied',
|
||||||
|
attachImage: 'Attach image',
|
||||||
|
uploading: 'Uploading…',
|
||||||
|
uploadFailed: 'Couldn’t upload the image.',
|
||||||
ready: 'Connected · MLS',
|
ready: 'Connected · MLS',
|
||||||
errorPrefix: 'Couldn’t reach the coordinator',
|
errorPrefix: 'Couldn’t reach the coordinator',
|
||||||
interactiveHint:
|
interactiveHint:
|
||||||
|
|
@ -57,6 +63,12 @@ const cordnUi = {
|
||||||
noRequests: 'No hay solicitudes pendientes.',
|
noRequests: 'No hay solicitudes pendientes.',
|
||||||
memberCount: '{{count}} en el grupo',
|
memberCount: '{{count}} en el grupo',
|
||||||
you: 'Tú',
|
you: 'Tú',
|
||||||
|
groupRef: 'Enlace del grupo',
|
||||||
|
copyRef: 'Copiar',
|
||||||
|
copied: 'Copiado',
|
||||||
|
attachImage: 'Adjuntar imagen',
|
||||||
|
uploading: 'Subiendo…',
|
||||||
|
uploadFailed: 'No se pudo subir la imagen.',
|
||||||
ready: 'Conectado · MLS',
|
ready: 'Conectado · MLS',
|
||||||
errorPrefix: 'No se pudo contactar con el coordinador',
|
errorPrefix: 'No se pudo contactar con el coordinador',
|
||||||
interactiveHint:
|
interactiveHint:
|
||||||
|
|
|
||||||
|
|
@ -46,3 +46,6 @@ export {
|
||||||
type CordnMlsIdentity,
|
type CordnMlsIdentity,
|
||||||
type CordnKeyPackage,
|
type CordnKeyPackage,
|
||||||
} from './mls';
|
} from './mls';
|
||||||
|
|
||||||
|
/** Build the shareable `cordn1…` reference for a group (Cordn wire format). */
|
||||||
|
export { encodeGroupRef, type GroupRef } from '@cordn/core';
|
||||||
|
|
|
||||||
|
|
@ -389,10 +389,16 @@ export async function createGroupApplicationMessage(params: {
|
||||||
sender: string;
|
sender: string;
|
||||||
content: string;
|
content: string;
|
||||||
tags?: string[][];
|
tags?: string[][];
|
||||||
|
/** Optional image URL, carried inside the encrypted envelope. */
|
||||||
|
imageUrl?: string;
|
||||||
}): Promise<{ newState: ClientState; messageBase64: string; envelope: CordnEnvelope }> {
|
}): Promise<{ newState: ClientState; messageBase64: string; envelope: CordnEnvelope }> {
|
||||||
const cipherSuite = await getCipherSuite();
|
const cipherSuite = await getCipherSuite();
|
||||||
|
const tags = [
|
||||||
|
...(params.tags ?? []),
|
||||||
|
...(params.imageUrl ? [['image', params.imageUrl]] : []),
|
||||||
|
];
|
||||||
const envelope = finalizeEnvelope(
|
const envelope = finalizeEnvelope(
|
||||||
createEnvelope({ pubkey: params.sender, content: params.content, tags: params.tags }),
|
createEnvelope({ pubkey: params.sender, content: params.content, tags }),
|
||||||
);
|
);
|
||||||
const result = await createApplicationMessage({
|
const result = await createApplicationMessage({
|
||||||
context: { cipherSuite, authService: unsafeTestingAuthenticationService },
|
context: { cipherSuite, authService: unsafeTestingAuthenticationService },
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,14 @@ describe.skipIf(!live)('cordn native (live)', () => {
|
||||||
expect(receivedByB.map((m) => m.content)).toContain('hello from A');
|
expect(receivedByB.map((m) => m.content)).toContain('hello from A');
|
||||||
expect(receivedByB.find((m) => m.content === 'hello from A')?.sender).toBe(a.pubkey);
|
expect(receivedByB.find((m) => m.content === 'hello from A')?.sender).toBe(a.pubkey);
|
||||||
|
|
||||||
|
// An image message: the URL travels inside the encrypted envelope.
|
||||||
|
const imageUrl = 'https://blossom.skate-spots.org/abc123.png';
|
||||||
|
await a.send(group.gid, 'look at this', imageUrl);
|
||||||
|
const withImage = await b.fetch(bGroup.gid);
|
||||||
|
const imageMsg = withImage.find((m) => m.imageUrl);
|
||||||
|
expect(imageMsg?.imageUrl).toBe(imageUrl);
|
||||||
|
expect(imageMsg?.content).toBe('look at this');
|
||||||
|
|
||||||
await b.send(bGroup.gid, 'hi from B');
|
await b.send(bGroup.gid, 'hi from B');
|
||||||
const receivedByA = await a.fetch(group.gid);
|
const receivedByA = await a.fetch(group.gid);
|
||||||
expect(receivedByA.map((m) => m.content)).toContain('hi from B');
|
expect(receivedByA.map((m) => m.content)).toContain('hi from B');
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,8 @@ export interface CordnChatMessage {
|
||||||
/** Proven MLS sender pubkey (from the MLS authenticated data). */
|
/** Proven MLS sender pubkey (from the MLS authenticated data). */
|
||||||
sender: string;
|
sender: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
/** Optional image URL (decrypted from the envelope's `image` tag). */
|
||||||
|
imageUrl?: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
cursor: number;
|
cursor: number;
|
||||||
direction: 'inbound' | 'outbound';
|
direction: 'inbound' | 'outbound';
|
||||||
|
|
@ -469,12 +471,13 @@ export class CordnSession {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Encrypt, post and locally adopt an application message. */
|
/** Encrypt, post and locally adopt an application message. */
|
||||||
async send(gid: string, content: string): Promise<CordnChatMessage> {
|
async send(gid: string, content: string, imageUrl?: string): Promise<CordnChatMessage> {
|
||||||
const group = this.requireGroup(gid);
|
const group = this.requireGroup(gid);
|
||||||
const { newState, messageBase64, envelope } = await createGroupApplicationMessage({
|
const { newState, messageBase64, envelope } = await createGroupApplicationMessage({
|
||||||
state: group.state,
|
state: group.state,
|
||||||
sender: this.identity.pubkey,
|
sender: this.identity.pubkey,
|
||||||
content,
|
content,
|
||||||
|
imageUrl,
|
||||||
tags: causalPrevTags(
|
tags: causalPrevTags(
|
||||||
group.messages.map((m) => m.id),
|
group.messages.map((m) => m.id),
|
||||||
group.messages.map((m) => m.prev),
|
group.messages.map((m) => m.prev),
|
||||||
|
|
@ -488,6 +491,7 @@ export class CordnSession {
|
||||||
id: envelope.id ?? '',
|
id: envelope.id ?? '',
|
||||||
sender: this.identity.pubkey,
|
sender: this.identity.pubkey,
|
||||||
content,
|
content,
|
||||||
|
imageUrl,
|
||||||
createdAt: envelope.created_at,
|
createdAt: envelope.created_at,
|
||||||
cursor: posted.cursor,
|
cursor: posted.cursor,
|
||||||
direction: 'outbound',
|
direction: 'outbound',
|
||||||
|
|
@ -524,6 +528,7 @@ export class CordnSession {
|
||||||
id: envelope.id ?? message.msg_64,
|
id: envelope.id ?? message.msg_64,
|
||||||
sender,
|
sender,
|
||||||
content: envelope.content,
|
content: envelope.content,
|
||||||
|
imageUrl: envelope.tags.find((tag) => tag[0] === 'image')?.[1],
|
||||||
createdAt: envelope.created_at,
|
createdAt: envelope.created_at,
|
||||||
cursor: message.cursor,
|
cursor: message.cursor,
|
||||||
direction: sender === this.identity.pubkey ? 'outbound' : 'inbound',
|
direction: sender === this.identity.pubkey ? 'outbound' : 'inbound',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue