feat: restore map footer, inline crew roles, Cordn reactions/push

- Keep the map footer links permanently visible; remove the hide toggle.
- Publish inline founder promote/demote from the member row.
- Add encrypted MLS reaction/delete/read control messages and UI.
- Add the minute Cordn join watcher: NIP-17 DMs + encrypted bell notices.
- Add the browser translation-matrix runner and a low-end performance finding.
This commit is contained in:
SkateSpots 2026-10-28 11:02:47 +07:00
parent d230f02328
commit 66fc47cc28
43 changed files with 1459 additions and 83 deletions

View file

@ -1542,19 +1542,38 @@ session with **no other memory of this project**. So:
it (`QRScannerModal`), calls `addMemberByIdentifier(gid, kpRef)`, and the new
device's `checkInvite` accepts the Welcome — no join request/founder round
trip. Member leaves can be removed by `leafIndex` (`removeMemberFromGroup` →
`createCommit` with `defaultProposalTypes.remove`); the founder gets an
**Edit Crew** link for admin-role changes. Messages have copy/reply actions
(reply is quoted text; reactions/delete are still open). Live benchmark
(`scripts/cordn-benchmark.ts`, 30 msgs): ~489ms/send, ~20ms/fetch, snapshot
~13.8KB (~460 B/message) — fine for hundreds of messages.
`createCommit` with `defaultProposalTypes.remove`). **Inline promote/demote
(2026-10-28):** the founder gets a shield button on each non-founder member row;
it republishes the **existing crew event tags** with the target's `p` tag
switched between plain and `admin` (only the founder can do this — an admin's
republish would fork the crew). Messages have copy/reply, **reactions
(👍/❤️/🔥/😂)**, **delete-for-everyone**, and **read receipts**. These are
encrypted MLS **control messages** (empty content + `reaction`/`unreact`/
`delete`/`read` tags) sent via `sendControl`; `useCordnNative` filters them out
of the visible list and derives reaction/read maps. Delete is a local
tombstone, not a relay hard-delete; automatic read receipts are silent-signer
only (Amber would prompt). Live benchmark (`scripts/cordn-benchmark.ts`, 30
msgs): ~489ms/send, ~20ms/fetch, snapshot ~13.8KB (~460 B/message).
- **Cordn join-request push (2026-10-28):** `scripts/cordn-join-watcher.ts`
runs every minute (user timer) and polls the coordinator for known crew gids.
New requests become (a) a **NIP-17 DM** to the founder/admins (push service
wakes the phone) and (b) an **encrypted NIP-78** notice (`t=cordnjoinrequest`,
authored by the dedicated watcher key `82ce71cf…`). `useCordnJoinNotifications`
decrypts it; the bell's Crews/All tabs show a `CordnJoinRequestCard` with
Review/Dismiss, and count it by `seenAt`. Key `~/.config/skatespots/cordn-watcher.env`
(600); state `~/.local/state/skatespots-cordn-watcher/state.json`. Crews with a
**custom coordinator** are skipped (only the SkateSpots embedded coordinator is
queried); approved requests leave the coordinator but the bell card remains
until dismissed.
- **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
(Newsletter/License/Privacy/Collaborators + "not sponsored by OpenSats") as an
overlay inside the map container: a **slim full-width bar on desktop** and a
**tiny collapsed "Links" chip on mobile** (expands to a compact scrollable row).
Dismissible via `useMapFooterHidden` (`skatespots:map-footer-hidden`, same-tab
synced), restorable in **Settings → Appearance**. The map's OpenStreetMap
attribution moved to `md:bottom-7` so the bar doesn't cover it.
(DIY/Newsletter/License/Privacy + "not sponsored by OpenSats") as an overlay
inside the map container: a **slim full-width bar on desktop** and a **tiny
collapsed "Links" chip on mobile** (expands to a compact scrollable row).
**Always visible (2026-10-28):** the old dismissible `useMapFooterHidden`
behavior/setting was removed — it was easy to hide the only footer on the map
forever and then report it missing. The map's OpenStreetMap attribution moved
to `md:bottom-7` so the bar doesn't cover it.
- **Map layout — controls float (2026-09-29):** on mobile the `MapPage` toolbar
is **`absolute` over the map** (`pointer-events-none` wrapper + gradient +
`pointer-events-auto` inner) so the map fills the whole container; `md:static`
@ -2627,6 +2646,15 @@ Rules:
- **Conversion:** Docker Python 3.11 + torch 2.4.1 + transformers 4.46.3 +
optimum 1.24; dynamic q8; `tokenizer.json` generated from the SentencePiece
models (needs protobuf + `sentencepiece_model_pb2.py`).
- **Browser matrix runner (2026-10-28):** `scripts/translation-matrix.mjs`
(`npm run translation:matrix`) serves `dist` and drives the built
`translate.worker` in headless Chromium; `MATRIX_LANGUAGES=es,ca` limits it.
It writes `/tmp/opencode/translation-matrix.json` and a per-language partial.
First runs on this box: en↔es **~54s**, en↔ca **~21s** (model download
included for es); Basque downloads did not finish in a 25-minute run. This is a
real performance finding, not just missing test coverage: the 617 MB M2M100
single-threaded WASM is too slow for low-end phones. Low-end/APK verification
still needs a device (and likely a smaller model or native ONNX).
- **Still to do:** verify all 24 pairs in the browser/APK and measure
speed/memory on low-end phones. **1.7.2 and 1.7.3 are released** (the phone
needs the Zapstore update; the web deploy timer batches for 24h unless forced).

View file

@ -27,7 +27,8 @@
"android:version": "node scripts/sync-android-version.mjs",
"android:build": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs && cd android && ./gradlew assembleRelease",
"android:build:apk": "CAPACITOR_BUILD=1 vite build && sed -i 's|<script type=\"module\" src=\"/shakespeare_tailwind[^\"]*\\.js\"></script>||g' dist/index.html && sed -i 's|<style type=\"text/tailwindcss\">|<style>|g' dist/index.html && cp dist/index.html dist/404.html",
"newsletter:weekly": "tsx scripts/newsletter-weekly.ts"
"newsletter:weekly": "tsx scripts/newsletter-weekly.ts",
"translation:matrix": "node scripts/translation-matrix.mjs"
},
"dependencies": {
"@bitcoinerlab/secp256k1": "^1.2.0",

10
scripts/cordn-join-watcher.sh Executable file
View file

@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Background watcher for Cordn private-chat join requests. Runs from the local
# repo so it can reach the coordinator and send NIP-17/NIP-78 notifications.
set -euo pipefail
cd "$(dirname "$0")/.."
if [ -d "$HOME/.nvm/versions/node" ]; then
NODE_BIN="$(ls -d "$HOME"/.nvm/versions/node/*/bin 2>/dev/null | sort -V | tail -1)"
[ -n "$NODE_BIN" ] && export PATH="$NODE_BIN:$PATH"
fi
exec npx tsx scripts/cordn-join-watcher.ts

View file

@ -0,0 +1,361 @@
/**
* Cordn join-request watcher.
*
* Coordinator join requests are RPC rows, not Nostr events. This small always-on
* job turns each new request into:
* 1. a NIP-17 DM for OS/web push (the app's push service marks `skatespots`),
* 2. an encrypted NIP-78 notification for the in-app bell.
*
* It uses a dedicated watcher key (no admin key), only reads coordinator
* metadata + public crew events, and never sees message plaintext.
*
* Run: npx tsx scripts/cordn-join-watcher.ts
*/
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import WebSocket from 'ws';
import {
finalizeEvent,
generateSecretKey,
getPublicKey,
nip19,
} from 'nostr-tools';
import * as nip44 from 'nostr-tools/nip44';
import * as nip59 from 'nostr-tools/nip59';
import { SimplePool, useWebSocketImplementation } from 'nostr-tools/pool';
import { CordnCoordinatorClient } from '../src/lib/cordn/native/coordinator';
import type { FetchManyPendingJoinRequestsOutput } from '@cordn/core';
import { SKATESPOTS_COORDINATOR } from '../src/lib/coordinator';
import {
CORDN_JOIN_D_PREFIX,
CORDN_JOIN_TAG,
CORDN_WATCHER_PUBKEY,
} from '../src/lib/cordn/watch';
(globalThis as unknown as { WebSocket?: unknown }).WebSocket = WebSocket;
useWebSocketImplementation(WebSocket as never);
const COMMUNITY = 'wss://relay.skate-spots.org';
const DISCOVERY = [
'wss://purplepag.es',
'wss://relay.ditto.pub',
'wss://relay.skate-spots.org',
'wss://nos.lol',
];
const CREW_KIND = 35006;
const DM_KIND = 14;
const GIFT_WRAP_KIND = 1059;
const PUSH_MARKER = 'skatespots';
interface Crew {
gid: string;
name: string;
pubkey: string;
identifier: string;
recipients: string[];
}
interface WatcherState {
seen: Record<string, number>;
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
function readWatcherSecret(): Uint8Array {
const file = join(homedir(), '.config/skatespots/cordn-watcher.env');
const text = readFileSync(file, 'utf8');
const nsec = text.match(/^CORDN_WATCHER_NSEC=(nsec1[0-9a-z]+)$/m)?.[1];
if (!nsec) throw new Error(`CORDN_WATCHER_NSEC not found in ${file}`);
const decoded = nip19.decode(nsec);
if (decoded.type !== 'nsec') throw new Error('CORDN_WATCHER_NSEC is not an nsec');
const pubkey = getPublicKey(decoded.data);
if (pubkey !== CORDN_WATCHER_PUBKEY) {
throw new Error('watcher key does not match src/lib/cordn/watch.ts');
}
return decoded.data;
}
function statePath(): string {
const dir = join(homedir(), '.local', 'state', 'skatespots-cordn-watcher');
mkdirSync(dir, { recursive: true });
return join(dir, 'state.json');
}
function loadState(): WatcherState {
const path = statePath();
if (!existsSync(path)) return { seen: {} };
try {
const parsed = JSON.parse(readFileSync(path, 'utf8')) as Partial<WatcherState>;
return { seen: parsed.seen ?? {} };
} catch {
return { seen: {} };
}
}
function saveState(state: WatcherState): void {
const cutoff = Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60;
for (const [key, at] of Object.entries(state.seen)) {
if (at < cutoff) delete state.seen[key];
}
writeFileSync(statePath(), JSON.stringify(state, null, 2), { mode: 0o600 });
}
function randomPastTimestamp(): number {
return Math.floor(Date.now() / 1000) - Math.floor(Math.random() * 2 * 24 * 60 * 60);
}
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return Promise.race([
promise,
new Promise<T>((_, reject) => setTimeout(() => reject(new Error('timeout')), ms)),
]);
}
function shortNpub(pubkey: string): string {
try {
const npub = nip19.npubEncode(pubkey);
return `${npub.slice(0, 12)}…${npub.slice(-6)}`;
} catch {
return pubkey.slice(0, 12);
}
}
function crewFromEvent(event: {
pubkey: string;
tags: string[][];
}): Crew | null {
const d = event.tags.find(([name]) => name === 'd')?.[1];
const name = event.tags.find(([name]) => name === 'title')?.[1];
if (!d || !name) return null;
const coordinatorTag = event.tags.find(([name]) => name === 'coordinator');
const coordinatorPubkey = coordinatorTag?.[2];
if (coordinatorPubkey && coordinatorPubkey !== SKATESPOTS_COORDINATOR.pubkey) return null;
const admins = event.tags
.filter(([name, value, marker]) => name === 'p' && /^[0-9a-f]{64}$/i.test(value ?? '') && marker === 'admin')
.map(([, value]) => value.toLowerCase());
const recipients = [...new Set([event.pubkey.toLowerCase(), ...admins])];
return {
gid: `${CREW_KIND}:${event.pubkey}:${d}`,
name,
pubkey: event.pubkey,
identifier: d,
recipients,
};
}
async function discoverDmRelays(pool: SimplePool, recipients: string[]): Promise<string[]> {
const relays = new Set<string>([COMMUNITY]);
const [inboxes, relayLists] = await Promise.all([
pool
.querySync(
DISCOVERY,
{ kinds: [10050], authors: recipients, limit: recipients.length * 2 },
{ maxWait: 4000 },
)
.catch(() => []),
pool
.querySync(
DISCOVERY,
{ kinds: [10002], authors: recipients, limit: recipients.length * 2 },
{ maxWait: 4000 },
)
.catch(() => []),
]);
const haveInbox = new Set<string>();
const addRelay = (url: string | undefined) => {
if (!url) return;
relays.add(url.replace(/\/+$/, ''));
};
for (const event of inboxes) {
for (const [name, url] of event.tags) {
if (name === 'relay' && url) {
addRelay(url);
haveInbox.add(event.pubkey);
}
}
}
for (const event of relayLists) {
if (haveInbox.has(event.pubkey)) continue;
for (const [name, url, marker] of event.tags) {
if (name === 'r' && url && (!marker || marker === 'write')) addRelay(url);
}
}
return [...relays];
}
function createDmWrap(params: {
watcherSecret: Uint8Array;
recipient: string;
text: string;
}): ReturnType<typeof finalizeEvent> {
const rumor = nip59.createRumor(
{
kind: DM_KIND,
created_at: Math.floor(Date.now() / 1000),
tags: [['p', params.recipient], ['subject', 'Cordn join request']],
content: params.text,
},
params.watcherSecret,
);
const seal = nip59.createSeal(rumor, params.watcherSecret, params.recipient);
const ephemeral = generateSecretKey();
const conversationKey = nip44.v2.utils.getConversationKey(ephemeral, params.recipient);
const content = nip44.v2.encrypt(JSON.stringify(seal), conversationKey);
return finalizeEvent(
{
kind: GIFT_WRAP_KIND,
created_at: randomPastTimestamp(),
tags: [['p', params.recipient], [PUSH_MARKER, 'push']],
content,
},
ephemeral,
);
}
function encryptedNotice(params: {
watcherSecret: Uint8Array;
recipient: string;
key: string;
payload: Record<string, unknown>;
}): ReturnType<typeof finalizeEvent> {
const conversationKey = nip44.v2.utils.getConversationKey(
params.watcherSecret,
params.recipient,
);
const content = nip44.v2.encrypt(JSON.stringify(params.payload), conversationKey);
const d = `${CORDN_JOIN_D_PREFIX}:${createHash('sha256')
.update(`${params.key}:${params.recipient}`)
.digest('hex')
.slice(0, 32)}`;
return finalizeEvent(
{
kind: 30078,
created_at: Math.floor(Date.now() / 1000),
tags: [
['d', d],
['p', params.recipient],
['t', CORDN_JOIN_TAG],
['alt', 'Cordn join request'],
],
content,
},
params.watcherSecret,
);
}
async function main(): Promise<void> {
const watcherSecret = readWatcherSecret();
const watcherPubkey = getPublicKey(watcherSecret);
const pool = new SimplePool();
const crewEvents = await pool.querySync(
[COMMUNITY],
{ kinds: [CREW_KIND], '#t': ['skatecrew'], limit: 500 },
{ maxWait: 8000 },
);
const crews = crewEvents
.map(crewFromEvent)
.filter((crew): crew is Crew => !!crew && crew.recipients.length > 0);
if (crews.length === 0) {
pool.close([COMMUNITY]);
console.log('[cordn-watcher] no crews found');
return;
}
const coordinator = new CordnCoordinatorClient({
serverPubkey: SKATESPOTS_COORDINATOR.pubkey,
relays: SKATESPOTS_COORDINATOR.relays,
privateKeyHex: Array.from(watcherSecret, (byte) => byte.toString(16).padStart(2, '0')).join(''),
});
let requests: FetchManyPendingJoinRequestsOutput;
try {
requests = await coordinator.fetchManyPendingJoinRequests({
groups: crews.map((crew) => ({ gid: crew.gid })),
});
} finally {
await coordinator.disconnect().catch(() => undefined);
}
if (requests.requests.length === 0) {
pool.close([COMMUNITY]);
console.log('[cordn-watcher] no pending join requests');
return;
}
const byGid = new Map(crews.map((crew) => [crew.gid, crew]));
const state = loadState();
let sent = 0;
for (const request of requests.requests) {
const crew = byGid.get(request.gid);
if (!crew) continue;
const key = `${request.gid}:${request.pk}:${request.kp_ref}`;
if (state.seen[key]) continue;
const atSeconds = request.at > 1e12 ? Math.floor(request.at / 1000) : request.at;
const payload = {
id: key,
gid: request.gid,
crewName: crew.name,
crewPubkey: crew.pubkey,
crewIdentifier: crew.identifier,
requester: request.pk,
at: atSeconds,
};
const text = [
`New private-chat request for ${crew.name}.`,
'',
`Requester: ${shortNpub(request.pk)}`,
'',
`Review: https://skate-spots.org/#/crew/${crew.pubkey}/${crew.identifier}?tab=mls`,
].join('\n');
const relays = await discoverDmRelays(pool, crew.recipients);
let delivered = 0;
for (const recipient of crew.recipients) {
const notice = encryptedNotice({
watcherSecret,
recipient,
key,
payload,
});
const wrap = createDmWrap({ watcherSecret, recipient, text });
const published = await Promise.allSettled([
...pool.publish([COMMUNITY], notice).map((promise) => withTimeout(promise, 10_000)),
...pool.publish(relays, wrap).map((promise) => withTimeout(promise, 10_000)),
]);
if (published.some((result) => result.status === 'fulfilled')) delivered += 1;
}
if (delivered > 0) {
sent += delivered;
state.seen[key] = atSeconds || Math.floor(Date.now() / 1000);
saveState(state);
}
}
pool.close([...new Set([COMMUNITY, ...DISCOVERY])]);
console.log(
JSON.stringify(
{
watcher: nip19.npubEncode(watcherPubkey),
crews: crews.length,
pending: requests.requests.length,
newRequests: sent,
},
null,
2,
),
);
// Keep the timer's journal quiet on a normal idle run.
await sleep(10);
}
main().catch((error) => {
console.error(
`[cordn-watcher] ${error instanceof Error ? error.message : String(error)}`,
);
process.exit(1);
});

View file

@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Install the Cordn join-request watcher (user systemd, every minute).
set -euo pipefail
cd "$(dirname "$0")/.."
mkdir -p "$HOME/.config/systemd/user"
cp scripts/systemd/skatespots-cordn-watcher.service scripts/systemd/skatespots-cordn-watcher.timer "$HOME/.config/systemd/user/"
systemctl --user daemon-reload
systemctl --user enable --now skatespots-cordn-watcher.timer
systemctl --user list-timers skatespots-cordn-watcher.timer --no-pager

View file

@ -0,0 +1,9 @@
[Unit]
Description=Watch Cordn coordinator join requests and notify crew admins
Documentation=file:///home/sovereignsk8/SkateSpots/scripts/cordn-join-watcher.ts
[Service]
Type=oneshot
WorkingDirectory=/home/sovereignsk8/SkateSpots
ExecStart=/home/sovereignsk8/SkateSpots/scripts/cordn-join-watcher.sh
TimeoutStartSec=3min

View file

@ -0,0 +1,10 @@
[Unit]
Description=Run the Cordn join-request watcher every minute
[Timer]
OnBootSec=45
OnUnitActiveSec=60
Persistent=true
[Install]
WantedBy=timers.target

166
scripts/translation-matrix.mjs Executable file
View file

@ -0,0 +1,166 @@
#!/usr/bin/env node
/**
* Browser translation matrix (on-device worker).
*
* Serves the production dist, instantiates the built translate.worker chunk in
* headless Chromium, and round-trips one short sentence through every app
* language (en→xx then xx→en). Verifies the worker returns text and records
* latency. This is a hardware-reality check, not a CI test: it downloads the
* ~617 MB multilingual model on first use.
*
* Usage: node scripts/translation-matrix.mjs
* Output: /tmp/opencode/translation-matrix.json (+ a short summary on stdout)
*/
import { createServer } from 'node:http';
import { spawn } from 'node:child_process';
import { readFile, readdir } from 'node:fs/promises';
import { existsSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, extname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
import { tmpdir } from 'node:os';
import net from 'node:net';
import WebSocket from 'ws';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const dist = join(root, 'dist');
const chromePath = process.env.CHROME_PATH || '/snap/bin/chromium';
const timeoutMs = Number(process.env.MATRIX_TIMEOUT_MS || 30 * 60 * 1000);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const MIME = {
'.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript',
'.css': 'text/css', '.json': 'application/json', '.wasm': 'application/wasm',
'.png': 'image/png', '.svg': 'image/svg+xml', '.woff2': 'font/woff2',
};
const workerFile = (await readdir(join(dist, 'assets'))).find((file) => /^translate\.worker-.*\.js$/.test(file));
if (!workerFile) throw new Error('translate.worker chunk not found — build first');
if (!existsSync(chromePath)) throw new Error(`Chromium not found at ${chromePath}`);
const freePort = () => new Promise((resolve) => {
const s = net.createServer();
s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); });
});
const port = await freePort();
const workerUrl = `http://127.0.0.1:${port}/assets/${workerFile}`;
const server = createServer(async (req, res) => {
const url = decodeURIComponent((req.url || '/').split('?')[0]);
if (url === '/matrix') {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('<!doctype html><meta charset="utf-8"><title>translation matrix</title><body>ready</body>');
return;
}
let file = join(dist, normalize(url).replace(/^(\.\.[/\\])+/, ''));
if (url === '/' || !extname(file)) file = join(dist, 'index.html');
try {
const body = await readFile(file);
res.writeHead(200, { 'Content-Type': MIME[extname(file)] || 'application/octet-stream' });
res.end(body);
} catch {
res.writeHead(404); res.end('not found');
}
});
await new Promise((resolve) => server.listen(port, '127.0.0.1', resolve));
const cdpPort = await freePort();
const profile = join(tmpdir(), `skatespots-matrix-${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',
'--js-flags=--max-old-space-size=4096',
`--user-data-dir=${profile}`, `--remote-debugging-port=${cdpPort}`,
`http://127.0.0.1:${port}/matrix`,
], { stdio: ['ignore', 'ignore', 'pipe'] });
chrome.stderr.on('data', () => {});
const cleanup = () => { try { chrome.kill('SIGKILL'); } catch {} try { server.close(); } catch {} try { rmSync(profile, { recursive: true, force: true }); } catch {} };
process.on('exit', cleanup);
async function pageTarget() {
for (let i = 0; i < 80; i++) {
try {
const list = await (await fetch(`http://127.0.0.1:${cdpPort}/json`)).json();
const page = list.find((t) => t.type === 'page' && t.url.includes('/matrix'));
if (page) return page;
} catch {}
await sleep(300);
}
throw new Error('no Chromium page target');
}
const target = await pageTarget();
const ws = new WebSocket(target.webSocketDebuggerUrl, { maxPayload: 64 * 1024 * 1024 });
let id = 0; const pending = new Map(); const exceptions = [];
ws.on('message', (raw) => {
const message = JSON.parse(raw);
if (message.id && pending.has(message.id)) { pending.get(message.id)(message); pending.delete(message.id); return; }
if (message.method === 'Runtime.exceptionThrown') {
exceptions.push(message.params.exceptionDetails?.exception?.description || message.params.exceptionDetails?.text || 'exception');
}
});
const send = (method, params = {}) => new Promise((resolve) => {
const requestId = ++id; pending.set(requestId, resolve);
ws.send(JSON.stringify({ id: requestId, method, params }));
});
await new Promise((resolve) => ws.on('open', resolve));
await send('Runtime.enable');
await send('Page.enable');
const evalJS = async (expression, awaitPromise = false) => (await send('Runtime.evaluate', {
expression, returnByValue: true, awaitPromise,
}))?.result?.result?.value;
const init = await evalJS(`(() => {
try {
const worker = new Worker(${JSON.stringify(workerUrl)}, { type: 'module' });
window.__matrix = { worker, seq: 0, pending: new Map() };
worker.onerror = (event) => { window.__matrixError = String(event.message || event); };
worker.onmessage = (event) => {
const data = event.data;
if (!data || typeof data.id !== 'number') return;
if (data.type === 'progress') return;
const p = window.__matrix.pending.get(data.id);
if (!p) return;
window.__matrix.pending.delete(data.id);
if (data.type === 'result') p.resolve(data.text);
else p.reject(new Error(data.code || 'FAILED'));
};
window.__matrix.translate = (text, source, target) => new Promise((resolve, reject) => {
const requestId = ++window.__matrix.seq;
window.__matrix.pending.set(requestId, { resolve, reject });
worker.postMessage({ type: 'translate', id: requestId, text, source, target });
});
return 'ok';
} catch (error) {
window.__matrixError = String(error);
return 'error';
}
})()`);
if (init !== 'ok') throw new Error(`worker init failed: ${await evalJS('String(window.__matrixError || "unknown")')}`);
const ALL_LANGUAGES = ['en', 'es', 'ca', 'eu', 'gl', 'hi', 'th', 'ja', 'pt', 'fr', 'de', 'it', 'nl', 'pl', 'ru', 'ko', 'zh-CN', 'zh-TW', 'uk', 'tr', 'vi', 'id', 'sv', 'cs', 'ro', 'el'];
const selected = (process.env.MATRIX_LANGUAGES ?? '').split(',').map((v) => v.trim()).filter(Boolean);
const languages = selected.length ? ['en', ...selected.filter((code) => code !== 'en')] : ALL_LANGUAGES;
const sourceText = 'The skater dropped into the bowl and landed a clean trick.';
const results = [];
const started = Date.now();
for (const language of languages.filter((code) => code !== 'en')) {
const t0 = Date.now();
let ok = false;
let forward = '';
let back = '';
let error = '';
try {
forward = await evalJS(`window.__matrix.translate(${JSON.stringify(sourceText)}, ${JSON.stringify('en')}, ${JSON.stringify(language)})`, true) ?? '';
back = await evalJS(`window.__matrix.translate(${JSON.stringify(forward)}, ${JSON.stringify(language)}, ${JSON.stringify('en')})`, true) ?? '';
ok = typeof forward === 'string' && forward.trim().length > 0 && typeof back === 'string' && back.trim().length > 0;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
}
const ms = Date.now() - t0;
results.push({ language, ok, ms, forwardLength: forward.length, backLength: back.length, error });
writeFileSync('/tmp/opencode/translation-matrix.partial.json', JSON.stringify({ results }, null, 2));
console.log(`${language}: ${ok ? 'ok' : 'FAIL'} ${ms}ms forward=${forward.length} back=${back.length}${error ? ` (${error})` : ''}`);
}
const elapsed = Date.now() - started;
const heap = await evalJS('performance.memory ? performance.memory.usedJSHeapSize : null');
const report = { workerFile, languages: languages.length, results, elapsedMs: elapsed, jsHeapBytes: heap, exceptions };
writeFileSync('/tmp/opencode/translation-matrix.json', JSON.stringify(report, null, 2));
console.log(JSON.stringify({ ok: results.every((r) => r.ok), languages: results.length, elapsedMs: elapsed, jsHeapBytes: heap, exceptions: exceptions.length }));
ws.close(); cleanup(); process.exit(report.ok && exceptions.length === 0 ? 0 : 1);

View file

@ -0,0 +1,67 @@
import { Link } from 'react-router-dom';
import { Shield, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useTranslation } from '@/contexts/language';
import { shortNpub } from '@/lib/coordinator';
import { formatDistanceToNow } from '@/lib/dateFnsI18n';
import type { CordnJoinNotice } from '@/lib/cordn/watch';
/** A background-delivered Cordn join request in the notification bell. */
export function CordnJoinRequestCard({
notice,
onDismiss,
onNavigate,
}: {
notice: CordnJoinNotice;
onDismiss: (id: string) => void;
onNavigate?: () => void;
}) {
const { t } = useTranslation();
return (
<div className="rounded-xl border border-teal-500/25 bg-gradient-to-br from-teal-500/[0.08] to-transparent p-3">
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl bg-teal-500/15">
<Shield className="h-4 w-4 text-teal-300" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-black text-white">{t('cordnUi.joinRequestTitle')}</p>
<p className="text-xs text-gray-300">
{t('cordnUi.joinRequestFor', { crew: notice.crewName })}
</p>
</div>
<Button
type="button"
size="icon"
variant="ghost"
className="h-6 w-6 shrink-0 text-gray-500 hover:text-white"
onClick={() => onDismiss(notice.id)}
aria-label={t('cordnUi.joinRequestDismiss')}
title={t('cordnUi.joinRequestDismiss')}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
<p className="mt-1 text-[11px] text-gray-500">
{shortNpub(notice.requester)} ·{' '}
{formatDistanceToNow(new Date(notice.at * 1000), { addSuffix: true })}
</p>
<Button
asChild
size="sm"
variant="outline"
className="mt-2 h-7 gap-1 border-teal-500/40 px-2 text-xs text-teal-200"
>
<Link
to={`/crew/${notice.crewPubkey}/${notice.crewIdentifier}?tab=mls`}
onClick={onNavigate}
>
{t('cordnUi.joinRequestReview')}
</Link>
</Button>
</div>
</div>
</div>
);
}

View file

@ -1,8 +1,7 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { ChevronUp, X } from 'lucide-react';
import { ChevronUp } from 'lucide-react';
import { useTranslation } from '@/contexts/language';
import { useMapFooterHidden } from '@/hooks/useMapFooterHidden';
import { cn } from '@/lib/utils';
/**
@ -10,16 +9,13 @@ import { cn } from '@/lib/utils';
* (a full-bleed route where the footer is hidden) without stealing map height.
*
* Desktop: a slim full-width bar. Mobile: a tiny collapsed "Links" chip that
* expands to a compact row. Dismissible, and restorable from Settings →
* Appearance.
* expands to a compact row. Always visible: the map is a full-bleed route, so
* this strip is the only footer there — no accidental permanent hiding.
*/
export function MapFooterStrip() {
const { t } = useTranslation();
const [hidden, setHidden] = useMapFooterHidden();
const [open, setOpen] = useState(false);
if (hidden) return null;
const links = [
{ to: '/diy', label: t('nav.diy') },
{ to: '/newsletter', label: t('nav.newsletter') },
@ -44,13 +40,6 @@ export function MapFooterStrip() {
<img src="https://opensats.org/favicon.ico" alt="OpenSats" className="h-3 w-3" />
<span className="font-medium text-gray-400">OpenSats</span>
</span>
<button
onClick={() => setHidden(true)}
aria-label={t('mapUi.mapFooter.hide')}
className="min-h-0 min-w-0 text-gray-500 transition-colors hover:text-white"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
@ -63,13 +52,6 @@ export function MapFooterStrip() {
{l.label}
</Link>
))}
<button
onClick={() => setHidden(true)}
aria-label={t('mapUi.mapFooter.hide')}
className="min-h-0 min-w-0 text-gray-500 hover:text-white"
>
<X className="h-3 w-3" />
</button>
</div>
)}
<button

View file

@ -9,6 +9,7 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { CrewInviteCard } from '@/components/CrewInviteCard';
import { CordnJoinRequestCard } from '@/components/CordnJoinRequestCard';
import { JoinRequestCard } from '@/components/JoinRequestCard';
import { CollectiveJoinRequestCard } from '@/components/CollectiveJoinRequestCard';
import { ActivityNotificationCard } from '@/components/ActivityNotificationCard';
@ -16,6 +17,7 @@ import { TradeNotificationCard } from '@/components/TradeNotificationCard';
import { ImageWithFallback } from '@/components/ImageWithFallback';
import { EmptyState as SharedEmptyState } from '@/components/EmptyState';
import { useCrewNotifications, useIsPendingMember } from '@/hooks/useCrewNotifications';
import { useCordnJoinNotifications } from '@/hooks/useCordnJoinNotifications';
import { useActivityNotifications } from '@/hooks/useActivityNotifications';
import { useTradeNotifications } from '@/hooks/useTradeNotifications';
import { useNotificationsRead } from '@/hooks/useNotificationsRead';
@ -213,6 +215,11 @@ export function NotificationBell({ className }: { className?: string }) {
const { user, invites, listedCrews, joinRequests, collectiveRequests, isLoading: crewLoading } =
useCrewNotifications();
const { notifications: activity, isLoading: activityLoading } = useActivityNotifications();
const {
requests: cordnJoinRequests,
isLoading: cordnJoinLoading,
dismiss: dismissCordnJoin,
} = useCordnJoinNotifications();
const { lastRead, seenAt, markAllRead } = useNotificationsRead();
// Buying/selling steps: an offer on your listing, a counter it's your turn to
@ -227,8 +234,13 @@ export function NotificationBell({ className }: { className?: string }) {
// Total actionable crew items — these always RENDER in the Crews/All tabs
// until the user acts on them (accept/decline/confirm).
const crewTotal = useMemo(
() => invites.length + listedCrews.length + joinRequests.length + collectiveRequests.length,
[invites, listedCrews, joinRequests, collectiveRequests],
() =>
invites.length +
listedCrews.length +
joinRequests.length +
collectiveRequests.length +
cordnJoinRequests.length,
[invites, listedCrews, joinRequests, collectiveRequests, cordnJoinRequests.length],
);
// Crew items that count toward the BADGE = ones the user hasn't acknowledged
@ -241,8 +253,9 @@ export function NotificationBell({ className }: { className?: string }) {
for (const crew of listedCrews) if (ts(crew) > seenAt) count++;
for (const { request } of joinRequests) if (ts(request) > seenAt) count++;
for (const req of collectiveRequests) if (ts(req) > seenAt) count++;
for (const req of cordnJoinRequests) if (req.at > seenAt) count++;
return count;
}, [invites, listedCrews, joinRequests, collectiveRequests, seenAt]);
}, [invites, listedCrews, joinRequests, collectiveRequests, cordnJoinRequests, seenAt]);
// Unread social items = activity newer than the last time the bell was opened.
const unreadActivity = useMemo(
@ -277,7 +290,7 @@ export function NotificationBell({ className }: { className?: string }) {
}
}, [open, markAllRead, markNewsletterRead]);
const isLoading = crewLoading || activityLoading || tradeLoading;
const isLoading = crewLoading || activityLoading || tradeLoading || cordnJoinLoading;
if (!user) return null;
@ -414,6 +427,14 @@ export function NotificationBell({ className }: { className?: string }) {
{joinRequests.map(({ request, crew, endorsements }) => (
<JoinRequestCard key={request.id} request={request} crew={crew} endorsements={endorsements} compact />
))}
{cordnJoinRequests.map((notice) => (
<CordnJoinRequestCard
key={notice.id}
notice={notice}
onDismiss={dismissCordnJoin}
onNavigate={() => setOpen(false)}
/>
))}
{collectiveRequests.map((request) => (
<CollectiveJoinRequestCard key={request.id} request={request} compact />
))}
@ -534,6 +555,14 @@ export function NotificationBell({ className }: { className?: string }) {
{joinRequests.map(({ request, crew, endorsements }) => (
<JoinRequestCard key={request.id} request={request} crew={crew} endorsements={endorsements} compact />
))}
{cordnJoinRequests.map((notice) => (
<CordnJoinRequestCard
key={notice.id}
notice={notice}
onDismiss={dismissCordnJoin}
onNavigate={() => setOpen(false)}
/>
))}
{collectiveRequests.map((request) => (
<CollectiveJoinRequestCard key={request.id} request={request} compact />
))}

View file

@ -4,6 +4,7 @@ import type { ReactNode } from 'react';
import {
AlertTriangle,
Check,
CheckCheck,
ChevronDown,
Copy,
ExternalLink,
@ -18,6 +19,9 @@ import {
ScanLine,
Send,
Share2,
ShieldMinus,
ShieldPlus,
SmilePlus,
Trash2,
UserPlus,
Users,
@ -41,6 +45,8 @@ import { QRScannerModal } from '@/components/wallet/QRScannerModal';
import { useTranslation } from '@/contexts/language';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useQueryClient } from '@tanstack/react-query';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useCordnNative } from '@/hooks/useCordnNative';
import { shortNpub } from '@/lib/coordinator';
@ -48,6 +54,7 @@ import { isNativePlatform } from '@/lib/platform';
import { formatDistanceToNow } from '@/lib/dateFnsI18n';
import { cn } from '@/lib/utils';
import type { CordnRoom } from '@/lib/cordn/room';
import { SKATE_CREW_KIND, type SkateCrew } from '@/lib/types';
import { readCordnSeen, writeCordnSeen } from '@/lib/cordn/unread';
import { clearPendingJoin, readPendingJoin, writePendingJoin } from '@/lib/cordn/pendingJoin';
@ -100,7 +107,13 @@ export function CordnNativePanel({
onRequestsChange,
}: CordnNativePanelProps) {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
const queryClient = useQueryClient();
const [roleBusy, setRoleBusy] = useState<string | null>(null);
const isCommunity = scope === 'community';
const sourceCrew = crew as SkateCrew;
const canChangeRoles = !isCommunity && user?.pubkey === crew.pubkey && !!sourceCrew.event;
const copy = {
title: isCommunity ? t('communityUi.title') : t('cordnUi.title'),
subtitle: isCommunity ? t('communityUi.subtitle') : t('cordnUi.subtitle'),
@ -142,13 +155,14 @@ export function CordnNativePanel({
t('cordnUi.onboardingMember3'),
],
};
const { user } = useCurrentUser();
const native = useCordnNative({ crew, enabled: !!user && isMember, active });
// Stable callbacks (the hook's return object is recreated each render).
const sendNative = native.send;
const checkInviteNative = native.checkInvite;
const [draft, setDraft] = useState('');
const [replyTo, setReplyTo] = useState<{ id: string; content: string } | null>(null);
const [reactingTo, setReactingTo] = useState<string | null>(null);
const readSentRef = useRef<Set<string>>(new Set());
const [copied, setCopied] = useState(false);
const [copiedLink, setCopiedLink] = useState(false);
const [copiedDevice, setCopiedDevice] = useState(false);
@ -182,6 +196,18 @@ export function CordnNativePanel({
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
}, [native.messages.length, pending?.state]);
// Read receipts: when the tab is visible, acknowledge the latest inbound
// message once. Control receipts are filtered out of `native.messages`.
useEffect(() => {
if (!active || !native.ready || !user?.pubkey || native.interactiveHintVisible) return;
const lastInbound = [...native.messages]
.reverse()
.find((m) => m.direction === 'inbound' && m.sender !== user.pubkey);
if (!lastInbound || readSentRef.current.has(lastInbound.id)) return;
readSentRef.current.add(lastInbound.id);
void native.markRead(lastInbound.id);
}, [active, native, native.messages, native.markRead, native.ready, user?.pubkey]);
useEffect(() => {
if (!user?.pubkey) return;
setAwaiting(readPendingJoin(user.pubkey, native.groupId));
@ -501,6 +527,29 @@ export function CordnNativePanel({
const roleOf = (pubkey: string): MemberRole =>
pubkey === crew.pubkey ? 'founder' : crew.admins.includes(pubkey) ? 'admin' : 'member';
const updateAdminRole = async (memberPubkey: string, makeAdmin: boolean) => {
if (!canChangeRoles || !sourceCrew.event) return;
setRoleBusy(memberPubkey);
try {
const tags = sourceCrew.event.tags.filter(
([name, value]) => !(name === 'p' && value === memberPubkey),
);
tags.push(makeAdmin ? ['p', memberPubkey, '', 'admin'] : ['p', memberPubkey]);
await publishEvent({
kind: SKATE_CREW_KIND,
content: sourceCrew.event.content,
tags,
});
await queryClient.invalidateQueries({ queryKey: ['skate-crew'] });
await queryClient.invalidateQueries({ queryKey: ['skate-crews'] });
setNotice(t(makeAdmin ? 'cordnUi.adminAdded' : 'cordnUi.adminRemoved'));
} catch {
setNotice(t('cordnUi.roleUpdateFailed'));
} finally {
setRoleBusy(null);
}
};
return (
<Card className="border-white/10 bg-black/40">
<CardHeader className="pb-3">
@ -716,6 +765,9 @@ export function CordnNativePanel({
founderLabel={copy.owner}
botPubkey={botPubkey}
botName={copy.botName}
canChangeRole={canChangeRoles && member.pubkey !== user.pubkey && member.pubkey !== crew.pubkey}
roleBusy={roleBusy === member.pubkey}
onToggleAdmin={() => void updateAdminRole(member.pubkey, roleOf(member.pubkey) !== 'admin')}
canRemove={canManage && member.pubkey !== user.pubkey && member.pubkey !== crew.pubkey}
onRemove={() => {
if (!window.confirm(t('cordnUi.removeMemberConfirm', { name: shortNpub(member.pubkey) }))) return;
@ -771,7 +823,21 @@ export function CordnNativePanel({
<Check className="h-3 w-3" /> {t('cordnUi.sent')}
</span>
)}
{m.direction === 'outbound' && (native.readBy[m.id]?.length ?? 0) > 0 && (
<span className="flex items-center gap-0.5 text-teal-400">
<CheckCheck className="h-3 w-3" /> {t('cordnUi.readBy', { count: native.readBy[m.id].length })}
</span>
)}
<span className="ml-auto flex items-center gap-0.5">
<button
type="button"
onClick={() => setReactingTo(reactingTo === m.id ? null : m.id)}
className="rounded p-0.5 text-gray-500 hover:bg-white/10 hover:text-gray-200"
aria-label={t('cordnUi.addReaction')}
title={t('cordnUi.addReaction')}
>
<SmilePlus className="h-3 w-3" />
</button>
<button
type="button"
onClick={() => void copyMessage(m.content)}
@ -790,6 +856,20 @@ export function CordnNativePanel({
>
<Reply className="h-3 w-3" />
</button>
{m.direction === 'outbound' && (
<button
type="button"
onClick={() => {
if (!window.confirm(t('cordnUi.deleteMessageConfirm'))) return;
void native.deleteMessage(m.id);
}}
className="rounded p-0.5 text-gray-500 hover:bg-red-500/10 hover:text-red-300"
aria-label={t('cordnUi.deleteMessage')}
title={t('cordnUi.deleteMessage')}
>
<Trash2 className="h-3 w-3" />
</button>
)}
</span>
</div>
<div
@ -812,6 +892,44 @@ export function CordnNativePanel({
<div className="whitespace-pre-wrap break-words">{m.content}</div>
)}
</div>
{reactingTo === m.id && (
<div className="flex items-center gap-1 rounded-full border border-white/10 bg-black/80 px-2 py-1">
{['👍', '❤️', '🔥', '😂'].map((emoji) => (
<button
key={emoji}
type="button"
onClick={() => {
void native.toggleReaction(m.id, emoji);
setReactingTo(null);
}}
className="rounded px-1 text-sm hover:bg-white/10"
>
{emoji}
</button>
))}
</div>
)}
{Object.values(native.reactions[m.id] ?? {}).some((pks) => pks.length > 0) && (
<div className={cn('flex flex-wrap gap-1', m.direction === 'outbound' && 'justify-end')}>
{Object.entries(native.reactions[m.id] ?? {})
.filter(([, pks]) => pks.length > 0)
.map(([emoji, pks]) => (
<button
key={emoji}
type="button"
onClick={() => void native.toggleReaction(m.id, emoji)}
className={cn(
'rounded-full border px-1.5 py-0.5 text-[10px]',
user?.pubkey && pks.includes(user.pubkey)
? 'border-teal-500/40 bg-teal-500/10 text-teal-200'
: 'border-white/10 bg-white/5 text-gray-300',
)}
>
{emoji} {pks.length}
</button>
))}
</div>
)}
</div>
))
)}
@ -1230,6 +1348,9 @@ function MemberRow({
botName,
canRemove,
onRemove,
canChangeRole,
roleBusy,
onToggleAdmin,
}: {
pubkey: string;
role: MemberRole;
@ -1239,6 +1360,9 @@ function MemberRow({
botName?: string;
canRemove?: boolean;
onRemove?: () => void;
canChangeRole?: boolean;
roleBusy?: boolean;
onToggleAdmin?: () => void;
}) {
const { t } = useTranslation();
const label =
@ -1262,6 +1386,25 @@ function MemberRow({
>
{label}
</Badge>
{canChangeRole && onToggleAdmin && (
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-gray-500 hover:bg-amber-500/10 hover:text-amber-300"
onClick={onToggleAdmin}
disabled={roleBusy}
aria-label={role === 'admin' ? t('cordnUi.removeAdmin') : t('cordnUi.makeAdmin')}
title={role === 'admin' ? t('cordnUi.removeAdmin') : t('cordnUi.makeAdmin')}
>
{roleBusy ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : role === 'admin' ? (
<ShieldMinus className="h-3 w-3" />
) : (
<ShieldPlus className="h-3 w-3" />
)}
</Button>
)}
{canRemove && onRemove && (
<Button
size="icon"

View file

@ -0,0 +1,116 @@
import { useCallback, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { APP_DATA_KIND } from '@/lib/types';
import {
CORDN_JOIN_TAG,
CORDN_WATCHER_PUBKEY,
type CordnJoinNotice,
} from '@/lib/cordn/watch';
const DISMISSED_KEY = 'skatespots:cordn-join-dismissed';
/**
* New Cordn private-chat join requests for this viewer, delivered by the
* background watcher as encrypted NIP-78 events. The watcher also sends the
* same event as a NIP-17 DM, so the OS push path works while the app is closed.
*/
export function useCordnJoinNotifications() {
const { user } = useCurrentUser();
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const [dismissed, setDismissed] = useLocalStorage<string[]>(DISMISSED_KEY, []);
const canRead = !!user?.pubkey && !!user.signer.nip44;
const query = useQuery({
queryKey: ['cordn-join-notifications', user?.pubkey, getQueryKeySuffix()],
enabled: canRead,
staleTime: 30_000,
queryFn: async (c): Promise<CordnJoinNotice[]> => {
if (!user?.pubkey || !user.signer.nip44) return [];
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6_000)]);
try {
const events = await querySkateContent(
[
{
kinds: [APP_DATA_KIND],
authors: [CORDN_WATCHER_PUBKEY],
'#t': [CORDN_JOIN_TAG],
'#p': [user.pubkey],
limit: 100,
},
],
{ signal },
);
const notices: CordnJoinNotice[] = [];
for (const event of events) {
let plain: string;
try {
plain = event.content.trim().startsWith('{')
? event.content
: await user.signer.nip44.decrypt(CORDN_WATCHER_PUBKEY, event.content);
} catch {
continue;
}
try {
const parsed = JSON.parse(plain) as Partial<CordnJoinNotice>;
if (
typeof parsed.id !== 'string' ||
typeof parsed.gid !== 'string' ||
typeof parsed.crewName !== 'string' ||
typeof parsed.crewPubkey !== 'string' ||
typeof parsed.crewIdentifier !== 'string' ||
typeof parsed.requester !== 'string'
) {
continue;
}
const rawAt = typeof parsed.at === 'number' ? parsed.at : event.created_at;
notices.push({
id: parsed.id,
gid: parsed.gid,
crewName: parsed.crewName,
crewPubkey: parsed.crewPubkey,
crewIdentifier: parsed.crewIdentifier,
requester: parsed.requester,
at: rawAt > 1e12 ? Math.floor(rawAt / 1000) : rawAt,
eventId: event.id,
});
} catch {
// Ignore malformed notices.
}
}
const seen = new Set<string>();
return notices
.filter((notice) => {
if (seen.has(notice.id)) return false;
seen.add(notice.id);
return true;
})
.sort((a, b) => b.at - a.at)
.slice(0, 30);
} catch {
return [];
}
},
});
const dismissedSet = useMemo(() => new Set(dismissed), [dismissed]);
const requests = useMemo(
() => (query.data ?? []).filter((notice) => !dismissedSet.has(notice.id)),
[query.data, dismissedSet],
);
const dismiss = useCallback(
(id: string) => {
setDismissed((current) => (current.includes(id) ? current : [...current, id].slice(-200)));
},
[setDismissed],
);
return {
requests,
isLoading: query.isLoading,
dismiss,
};
}

View file

@ -60,6 +60,16 @@ export interface UseCordnNativeResult {
* Returns the stored message on success, or `null` when the send failed.
*/
send: (text: string, imageUrl?: string) => Promise<CordnChatMessage | null>;
/** Visible messages, with control messages (reaction/delete/read) filtered out. */
reactions: Record<string, Record<string, string[]>>;
/** Message id → pubkeys that sent a read receipt. */
readBy: Record<string, string[]>;
/** Toggle a reaction for this viewer. */
toggleReaction: (messageId: string, emoji: string) => Promise<boolean>;
/** Delete-for-everyone (an MLS control tombstone; not a server hard delete). */
deleteMessage: (messageId: string) => Promise<boolean>;
/** Send a read receipt for an inbound message. */
markRead: (messageId: string) => Promise<boolean>;
/** Re-fetch messages (and adopt any new commits). */
refresh: () => Promise<void>;
/** Founder/admin: create the crew's MLS group. */
@ -261,6 +271,93 @@ export function useCordnNative({
[group, run],
);
const { visibleMessages, reactions, readBy } = useMemo(() => {
const reactionSets: Record<string, Record<string, Set<string>>> = {};
const readSets: Record<string, Set<string>> = {};
const deleted = new Set<string>();
const controlIds = new Set<string>();
for (const message of messages) {
let isControl = false;
for (const [name, first, second] of message.tags ?? []) {
if (name === 'delete' && first) {
deleted.add(first);
isControl = true;
} else if ((name === 'reaction' || name === 'unreact') && first && second) {
reactionSets[second] ??= {};
reactionSets[second][first] ??= new Set();
if (name === 'reaction') reactionSets[second][first].add(message.sender);
else reactionSets[second][first].delete(message.sender);
isControl = true;
} else if (name === 'read' && first) {
readSets[first] ??= new Set();
readSets[first].add(message.sender);
isControl = true;
}
}
if (isControl && !message.content.trim() && !message.imageUrl) controlIds.add(message.id);
}
const reactionMap: Record<string, Record<string, string[]>> = {};
for (const [target, byEmoji] of Object.entries(reactionSets)) {
reactionMap[target] = {};
for (const [emoji, senders] of Object.entries(byEmoji)) {
if (senders.size > 0) reactionMap[target][emoji] = [...senders];
}
}
const readMap: Record<string, string[]> = {};
for (const [target, senders] of Object.entries(readSets)) {
if (senders.size > 0) readMap[target] = [...senders];
}
return {
visibleMessages: messages.filter((m) => !controlIds.has(m.id) && !deleted.has(m.id)),
reactions: reactionMap,
readBy: readMap,
};
}, [messages]);
const toggleReaction = useCallback(
async (messageId: string, emoji: string) => {
const session = sessionRef.current;
if (!session || !group || !user?.pubkey) return false;
const already = reactions[messageId]?.[emoji]?.includes(user.pubkey);
const result = await run(async () => {
await session.sendControl(group.gid, [[already ? 'unreact' : 'reaction', emoji, messageId]]);
setMessages(session.messages(group.gid));
return true;
});
return result === true;
},
[group, reactions, run, user?.pubkey],
);
const deleteMessage = useCallback(
async (messageId: string) => {
const session = sessionRef.current;
if (!session || !group) return false;
const result = await run(async () => {
await session.sendControl(group.gid, [['delete', messageId]]);
setMessages(session.messages(group.gid));
return true;
});
return result === true;
},
[group, run],
);
const markRead = useCallback(
async (messageId: string) => {
const session = sessionRef.current;
if (!session || !group) return false;
try {
await session.sendControl(group.gid, [['read', messageId]]);
setMessages(session.messages(group.gid));
return true;
} catch {
return false;
}
},
[group],
);
const createChat = useCallback(async () => {
const session = sessionRef.current;
if (!session || !crew || !groupId) return false;
@ -406,10 +503,15 @@ export function useCordnNative({
coordinator,
groupId,
group,
messages,
messages: visibleMessages,
reactions,
readBy,
busy,
error,
send,
toggleReaction,
deleteMessage,
markRead,
refresh,
createChat,
requestJoin,

View file

@ -1,30 +0,0 @@
import { useCallback, useEffect } from 'react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
const KEY = 'skatespots:map-footer-hidden';
const EVENT = 'mapfooter:hiddenchange';
/**
* Whether the thin links strip at the bottom of the map is hidden. Device-local
* and synced within the tab via a custom event (so the Settings toggle and the
* strip stay in step). Restorable from Settings → Appearance.
*/
export function useMapFooterHidden(): [boolean, (value: boolean) => void] {
const [hidden, setHiddenRaw] = useLocalStorage(KEY, false);
useEffect(() => {
const handler = (e: Event) => setHiddenRaw(!!(e as CustomEvent<boolean>).detail);
window.addEventListener(EVENT, handler);
return () => window.removeEventListener(EVENT, handler);
}, [setHiddenRaw]);
const setHidden = useCallback(
(value: boolean) => {
setHiddenRaw(value);
window.dispatchEvent(new CustomEvent(EVENT, { detail: value }));
},
[setHiddenRaw],
);
return [hidden, setHidden];
}

View file

@ -964,6 +964,19 @@ const batch1 = {
members: "Membres",
roleFounder: "Fundador",
roleAdmin: "Administrador",
makeAdmin: "Fes admin",
removeAdmin: "Treu admin",
adminAdded: "Membre ascendit a admin.",
adminRemoved: "Rol d'admin eliminat.",
roleUpdateFailed: "No s'ha pogut actualitzar el rol. Torna-ho a provar.",
addReaction: "Afegeix una reacció",
deleteMessage: "Elimina el missatge",
deleteMessageConfirm: "Vols eliminar aquest missatge per a tothom?",
readBy: "Llegit per {{count}}",
joinRequestTitle: "Sol·licitud de xat privat",
joinRequestFor: "Nova sol·licitud per unir-te a {{crew}}",
joinRequestReview: "Revisa la sol·licitud",
joinRequestDismiss: "Descarta la sol·licitud",
roleMember: "Membre",
qrTitle: "Escaneja per unir-te al xat privat",
qrHint: "Els membres de la colla ho escanegen amb la càmera del mòbil. L’enllaç obre la pestanya Xat privat.",

View file

@ -342,6 +342,19 @@ const drift = {
members: "Členové",
roleFounder: "Zakladatel",
roleAdmin: "Administrátor",
makeAdmin: "Udělat adminem",
removeAdmin: "Odebrat admina",
adminAdded: "Člen povýšen na admina.",
adminRemoved: "Role admina odebrána.",
roleUpdateFailed: "Roli se nepodařilo změnit. Zkus to znovu.",
addReaction: "Přidat reakci",
deleteMessage: "Smazat zprávu",
deleteMessageConfirm: "Smazat tuto zprávu pro všechny?",
readBy: "Přečetlo {{count}}",
joinRequestTitle: "Žádost o soukromý chat",
joinRequestFor: "Nová žádost o připojení k {{crew}}",
joinRequestReview: "Zkontrolovat žádost",
joinRequestDismiss: "Odmítnout žádost",
roleMember: "Člen",
qrTitle: "Naskenuj a připoj se k soukromému chatu",
qrHint: "Členové crew to naskenují kamerou telefonu. Odkaz otevře kartu Soukromý chat.",

View file

@ -344,6 +344,19 @@ const drift = {
members: "Mitglieder",
roleFounder: "Gründer",
roleAdmin: "Admin",
makeAdmin: "Zum Admin machen",
removeAdmin: "Admin entfernen",
adminAdded: "Mitglied zum Admin befördert.",
adminRemoved: "Admin-Rolle entfernt.",
roleUpdateFailed: "Rolle konnte nicht geändert werden. Versuch es erneut.",
addReaction: "Reaktion hinzufügen",
deleteMessage: "Nachricht löschen",
deleteMessageConfirm: "Diese Nachricht für alle löschen?",
readBy: "Gelesen von {{count}}",
joinRequestTitle: "Private Chat-Anfrage",
joinRequestFor: "Neue Anfrage zum Beitritt zu {{crew}}",
joinRequestReview: "Anfrage prüfen",
joinRequestDismiss: "Anfrage verwerfen",
roleMember: "Mitglied",
qrTitle: "Scannen und dem privaten Chat beitreten",
qrHint: "Crew-Mitglieder scannen das mit der Handykamera. Der Link öffnet den Tab „Privater Chat“.",

View file

@ -346,6 +346,19 @@ const drift = {
members: "Μέλη",
roleFounder: "Ιδρυτής",
roleAdmin: "Διαχειριστής",
makeAdmin: "Ορισμός ως διαχειριστή",
removeAdmin: "Αφαίρεση διαχειριστή",
adminAdded: "Το μέλος έγινε διαχειριστής.",
adminRemoved: "Ο ρόλος διαχειριστή αφαιρέθηκε.",
roleUpdateFailed: "Δεν ήταν δυνατή η αλλαγή ρόλου. Δοκίμασε ξανά.",
addReaction: "Προσθήκη αντίδρασης",
deleteMessage: "Διαγραφή μηνύματος",
deleteMessageConfirm: "Διαγραφή αυτού του μηνύματος για όλους;",
readBy: "Διαβάστηκε από {{count}}",
joinRequestTitle: "Αίτημα ιδιωτικής συνομιλίας",
joinRequestFor: "Νέο αίτημα συμμετοχής στο {{crew}}",
joinRequestReview: "Έλεγχος αιτήματος",
joinRequestDismiss: "Απόρριψη αιτήματος",
roleMember: "Μέλος",
qrTitle: "Σαρώστε για να μπείτε στην ιδιωτική συνομιλία",
qrHint: "Τα μέλη του crew το σαρώνουν με την κάμερα του κινητού. Ο σύνδεσμος ανοίγει την καρτέλα Ιδιωτική συνομιλία.",

View file

@ -964,6 +964,19 @@ const batch1 = {
members: "Kideak",
roleFounder: "Sortzailea",
roleAdmin: "Administratzailea",
makeAdmin: "Egin administratzaile",
removeAdmin: "Kendu administratzailea",
adminAdded: "Kidea administratzaile izendatu da.",
adminRemoved: "Administratzaile rola kendu da.",
roleUpdateFailed: "Ezin izan da rola eguneratu. Saiatu berriro.",
addReaction: "Gehitu erreakzioa",
deleteMessage: "Ezabatu mezua",
deleteMessageConfirm: "Mezu hau denentzat ezabatu?",
readBy: "{{count}} irakurrita",
joinRequestTitle: "Txat pribatuaren eskaera",
joinRequestFor: "{{crew}} taldean sartzeko eskaera berria",
joinRequestReview: "Berrikusi eskaera",
joinRequestDismiss: "Baztertu eskaera",
roleMember: "Kidea",
qrTitle: "Eskaneatu txat pribatura sartzeko",
qrHint: "Taldeko kideek telefonoaren kamerarekin eskaneatzen dute. Estekak Txat pribatua fitxa irekitzen du.",

View file

@ -344,6 +344,19 @@ const drift = {
members: "Membres",
roleFounder: "Fondateur",
roleAdmin: "Admin",
makeAdmin: "Nommer admin",
removeAdmin: "Retirer admin",
adminAdded: "Membre promu admin.",
adminRemoved: "Rôle admin retiré.",
roleUpdateFailed: "Impossible de modifier le rôle. Réessayez.",
addReaction: "Ajouter une réaction",
deleteMessage: "Supprimer le message",
deleteMessageConfirm: "Supprimer ce message pour tout le monde ?",
readBy: "Lu par {{count}}",
joinRequestTitle: "Demande de chat privé",
joinRequestFor: "Nouvelle demande pour rejoindre {{crew}}",
joinRequestReview: "Examiner la demande",
joinRequestDismiss: "Ignorer la demande",
roleMember: "Membre",
qrTitle: "Scannez pour rejoindre le chat privé",
qrHint: "Les membres de la Crew le scannent avec l’appareil photo de leur téléphone. Le lien ouvre l’onglet Chat privé.",

View file

@ -964,6 +964,19 @@ const batch1 = {
members: "Membros",
roleFounder: "Fundador",
roleAdmin: "Administrador",
makeAdmin: "Facer admin",
removeAdmin: "Quitar admin",
adminAdded: "Membro ascendido a admin.",
adminRemoved: "Rol de admin eliminado.",
roleUpdateFailed: "Non se puido actualizar o rol. Téntao de novo.",
addReaction: "Engadir reacción",
deleteMessage: "Eliminar mensaxe",
deleteMessageConfirm: "Eliminar esta mensaxe para todos?",
readBy: "Lido por {{count}}",
joinRequestTitle: "Solicitude de chat privado",
joinRequestFor: "Nova solicitude para unirte a {{crew}}",
joinRequestReview: "Revisar solicitude",
joinRequestDismiss: "Descartar solicitude",
roleMember: "Membro",
qrTitle: "Escanea para entrar no chat privado",
qrHint: "Os membros da crew escanéano coa cámara do móbil. A ligazón abre a pestana Chat privado.",

View file

@ -964,6 +964,19 @@ const batch1 = {
members: "सदस्य",
roleFounder: "संस्थापक",
roleAdmin: "एडमिन",
makeAdmin: "एडमिन बनाएँ",
removeAdmin: "एडमिन हटाएँ",
adminAdded: "सदस्य को एडमिन बनाया गया।",
adminRemoved: "एडमिन भूमिका हटाई गई।",
roleUpdateFailed: "भूमिका अपडेट नहीं हो सकी। फिर कोशिश करें।",
addReaction: "प्रतिक्रिया जोड़ें",
deleteMessage: "संदेश हटाएँ",
deleteMessageConfirm: "यह संदेश सभी के लिए हटाएँ?",
readBy: "{{count}} ने पढ़ा",
joinRequestTitle: "निजी चैट अनुरोध",
joinRequestFor: "{{crew}} में शामिल होने का नया अनुरोध",
joinRequestReview: "अनुरोध की समीक्षा करें",
joinRequestDismiss: "अनुरोध खारिज करें",
roleMember: "सदस्य",
qrTitle: "निजी चैट में शामिल होने के लिए स्कैन करें",
qrHint: "क्रू सदस्य इसे फ़ोन कैमरे से स्कैन करते हैं। लिंक निजी चैट टैब खोलता है।",

View file

@ -338,6 +338,19 @@ const driftStrings = {
members: "Anggota",
roleFounder: "Pendiri",
roleAdmin: "Admin",
makeAdmin: "Jadikan admin",
removeAdmin: "Hapus admin",
adminAdded: "Anggota dipromosikan menjadi admin.",
adminRemoved: "Peran admin dihapus.",
roleUpdateFailed: "Tidak dapat memperbarui peran. Coba lagi.",
addReaction: "Tambah reaksi",
deleteMessage: "Hapus pesan",
deleteMessageConfirm: "Hapus pesan ini untuk semua orang?",
readBy: "Dibaca oleh {{count}}",
joinRequestTitle: "Permintaan chat pribadi",
joinRequestFor: "Permintaan baru untuk bergabung dengan {{crew}}",
joinRequestReview: "Tinjau permintaan",
joinRequestDismiss: "Abaikan permintaan",
roleMember: "Anggota",
qrTitle: "Pindai untuk bergabung ke chat privat",
qrHint: "Anggota crew memindai ini dengan kamera ponsel. Tautan membuka tab Chat privat.",

View file

@ -344,6 +344,19 @@ const drift = {
members: "Membri",
roleFounder: "Fondatore",
roleAdmin: "Admin",
makeAdmin: "Rendi admin",
removeAdmin: "Rimuovi admin",
adminAdded: "Membro promosso ad admin.",
adminRemoved: "Ruolo admin rimosso.",
roleUpdateFailed: "Impossibile aggiornare il ruolo. Riprova.",
addReaction: "Aggiungi reazione",
deleteMessage: "Elimina messaggio",
deleteMessageConfirm: "Eliminare questo messaggio per tutti?",
readBy: "Letto da {{count}}",
joinRequestTitle: "Richiesta di chat privata",
joinRequestFor: "Nuova richiesta di adesione a {{crew}}",
joinRequestReview: "Esamina richiesta",
joinRequestDismiss: "Ignora richiesta",
roleMember: "Membro",
qrTitle: "Scansiona per unirti alla chat privata",
qrHint: "I membri della crew lo scansionano con la fotocamera del telefono. Il link apre la scheda Chat privata.",

View file

@ -354,6 +354,19 @@ const drift: Dict = {
members: "メンバー",
roleFounder: "創設者",
roleAdmin: "管理者",
makeAdmin: "管理者にする",
removeAdmin: "管理者を解除",
adminAdded: "メンバーを管理者にしました。",
adminRemoved: "管理者権限を解除しました。",
roleUpdateFailed: "役割を更新できませんでした。もう一度お試しください。",
addReaction: "リアクションを追加",
deleteMessage: "メッセージを削除",
deleteMessageConfirm: "このメッセージを全員から削除しますか?",
readBy: "{{count}}人が既読",
joinRequestTitle: "プライベートチャットのリクエスト",
joinRequestFor: "{{crew}}への参加リクエスト",
joinRequestReview: "リクエストを確認",
joinRequestDismiss: "リクエストを却下",
roleMember: "メンバー",
qrTitle: "スキャンしてプライベートチャットに参加",
qrHint: "クルーメンバーがスマートフォンのカメラでスキャンします。リンクからプライベートチャットタブが開きます。",

View file

@ -354,6 +354,19 @@ const drift: Dict = {
members: "멤버",
roleFounder: "창립자",
roleAdmin: "관리자",
makeAdmin: "관리자로 지정",
removeAdmin: "관리자 해제",
adminAdded: "멤버를 관리자로 승격했습니다.",
adminRemoved: "관리자 권한을 해제했습니다.",
roleUpdateFailed: "역할을 변경하지 못했습니다. 다시 시도하세요.",
addReaction: "반응 추가",
deleteMessage: "메시지 삭제",
deleteMessageConfirm: "이 메시지를 모든 사람에게서 삭제할까요?",
readBy: "{{count}}명이 읽음",
joinRequestTitle: "비공개 채팅 요청",
joinRequestFor: "{{crew}} 참여 요청",
joinRequestReview: "요청 검토",
joinRequestDismiss: "요청 무시",
roleMember: "멤버",
qrTitle: "스캔하여 비공개 채팅 참여",
qrHint: "크루 멤버가 휴대폰 카메라로 스캔합니다. 링크를 열면 비공개 채팅 탭으로 이동합니다.",

View file

@ -67,6 +67,19 @@ const cordnUi = {
members: 'Members',
roleFounder: 'Founder',
roleAdmin: 'Admin',
makeAdmin: 'Make admin',
removeAdmin: 'Remove admin',
adminAdded: 'Member promoted to admin.',
adminRemoved: 'Admin role removed.',
roleUpdateFailed: 'Could not update the role. Try again.',
addReaction: 'Add reaction',
deleteMessage: 'Delete message',
deleteMessageConfirm: 'Delete this message for everyone?',
readBy: 'Read by {{count}}',
joinRequestTitle: 'Private chat request',
joinRequestFor: 'New request to join {{crew}}',
joinRequestReview: 'Review request',
joinRequestDismiss: 'Dismiss request',
roleMember: 'Member',
qrTitle: 'Scan to join the private chat',
qrHint:
@ -156,6 +169,19 @@ const cordnUi = {
members: 'Miembros',
roleFounder: 'Fundador',
roleAdmin: 'Admin',
makeAdmin: 'Hacer admin',
removeAdmin: 'Quitar admin',
adminAdded: 'Miembro ascendido a admin.',
adminRemoved: 'Rol de admin eliminado.',
roleUpdateFailed: 'No se pudo actualizar el rol. Inténtalo de nuevo.',
addReaction: 'Añadir reacción',
deleteMessage: 'Eliminar mensaje',
deleteMessageConfirm: '¿Eliminar este mensaje para todos?',
readBy: 'Leído por {{count}}',
joinRequestTitle: 'Solicitud de chat privado',
joinRequestFor: 'Nueva solicitud para unirse a {{crew}}',
joinRequestReview: 'Revisar solicitud',
joinRequestDismiss: 'Descartar solicitud',
roleMember: 'Miembro',
qrTitle: 'Escanea para entrar al chat privado',
qrHint:

View file

@ -342,6 +342,19 @@ const drift = {
members: "Leden",
roleFounder: "Oprichter",
roleAdmin: "Admin",
makeAdmin: "Admin maken",
removeAdmin: "Admin verwijderen",
adminAdded: "Lid gepromoveerd tot admin.",
adminRemoved: "Adminrol verwijderd.",
roleUpdateFailed: "Rol bijwerken mislukt. Probeer het opnieuw.",
addReaction: "Reactie toevoegen",
deleteMessage: "Bericht verwijderen",
deleteMessageConfirm: "Dit bericht voor iedereen verwijderen?",
readBy: "Gelezen door {{count}}",
joinRequestTitle: "Verzoek voor privéchat",
joinRequestFor: "Nieuw verzoek om lid te worden van {{crew}}",
joinRequestReview: "Verzoek bekijken",
joinRequestDismiss: "Verzoek negeren",
roleMember: "Lid",
qrTitle: "Scan om deel te nemen aan de privéchat",
qrHint: "Crewleden scannen dit met de camera van hun telefoon. De link opent het tabblad Privéchat.",

View file

@ -342,6 +342,19 @@ const drift = {
members: "Członkowie",
roleFounder: "Założyciel",
roleAdmin: "Administrator",
makeAdmin: "Nadaj uprawnienia administratora",
removeAdmin: "Odbierz uprawnienia administratora",
adminAdded: "Członek awansowany na administratora.",
adminRemoved: "Rola administratora odebrana.",
roleUpdateFailed: "Nie udało się zmienić roli. Spróbuj ponownie.",
addReaction: "Dodaj reakcję",
deleteMessage: "Usuń wiadomość",
deleteMessageConfirm: "Usunąć tę wiadomość dla wszystkich?",
readBy: "Przeczytane przez {{count}}",
joinRequestTitle: "Prośba o prywatny czat",
joinRequestFor: "Nowa prośba o dołączenie do {{crew}}",
joinRequestReview: "Przejrzyj prośbę",
joinRequestDismiss: "Odrzuć prośbę",
roleMember: "Członek",
qrTitle: "Zeskanuj, aby dołączyć do prywatnego czatu",
qrHint: "Członkowie crew skanują to aparatem telefonu. Link otwiera kartę Prywatny czat.",

View file

@ -344,6 +344,19 @@ const drift = {
members: "Membros",
roleFounder: "Fundador",
roleAdmin: "Admin",
makeAdmin: "Tornar admin",
removeAdmin: "Remover admin",
adminAdded: "Membro promovido a admin.",
adminRemoved: "Papel de admin removido.",
roleUpdateFailed: "Não foi possível atualizar o papel. Tente novamente.",
addReaction: "Adicionar reação",
deleteMessage: "Excluir mensagem",
deleteMessageConfirm: "Excluir esta mensagem para todos?",
readBy: "Lido por {{count}}",
joinRequestTitle: "Pedido de chat privado",
joinRequestFor: "Novo pedido para entrar em {{crew}}",
joinRequestReview: "Rever pedido",
joinRequestDismiss: "Ignorar pedido",
roleMember: "Membro",
qrTitle: "Escaneie para entrar no chat privado",
qrHint: "Os membros da crew escaneiam com a câmera do celular. O link abre a aba Chat privado.",

View file

@ -342,6 +342,19 @@ const drift = {
members: "Membri",
roleFounder: "Fondator",
roleAdmin: "Admin",
makeAdmin: "Fă admin",
removeAdmin: "Elimină admin",
adminAdded: "Membru promovat admin.",
adminRemoved: "Rolul de admin a fost eliminat.",
roleUpdateFailed: "Rolul nu a putut fi actualizat. Încearcă din nou.",
addReaction: "Adaugă o reacție",
deleteMessage: "Șterge mesajul",
deleteMessageConfirm: "Ștergi acest mesaj pentru toți?",
readBy: "Citit de {{count}}",
joinRequestTitle: "Cerere de chat privat",
joinRequestFor: "Cerere nouă de alăturare la {{crew}}",
joinRequestReview: "Revizuiește cererea",
joinRequestDismiss: "Respinge cererea",
roleMember: "Membru",
qrTitle: "Scanează pentru a intra în chatul privat",
qrHint: "Membrii crew-ului scanează cu camera telefonului. Linkul deschide fila Chat privat.",

View file

@ -346,6 +346,19 @@ const drift = {
members: "Участники",
roleFounder: "Основатель",
roleAdmin: "Администратор",
makeAdmin: "Назначить админом",
removeAdmin: "Снять админа",
adminAdded: "Участник повышен до админа.",
adminRemoved: "Роль админа снята.",
roleUpdateFailed: "Не удалось изменить роль. Попробуйте ещё раз.",
addReaction: "Добавить реакцию",
deleteMessage: "Удалить сообщение",
deleteMessageConfirm: "Удалить это сообщение для всех?",
readBy: "Прочитали: {{count}}",
joinRequestTitle: "Запрос на приватный чат",
joinRequestFor: "Новый запрос на вступление в {{crew}}",
joinRequestReview: "Проверить запрос",
joinRequestDismiss: "Отклонить запрос",
roleMember: "Участник",
qrTitle: "Отсканируйте, чтобы войти в приватный чат",
qrHint: "Участники Crew сканируют код камерой телефона. Ссылка открывает вкладку «Приватный чат».",

View file

@ -346,6 +346,19 @@ const drift = {
members: "Medlemmar",
roleFounder: "Grundare",
roleAdmin: "Admin",
makeAdmin: "Gör till admin",
removeAdmin: "Ta bort admin",
adminAdded: "Medlem befordrad till admin.",
adminRemoved: "Adminrollen togs bort.",
roleUpdateFailed: "Kunde inte uppdatera rollen. Försök igen.",
addReaction: "Lägg till reaktion",
deleteMessage: "Ta bort meddelande",
deleteMessageConfirm: "Ta bort det här meddelandet för alla?",
readBy: "Läst av {{count}}",
joinRequestTitle: "Begäran om privat chatt",
joinRequestFor: "Ny begäran om att gå med i {{crew}}",
joinRequestReview: "Granska begäran",
joinRequestDismiss: "Avvisa begäran",
roleMember: "Medlem",
qrTitle: "Skanna för att gå med i den privata chatten",
qrHint: "Crewmedlemmar skannar det här med telefonkameran. Länken öppnar fliken Privat chatt.",

View file

@ -338,6 +338,19 @@ const driftStrings = {
members: "สมาชิก",
roleFounder: "ผู้ก่อตั้ง",
roleAdmin: "แอดมิน",
makeAdmin: "ตั้งเป็นแอดมิน",
removeAdmin: "ถอดแอดมิน",
adminAdded: "เลื่อนสมาชิกเป็นแอดมินแล้ว",
adminRemoved: "ถอดบทบาทแอดมินแล้ว",
roleUpdateFailed: "อัปเดตบทบาทไม่สำเร็จ ลองอีกครั้ง",
addReaction: "เพิ่มรีแอ็กชัน",
deleteMessage: "ลบข้อความ",
deleteMessageConfirm: "ลบข้อความนี้สำหรับทุกคนหรือไม่",
readBy: "อ่านโดย {{count}}",
joinRequestTitle: "คำขอแชทส่วนตัว",
joinRequestFor: "คำขอใหม่เพื่อเข้าร่วม {{crew}}",
joinRequestReview: "ตรวจสอบคำขอ",
joinRequestDismiss: "ปิดคำขอ",
roleMember: "สมาชิก",
qrTitle: "สแกนเพื่อเข้าร่วมแชทส่วนตัว",
qrHint: "สมาชิก crew สแกนด้วยกล้องโทรศัพท์ ลิงก์จะเปิดแท็บแชทส่วนตัว",

View file

@ -338,6 +338,19 @@ const driftStrings = {
members: "Üyeler",
roleFounder: "Kurucu",
roleAdmin: "Yönetici",
makeAdmin: "Yönetici yap",
removeAdmin: "Yöneticiliği kaldır",
adminAdded: "Üye yönetici yapıldı.",
adminRemoved: "Yönetici rolü kaldırıldı.",
roleUpdateFailed: "Rol güncellenemedi. Tekrar dene.",
addReaction: "Tepki ekle",
deleteMessage: "Mesajı sil",
deleteMessageConfirm: "Bu mesaj herkes için silinsin mi?",
readBy: "{{count}} kişi okudu",
joinRequestTitle: "Özel sohbet isteği",
joinRequestFor: "{{crew}} grubuna katılma isteği",
joinRequestReview: "İsteği incele",
joinRequestDismiss: "İsteği yoksay",
roleMember: "Üye",
qrTitle: "Özel sohbete katılmak için tara",
qrHint: "Crew üyeleri bunu telefon kamerasıyla tarar. Bağlantı Özel sohbet sekmesini açar.",

View file

@ -346,6 +346,19 @@ const drift = {
members: "Учасники",
roleFounder: "Засновник",
roleAdmin: "Адміністратор",
makeAdmin: "Зробити адміністратором",
removeAdmin: "Зняти адміністратора",
adminAdded: "Учасника підвищено до адміністратора.",
adminRemoved: "Роль адміністратора знято.",
roleUpdateFailed: "Не вдалося змінити роль. Спробуйте ще раз.",
addReaction: "Додати реакцію",
deleteMessage: "Видалити повідомлення",
deleteMessageConfirm: "Видалити це повідомлення для всіх?",
readBy: "Прочитали: {{count}}",
joinRequestTitle: "Запит на приватний чат",
joinRequestFor: "Новий запит на приєднання до {{crew}}",
joinRequestReview: "Переглянути запит",
joinRequestDismiss: "Відхилити запит",
roleMember: "Учасник",
qrTitle: "Відскануйте, щоб увійти до приватного чату",
qrHint: "Учасники команди сканують код камерою телефона. Посилання відкриває вкладку «Приватний чат».",

View file

@ -338,6 +338,19 @@ const driftStrings = {
members: "Thành viên",
roleFounder: "Người sáng lập",
roleAdmin: "Quản trị viên",
makeAdmin: "Đặt làm quản trị viên",
removeAdmin: "Bỏ quyền quản trị",
adminAdded: "Thành viên đã được thăng làm quản trị viên.",
adminRemoved: "Đã gỡ vai trò quản trị viên.",
roleUpdateFailed: "Không thể cập nhật vai trò. Hãy thử lại.",
addReaction: "Thêm cảm xúc",
deleteMessage: "Xóa tin nhắn",
deleteMessageConfirm: "Xóa tin nhắn này cho mọi người?",
readBy: "Đã xem bởi {{count}}",
joinRequestTitle: "Yêu cầu trò chuyện riêng tư",
joinRequestFor: "Yêu cầu mới tham gia {{crew}}",
joinRequestReview: "Xem xét yêu cầu",
joinRequestDismiss: "Bỏ qua yêu cầu",
roleMember: "Thành viên",
qrTitle: "Quét để tham gia chat riêng tư",
qrHint: "Thành viên crew quét mã bằng camera điện thoại. Liên kết mở tab Chat riêng tư.",

View file

@ -354,6 +354,19 @@ const drift: Dict = {
members: "成员",
roleFounder: "创始人",
roleAdmin: "管理员",
makeAdmin: "设为管理员",
removeAdmin: "取消管理员",
adminAdded: "成员已提升为管理员。",
adminRemoved: "已移除管理员角色。",
roleUpdateFailed: "无法更新角色,请重试。",
addReaction: "添加回应",
deleteMessage: "删除消息",
deleteMessageConfirm: "为所有人删除这条消息?",
readBy: "{{count}} 人已读",
joinRequestTitle: "私聊请求",
joinRequestFor: "加入 {{crew}} 的新请求",
joinRequestReview: "查看请求",
joinRequestDismiss: "忽略请求",
roleMember: "成员",
qrTitle: "扫描加入私密聊天",
qrHint: "Crew 成员用手机摄像头扫描。链接会打开“私密聊天”标签页。",

View file

@ -354,6 +354,19 @@ const drift: Dict = {
members: "成員",
roleFounder: "創始人",
roleAdmin: "管理員",
makeAdmin: "設為管理員",
removeAdmin: "取消管理員",
adminAdded: "成員已升為管理員。",
adminRemoved: "已移除管理員角色。",
roleUpdateFailed: "無法更新角色,請再試一次。",
addReaction: "新增回應",
deleteMessage: "刪除訊息",
deleteMessageConfirm: "為所有人刪除這則訊息?",
readBy: "{{count}} 人已讀",
joinRequestTitle: "私聊請求",
joinRequestFor: "加入 {{crew}} 的新請求",
joinRequestReview: "查看請求",
joinRequestDismiss: "忽略請求",
roleMember: "成員",
qrTitle: "掃描加入私密聊天",
qrHint: "Crew 成員用手機相機掃描。連結會開啟「私密聊天」分頁。",

View file

@ -75,6 +75,8 @@ export interface CordnChatMessage {
direction: 'inbound' | 'outbound';
/** Envelope ids this message causally follows (`prev` tags). */
prev: string[];
/** Decrypted envelope tags (reactions / deletes / read receipts are control tags). */
tags?: string[][];
}
export interface CordnGroupInfo {
@ -488,17 +490,25 @@ export class CordnSession {
}
/** Encrypt, post and locally adopt an application message. */
async send(gid: string, content: string, imageUrl?: string): Promise<CordnChatMessage> {
async send(
gid: string,
content: string,
imageUrl?: string,
tags?: string[][],
): Promise<CordnChatMessage> {
const group = this.requireGroup(gid);
const { newState, messageBase64, envelope } = await createGroupApplicationMessage({
state: group.state,
sender: this.identity.pubkey,
content,
imageUrl,
tags: causalPrevTags(
group.messages.map((m) => m.id),
group.messages.map((m) => m.prev),
),
tags: [
...causalPrevTags(
group.messages.map((m) => m.id),
group.messages.map((m) => m.prev),
),
...(tags ?? []),
],
});
const sealed = await sealForCoordinator({ state: group.state, messageBase64 });
const posted = await this.getClient().postGroupMessage(group.gid, sealed);
@ -513,6 +523,7 @@ export class CordnSession {
cursor: posted.cursor,
direction: 'outbound',
prev: envelope.tags.filter((t) => t[0] === 'prev').map((t) => t[1]),
tags: envelope.tags,
};
group.messages.push(stored);
group.cursor = Math.max(group.cursor, posted.cursor);
@ -520,6 +531,11 @@ export class CordnSession {
return stored;
}
/** Send an empty application message carrying control tags (reaction/delete/read). */
async sendControl(gid: string, tags: string[][]): Promise<CordnChatMessage> {
return this.send(gid, '', undefined, tags);
}
/** Bounded catch-up: fetch and decrypt everything after this group's cursor. */
async fetch(gid: string): Promise<CordnChatMessage[]> {
const group = this.requireGroup(gid);
@ -550,6 +566,7 @@ export class CordnSession {
cursor: message.cursor,
direction: sender === this.identity.pubkey ? 'outbound' : 'inbound',
prev: envelope.tags.filter((t) => t[0] === 'prev').map((t) => t[1]),
tags: envelope.tags,
};
group.messages.push(stored);
received.push(stored);

27
src/lib/cordn/watch.ts Normal file
View file

@ -0,0 +1,27 @@
/**
* Cordn join-request notifications.
*
* The coordinator stores join requests as RPC rows, not Nostr events, so a
* background watcher (scripts/cordn-join-watcher.ts) turns each new request into
* (a) a NIP-17 DM for push/OS notifications and (b) an encrypted NIP-78 event
* for the notification bell. Both are authored by this dedicated watcher key;
* it never holds or needs an admin key.
*/
export const CORDN_WATCHER_PUBKEY =
'82ce71cfb2f5ec5de8030e0ca9a185fbc920ab6badd9a6edee43620794046f34';
export const CORDN_JOIN_TAG = 'cordnjoinrequest';
export const CORDN_JOIN_D_PREFIX = 'skatespots:cordn-join';
export interface CordnJoinNotice {
/** Stable de-dupe key: gid:requester:kp_ref. */
id: string;
gid: string;
crewName: string;
crewPubkey: string;
crewIdentifier: string;
requester: string;
at: number;
/** Event id, used for dismiss state. */
eventId: string;
}

View file

@ -3,7 +3,6 @@ import { Link, useSearchParams } from 'react-router-dom';
import { EditProfileForm } from '@/components/EditProfileForm';
import { Switch } from '@/components/ui/switch';
import { useSasquatchHidden } from '@/hooks/useSasquatchHidden';
import { useMapFooterHidden } from '@/hooks/useMapFooterHidden';
import { SkaterDirectoryCard } from '@/components/SkaterDirectoryCard';
import { ProfilePreviewCard } from '@/components/ProfilePreviewCard';
import { RelayListManager } from '@/components/RelayListManager';
@ -618,7 +617,6 @@ function RelaysScreen({ onBack }: { onBack: () => void }) {
function AppearanceScreen({ onBack }: { onBack: () => void }) {
const { t } = useTranslation();
const [sasquatchHidden, setSasquatchHidden] = useSasquatchHidden();
const [mapFooterHidden, setMapFooterHidden] = useMapFooterHidden();
return (
<div>
<BackHeader title={t('settingsUi.settingsPage.appearance')} onBack={onBack} />
@ -648,13 +646,6 @@ function AppearanceScreen({ onBack }: { onBack: () => void }) {
</div>
<Switch checked={!sasquatchHidden} onCheckedChange={(v) => setSasquatchHidden(!v)} aria-label={t('settingsUi.settingsPage.showSasquatch')} />
</div>
<div className="mt-2 rounded-2xl border border-white/8 p-4 bg-white/2 flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-bold text-white">{t('settingsUi.settingsPage.showMapFooter')}</p>
<p className="text-xs text-muted-foreground">{t('settingsUi.settingsPage.showMapFooterDesc')}</p>
</div>
<Switch checked={!mapFooterHidden} onCheckedChange={(v) => setMapFooterHidden(!v)} aria-label={t('settingsUi.settingsPage.showMapFooter')} />
</div>
</div>
<div className="rounded-2xl border border-white/8 p-5 bg-white/2 text-center">