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:
parent
d230f02328
commit
66fc47cc28
43 changed files with 1459 additions and 83 deletions
50
AGENTS.md
50
AGENTS.md
|
|
@ -1542,19 +1542,38 @@ session with **no other memory of this project**. So:
|
||||||
it (`QRScannerModal`), calls `addMemberByIdentifier(gid, kpRef)`, and the new
|
it (`QRScannerModal`), calls `addMemberByIdentifier(gid, kpRef)`, and the new
|
||||||
device's `checkInvite` accepts the Welcome — no join request/founder round
|
device's `checkInvite` accepts the Welcome — no join request/founder round
|
||||||
trip. Member leaves can be removed by `leafIndex` (`removeMemberFromGroup` →
|
trip. Member leaves can be removed by `leafIndex` (`removeMemberFromGroup` →
|
||||||
`createCommit` with `defaultProposalTypes.remove`); the founder gets an
|
`createCommit` with `defaultProposalTypes.remove`). **Inline promote/demote
|
||||||
**Edit Crew** link for admin-role changes. Messages have copy/reply actions
|
(2026-10-28):** the founder gets a shield button on each non-founder member row;
|
||||||
(reply is quoted text; reactions/delete are still open). Live benchmark
|
it republishes the **existing crew event tags** with the target's `p` tag
|
||||||
(`scripts/cordn-benchmark.ts`, 30 msgs): ~489ms/send, ~20ms/fetch, snapshot
|
switched between plain and `admin` (only the founder can do this — an admin's
|
||||||
~13.8KB (~460 B/message) — fine for hundreds of messages.
|
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
|
- **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
|
(DIY/Newsletter/License/Privacy + "not sponsored by OpenSats") as an overlay
|
||||||
overlay inside the map container: a **slim full-width bar on desktop** and a
|
inside the map container: a **slim full-width bar on desktop** and a **tiny
|
||||||
**tiny collapsed "Links" chip on mobile** (expands to a compact scrollable row).
|
collapsed "Links" chip on mobile** (expands to a compact scrollable row).
|
||||||
Dismissible via `useMapFooterHidden` (`skatespots:map-footer-hidden`, same-tab
|
**Always visible (2026-10-28):** the old dismissible `useMapFooterHidden`
|
||||||
synced), restorable in **Settings → Appearance**. The map's OpenStreetMap
|
behavior/setting was removed — it was easy to hide the only footer on the map
|
||||||
attribution moved to `md:bottom-7` so the bar doesn't cover it.
|
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
|
- **Map layout — controls float (2026-09-29):** on mobile the `MapPage` toolbar
|
||||||
is **`absolute` over the map** (`pointer-events-none` wrapper + gradient +
|
is **`absolute` over the map** (`pointer-events-none` wrapper + gradient +
|
||||||
`pointer-events-auto` inner) so the map fills the whole container; `md:static`
|
`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 +
|
- **Conversion:** Docker Python 3.11 + torch 2.4.1 + transformers 4.46.3 +
|
||||||
optimum 1.24; dynamic q8; `tokenizer.json` generated from the SentencePiece
|
optimum 1.24; dynamic q8; `tokenizer.json` generated from the SentencePiece
|
||||||
models (needs protobuf + `sentencepiece_model_pb2.py`).
|
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
|
- **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
|
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).
|
needs the Zapstore update; the web deploy timer batches for 24h unless forced).
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,8 @@
|
||||||
"android:version": "node scripts/sync-android-version.mjs",
|
"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": "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",
|
"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": {
|
"dependencies": {
|
||||||
"@bitcoinerlab/secp256k1": "^1.2.0",
|
"@bitcoinerlab/secp256k1": "^1.2.0",
|
||||||
|
|
|
||||||
10
scripts/cordn-join-watcher.sh
Executable file
10
scripts/cordn-join-watcher.sh
Executable 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
|
||||||
361
scripts/cordn-join-watcher.ts
Normal file
361
scripts/cordn-join-watcher.ts
Normal 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);
|
||||||
|
});
|
||||||
9
scripts/install-cordn-watcher-timer.sh
Executable file
9
scripts/install-cordn-watcher-timer.sh
Executable 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
|
||||||
9
scripts/systemd/skatespots-cordn-watcher.service
Normal file
9
scripts/systemd/skatespots-cordn-watcher.service
Normal 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
|
||||||
10
scripts/systemd/skatespots-cordn-watcher.timer
Normal file
10
scripts/systemd/skatespots-cordn-watcher.timer
Normal 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
166
scripts/translation-matrix.mjs
Executable 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);
|
||||||
67
src/components/CordnJoinRequestCard.tsx
Normal file
67
src/components/CordnJoinRequestCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { ChevronUp, X } from 'lucide-react';
|
import { ChevronUp } from 'lucide-react';
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
import { useMapFooterHidden } from '@/hooks/useMapFooterHidden';
|
|
||||||
import { cn } from '@/lib/utils';
|
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.
|
* (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
|
* Desktop: a slim full-width bar. Mobile: a tiny collapsed "Links" chip that
|
||||||
* expands to a compact row. Dismissible, and restorable from Settings →
|
* expands to a compact row. Always visible: the map is a full-bleed route, so
|
||||||
* Appearance.
|
* this strip is the only footer there — no accidental permanent hiding.
|
||||||
*/
|
*/
|
||||||
export function MapFooterStrip() {
|
export function MapFooterStrip() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [hidden, setHidden] = useMapFooterHidden();
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
if (hidden) return null;
|
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
{ to: '/diy', label: t('nav.diy') },
|
{ to: '/diy', label: t('nav.diy') },
|
||||||
{ to: '/newsletter', label: t('nav.newsletter') },
|
{ 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" />
|
<img src="https://opensats.org/favicon.ico" alt="OpenSats" className="h-3 w-3" />
|
||||||
<span className="font-medium text-gray-400">OpenSats</span>
|
<span className="font-medium text-gray-400">OpenSats</span>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -63,13 +52,6 @@ export function MapFooterStrip() {
|
||||||
{l.label}
|
{l.label}
|
||||||
</Link>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
import { CrewInviteCard } from '@/components/CrewInviteCard';
|
import { CrewInviteCard } from '@/components/CrewInviteCard';
|
||||||
|
import { CordnJoinRequestCard } from '@/components/CordnJoinRequestCard';
|
||||||
import { JoinRequestCard } from '@/components/JoinRequestCard';
|
import { JoinRequestCard } from '@/components/JoinRequestCard';
|
||||||
import { CollectiveJoinRequestCard } from '@/components/CollectiveJoinRequestCard';
|
import { CollectiveJoinRequestCard } from '@/components/CollectiveJoinRequestCard';
|
||||||
import { ActivityNotificationCard } from '@/components/ActivityNotificationCard';
|
import { ActivityNotificationCard } from '@/components/ActivityNotificationCard';
|
||||||
|
|
@ -16,6 +17,7 @@ import { TradeNotificationCard } from '@/components/TradeNotificationCard';
|
||||||
import { ImageWithFallback } from '@/components/ImageWithFallback';
|
import { ImageWithFallback } from '@/components/ImageWithFallback';
|
||||||
import { EmptyState as SharedEmptyState } from '@/components/EmptyState';
|
import { EmptyState as SharedEmptyState } from '@/components/EmptyState';
|
||||||
import { useCrewNotifications, useIsPendingMember } from '@/hooks/useCrewNotifications';
|
import { useCrewNotifications, useIsPendingMember } from '@/hooks/useCrewNotifications';
|
||||||
|
import { useCordnJoinNotifications } from '@/hooks/useCordnJoinNotifications';
|
||||||
import { useActivityNotifications } from '@/hooks/useActivityNotifications';
|
import { useActivityNotifications } from '@/hooks/useActivityNotifications';
|
||||||
import { useTradeNotifications } from '@/hooks/useTradeNotifications';
|
import { useTradeNotifications } from '@/hooks/useTradeNotifications';
|
||||||
import { useNotificationsRead } from '@/hooks/useNotificationsRead';
|
import { useNotificationsRead } from '@/hooks/useNotificationsRead';
|
||||||
|
|
@ -213,6 +215,11 @@ export function NotificationBell({ className }: { className?: string }) {
|
||||||
const { user, invites, listedCrews, joinRequests, collectiveRequests, isLoading: crewLoading } =
|
const { user, invites, listedCrews, joinRequests, collectiveRequests, isLoading: crewLoading } =
|
||||||
useCrewNotifications();
|
useCrewNotifications();
|
||||||
const { notifications: activity, isLoading: activityLoading } = useActivityNotifications();
|
const { notifications: activity, isLoading: activityLoading } = useActivityNotifications();
|
||||||
|
const {
|
||||||
|
requests: cordnJoinRequests,
|
||||||
|
isLoading: cordnJoinLoading,
|
||||||
|
dismiss: dismissCordnJoin,
|
||||||
|
} = useCordnJoinNotifications();
|
||||||
const { lastRead, seenAt, markAllRead } = useNotificationsRead();
|
const { lastRead, seenAt, markAllRead } = useNotificationsRead();
|
||||||
|
|
||||||
// Buying/selling steps: an offer on your listing, a counter it's your turn to
|
// 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
|
// Total actionable crew items — these always RENDER in the Crews/All tabs
|
||||||
// until the user acts on them (accept/decline/confirm).
|
// until the user acts on them (accept/decline/confirm).
|
||||||
const crewTotal = useMemo(
|
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
|
// 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 crew of listedCrews) if (ts(crew) > seenAt) count++;
|
||||||
for (const { request } of joinRequests) if (ts(request) > 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 collectiveRequests) if (ts(req) > seenAt) count++;
|
||||||
|
for (const req of cordnJoinRequests) if (req.at > seenAt) count++;
|
||||||
return 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.
|
// Unread social items = activity newer than the last time the bell was opened.
|
||||||
const unreadActivity = useMemo(
|
const unreadActivity = useMemo(
|
||||||
|
|
@ -277,7 +290,7 @@ export function NotificationBell({ className }: { className?: string }) {
|
||||||
}
|
}
|
||||||
}, [open, markAllRead, markNewsletterRead]);
|
}, [open, markAllRead, markNewsletterRead]);
|
||||||
|
|
||||||
const isLoading = crewLoading || activityLoading || tradeLoading;
|
const isLoading = crewLoading || activityLoading || tradeLoading || cordnJoinLoading;
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
|
|
@ -414,6 +427,14 @@ export function NotificationBell({ className }: { className?: string }) {
|
||||||
{joinRequests.map(({ request, crew, endorsements }) => (
|
{joinRequests.map(({ request, crew, endorsements }) => (
|
||||||
<JoinRequestCard key={request.id} request={request} crew={crew} endorsements={endorsements} compact />
|
<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) => (
|
{collectiveRequests.map((request) => (
|
||||||
<CollectiveJoinRequestCard key={request.id} request={request} compact />
|
<CollectiveJoinRequestCard key={request.id} request={request} compact />
|
||||||
))}
|
))}
|
||||||
|
|
@ -534,6 +555,14 @@ export function NotificationBell({ className }: { className?: string }) {
|
||||||
{joinRequests.map(({ request, crew, endorsements }) => (
|
{joinRequests.map(({ request, crew, endorsements }) => (
|
||||||
<JoinRequestCard key={request.id} request={request} crew={crew} endorsements={endorsements} compact />
|
<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) => (
|
{collectiveRequests.map((request) => (
|
||||||
<CollectiveJoinRequestCard key={request.id} request={request} compact />
|
<CollectiveJoinRequestCard key={request.id} request={request} compact />
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import type { ReactNode } from 'react';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Check,
|
Check,
|
||||||
|
CheckCheck,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Copy,
|
Copy,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
|
@ -18,6 +19,9 @@ import {
|
||||||
ScanLine,
|
ScanLine,
|
||||||
Send,
|
Send,
|
||||||
Share2,
|
Share2,
|
||||||
|
ShieldMinus,
|
||||||
|
ShieldPlus,
|
||||||
|
SmilePlus,
|
||||||
Trash2,
|
Trash2,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
Users,
|
Users,
|
||||||
|
|
@ -41,6 +45,8 @@ import { QRScannerModal } from '@/components/wallet/QRScannerModal';
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
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';
|
||||||
|
|
@ -48,6 +54,7 @@ import { isNativePlatform } from '@/lib/platform';
|
||||||
import { formatDistanceToNow } from '@/lib/dateFnsI18n';
|
import { formatDistanceToNow } from '@/lib/dateFnsI18n';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { CordnRoom } from '@/lib/cordn/room';
|
import type { CordnRoom } from '@/lib/cordn/room';
|
||||||
|
import { SKATE_CREW_KIND, type SkateCrew } from '@/lib/types';
|
||||||
import { readCordnSeen, writeCordnSeen } from '@/lib/cordn/unread';
|
import { readCordnSeen, writeCordnSeen } from '@/lib/cordn/unread';
|
||||||
import { clearPendingJoin, readPendingJoin, writePendingJoin } from '@/lib/cordn/pendingJoin';
|
import { clearPendingJoin, readPendingJoin, writePendingJoin } from '@/lib/cordn/pendingJoin';
|
||||||
|
|
||||||
|
|
@ -100,7 +107,13 @@ export function CordnNativePanel({
|
||||||
onRequestsChange,
|
onRequestsChange,
|
||||||
}: CordnNativePanelProps) {
|
}: CordnNativePanelProps) {
|
||||||
const { t } = useTranslation();
|
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 isCommunity = scope === 'community';
|
||||||
|
const sourceCrew = crew as SkateCrew;
|
||||||
|
const canChangeRoles = !isCommunity && user?.pubkey === crew.pubkey && !!sourceCrew.event;
|
||||||
const copy = {
|
const copy = {
|
||||||
title: isCommunity ? t('communityUi.title') : t('cordnUi.title'),
|
title: isCommunity ? t('communityUi.title') : t('cordnUi.title'),
|
||||||
subtitle: isCommunity ? t('communityUi.subtitle') : t('cordnUi.subtitle'),
|
subtitle: isCommunity ? t('communityUi.subtitle') : t('cordnUi.subtitle'),
|
||||||
|
|
@ -142,13 +155,14 @@ export function CordnNativePanel({
|
||||||
t('cordnUi.onboardingMember3'),
|
t('cordnUi.onboardingMember3'),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
const { user } = useCurrentUser();
|
|
||||||
const native = useCordnNative({ crew, enabled: !!user && isMember, active });
|
const native = useCordnNative({ crew, enabled: !!user && isMember, active });
|
||||||
// Stable callbacks (the hook's return object is recreated each render).
|
// Stable callbacks (the hook's return object is recreated each render).
|
||||||
const sendNative = native.send;
|
const sendNative = native.send;
|
||||||
const checkInviteNative = native.checkInvite;
|
const checkInviteNative = native.checkInvite;
|
||||||
const [draft, setDraft] = useState('');
|
const [draft, setDraft] = useState('');
|
||||||
const [replyTo, setReplyTo] = useState<{ id: string; content: string } | null>(null);
|
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 [copied, setCopied] = useState(false);
|
||||||
const [copiedLink, setCopiedLink] = useState(false);
|
const [copiedLink, setCopiedLink] = useState(false);
|
||||||
const [copiedDevice, setCopiedDevice] = useState(false);
|
const [copiedDevice, setCopiedDevice] = useState(false);
|
||||||
|
|
@ -182,6 +196,18 @@ export function CordnNativePanel({
|
||||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
|
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
|
||||||
}, [native.messages.length, pending?.state]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!user?.pubkey) return;
|
if (!user?.pubkey) return;
|
||||||
setAwaiting(readPendingJoin(user.pubkey, native.groupId));
|
setAwaiting(readPendingJoin(user.pubkey, native.groupId));
|
||||||
|
|
@ -501,6 +527,29 @@ export function CordnNativePanel({
|
||||||
const roleOf = (pubkey: string): MemberRole =>
|
const roleOf = (pubkey: string): MemberRole =>
|
||||||
pubkey === crew.pubkey ? 'founder' : crew.admins.includes(pubkey) ? 'admin' : 'member';
|
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 (
|
return (
|
||||||
<Card className="border-white/10 bg-black/40">
|
<Card className="border-white/10 bg-black/40">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
|
|
@ -716,6 +765,9 @@ export function CordnNativePanel({
|
||||||
founderLabel={copy.owner}
|
founderLabel={copy.owner}
|
||||||
botPubkey={botPubkey}
|
botPubkey={botPubkey}
|
||||||
botName={copy.botName}
|
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}
|
canRemove={canManage && member.pubkey !== user.pubkey && member.pubkey !== crew.pubkey}
|
||||||
onRemove={() => {
|
onRemove={() => {
|
||||||
if (!window.confirm(t('cordnUi.removeMemberConfirm', { name: shortNpub(member.pubkey) }))) return;
|
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')}
|
<Check className="h-3 w-3" /> {t('cordnUi.sent')}
|
||||||
</span>
|
</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">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void copyMessage(m.content)}
|
onClick={() => void copyMessage(m.content)}
|
||||||
|
|
@ -790,6 +856,20 @@ export function CordnNativePanel({
|
||||||
>
|
>
|
||||||
<Reply className="h-3 w-3" />
|
<Reply className="h-3 w-3" />
|
||||||
</button>
|
</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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|
@ -812,6 +892,44 @@ export function CordnNativePanel({
|
||||||
<div className="whitespace-pre-wrap break-words">{m.content}</div>
|
<div className="whitespace-pre-wrap break-words">{m.content}</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
@ -1230,6 +1348,9 @@ function MemberRow({
|
||||||
botName,
|
botName,
|
||||||
canRemove,
|
canRemove,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
canChangeRole,
|
||||||
|
roleBusy,
|
||||||
|
onToggleAdmin,
|
||||||
}: {
|
}: {
|
||||||
pubkey: string;
|
pubkey: string;
|
||||||
role: MemberRole;
|
role: MemberRole;
|
||||||
|
|
@ -1239,6 +1360,9 @@ function MemberRow({
|
||||||
botName?: string;
|
botName?: string;
|
||||||
canRemove?: boolean;
|
canRemove?: boolean;
|
||||||
onRemove?: () => void;
|
onRemove?: () => void;
|
||||||
|
canChangeRole?: boolean;
|
||||||
|
roleBusy?: boolean;
|
||||||
|
onToggleAdmin?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const label =
|
const label =
|
||||||
|
|
@ -1262,6 +1386,25 @@ function MemberRow({
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</Badge>
|
</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 && (
|
{canRemove && onRemove && (
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|
|
||||||
116
src/hooks/useCordnJoinNotifications.ts
Normal file
116
src/hooks/useCordnJoinNotifications.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -60,6 +60,16 @@ export interface UseCordnNativeResult {
|
||||||
* Returns the stored message on success, or `null` when the send failed.
|
* Returns the stored message on success, or `null` when the send failed.
|
||||||
*/
|
*/
|
||||||
send: (text: string, imageUrl?: string) => Promise<CordnChatMessage | null>;
|
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). */
|
/** 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. */
|
||||||
|
|
@ -261,6 +271,93 @@ export function useCordnNative({
|
||||||
[group, run],
|
[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 createChat = useCallback(async () => {
|
||||||
const session = sessionRef.current;
|
const session = sessionRef.current;
|
||||||
if (!session || !crew || !groupId) return false;
|
if (!session || !crew || !groupId) return false;
|
||||||
|
|
@ -406,10 +503,15 @@ export function useCordnNative({
|
||||||
coordinator,
|
coordinator,
|
||||||
groupId,
|
groupId,
|
||||||
group,
|
group,
|
||||||
messages,
|
messages: visibleMessages,
|
||||||
|
reactions,
|
||||||
|
readBy,
|
||||||
busy,
|
busy,
|
||||||
error,
|
error,
|
||||||
send,
|
send,
|
||||||
|
toggleReaction,
|
||||||
|
deleteMessage,
|
||||||
|
markRead,
|
||||||
refresh,
|
refresh,
|
||||||
createChat,
|
createChat,
|
||||||
requestJoin,
|
requestJoin,
|
||||||
|
|
|
||||||
|
|
@ -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];
|
|
||||||
}
|
|
||||||
|
|
@ -964,6 +964,19 @@ const batch1 = {
|
||||||
members: "Membres",
|
members: "Membres",
|
||||||
roleFounder: "Fundador",
|
roleFounder: "Fundador",
|
||||||
roleAdmin: "Administrador",
|
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",
|
roleMember: "Membre",
|
||||||
qrTitle: "Escaneja per unir-te al xat privat",
|
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.",
|
qrHint: "Els membres de la colla ho escanegen amb la càmera del mòbil. L’enllaç obre la pestanya Xat privat.",
|
||||||
|
|
|
||||||
|
|
@ -342,6 +342,19 @@ const drift = {
|
||||||
members: "Členové",
|
members: "Členové",
|
||||||
roleFounder: "Zakladatel",
|
roleFounder: "Zakladatel",
|
||||||
roleAdmin: "Administrátor",
|
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",
|
roleMember: "Člen",
|
||||||
qrTitle: "Naskenuj a připoj se k soukromému chatu",
|
qrTitle: "Naskenuj a připoj se k soukromému chatu",
|
||||||
qrHint: "Členové crew to naskenují kamerou telefonu. Odkaz otevře kartu Soukromý chat.",
|
qrHint: "Členové crew to naskenují kamerou telefonu. Odkaz otevře kartu Soukromý chat.",
|
||||||
|
|
|
||||||
|
|
@ -344,6 +344,19 @@ const drift = {
|
||||||
members: "Mitglieder",
|
members: "Mitglieder",
|
||||||
roleFounder: "Gründer",
|
roleFounder: "Gründer",
|
||||||
roleAdmin: "Admin",
|
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",
|
roleMember: "Mitglied",
|
||||||
qrTitle: "Scannen und dem privaten Chat beitreten",
|
qrTitle: "Scannen und dem privaten Chat beitreten",
|
||||||
qrHint: "Crew-Mitglieder scannen das mit der Handykamera. Der Link öffnet den Tab „Privater Chat“.",
|
qrHint: "Crew-Mitglieder scannen das mit der Handykamera. Der Link öffnet den Tab „Privater Chat“.",
|
||||||
|
|
|
||||||
|
|
@ -346,6 +346,19 @@ const drift = {
|
||||||
members: "Μέλη",
|
members: "Μέλη",
|
||||||
roleFounder: "Ιδρυτής",
|
roleFounder: "Ιδρυτής",
|
||||||
roleAdmin: "Διαχειριστής",
|
roleAdmin: "Διαχειριστής",
|
||||||
|
makeAdmin: "Ορισμός ως διαχειριστή",
|
||||||
|
removeAdmin: "Αφαίρεση διαχειριστή",
|
||||||
|
adminAdded: "Το μέλος έγινε διαχειριστής.",
|
||||||
|
adminRemoved: "Ο ρόλος διαχειριστή αφαιρέθηκε.",
|
||||||
|
roleUpdateFailed: "Δεν ήταν δυνατή η αλλαγή ρόλου. Δοκίμασε ξανά.",
|
||||||
|
addReaction: "Προσθήκη αντίδρασης",
|
||||||
|
deleteMessage: "Διαγραφή μηνύματος",
|
||||||
|
deleteMessageConfirm: "Διαγραφή αυτού του μηνύματος για όλους;",
|
||||||
|
readBy: "Διαβάστηκε από {{count}}",
|
||||||
|
joinRequestTitle: "Αίτημα ιδιωτικής συνομιλίας",
|
||||||
|
joinRequestFor: "Νέο αίτημα συμμετοχής στο {{crew}}",
|
||||||
|
joinRequestReview: "Έλεγχος αιτήματος",
|
||||||
|
joinRequestDismiss: "Απόρριψη αιτήματος",
|
||||||
roleMember: "Μέλος",
|
roleMember: "Μέλος",
|
||||||
qrTitle: "Σαρώστε για να μπείτε στην ιδιωτική συνομιλία",
|
qrTitle: "Σαρώστε για να μπείτε στην ιδιωτική συνομιλία",
|
||||||
qrHint: "Τα μέλη του crew το σαρώνουν με την κάμερα του κινητού. Ο σύνδεσμος ανοίγει την καρτέλα Ιδιωτική συνομιλία.",
|
qrHint: "Τα μέλη του crew το σαρώνουν με την κάμερα του κινητού. Ο σύνδεσμος ανοίγει την καρτέλα Ιδιωτική συνομιλία.",
|
||||||
|
|
|
||||||
|
|
@ -964,6 +964,19 @@ const batch1 = {
|
||||||
members: "Kideak",
|
members: "Kideak",
|
||||||
roleFounder: "Sortzailea",
|
roleFounder: "Sortzailea",
|
||||||
roleAdmin: "Administratzailea",
|
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",
|
roleMember: "Kidea",
|
||||||
qrTitle: "Eskaneatu txat pribatura sartzeko",
|
qrTitle: "Eskaneatu txat pribatura sartzeko",
|
||||||
qrHint: "Taldeko kideek telefonoaren kamerarekin eskaneatzen dute. Estekak Txat pribatua fitxa irekitzen du.",
|
qrHint: "Taldeko kideek telefonoaren kamerarekin eskaneatzen dute. Estekak Txat pribatua fitxa irekitzen du.",
|
||||||
|
|
|
||||||
|
|
@ -344,6 +344,19 @@ const drift = {
|
||||||
members: "Membres",
|
members: "Membres",
|
||||||
roleFounder: "Fondateur",
|
roleFounder: "Fondateur",
|
||||||
roleAdmin: "Admin",
|
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",
|
roleMember: "Membre",
|
||||||
qrTitle: "Scannez pour rejoindre le chat privé",
|
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é.",
|
qrHint: "Les membres de la Crew le scannent avec l’appareil photo de leur téléphone. Le lien ouvre l’onglet Chat privé.",
|
||||||
|
|
|
||||||
|
|
@ -964,6 +964,19 @@ const batch1 = {
|
||||||
members: "Membros",
|
members: "Membros",
|
||||||
roleFounder: "Fundador",
|
roleFounder: "Fundador",
|
||||||
roleAdmin: "Administrador",
|
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",
|
roleMember: "Membro",
|
||||||
qrTitle: "Escanea para entrar no chat privado",
|
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.",
|
qrHint: "Os membros da crew escanéano coa cámara do móbil. A ligazón abre a pestana Chat privado.",
|
||||||
|
|
|
||||||
|
|
@ -964,6 +964,19 @@ const batch1 = {
|
||||||
members: "सदस्य",
|
members: "सदस्य",
|
||||||
roleFounder: "संस्थापक",
|
roleFounder: "संस्थापक",
|
||||||
roleAdmin: "एडमिन",
|
roleAdmin: "एडमिन",
|
||||||
|
makeAdmin: "एडमिन बनाएँ",
|
||||||
|
removeAdmin: "एडमिन हटाएँ",
|
||||||
|
adminAdded: "सदस्य को एडमिन बनाया गया।",
|
||||||
|
adminRemoved: "एडमिन भूमिका हटाई गई।",
|
||||||
|
roleUpdateFailed: "भूमिका अपडेट नहीं हो सकी। फिर कोशिश करें।",
|
||||||
|
addReaction: "प्रतिक्रिया जोड़ें",
|
||||||
|
deleteMessage: "संदेश हटाएँ",
|
||||||
|
deleteMessageConfirm: "यह संदेश सभी के लिए हटाएँ?",
|
||||||
|
readBy: "{{count}} ने पढ़ा",
|
||||||
|
joinRequestTitle: "निजी चैट अनुरोध",
|
||||||
|
joinRequestFor: "{{crew}} में शामिल होने का नया अनुरोध",
|
||||||
|
joinRequestReview: "अनुरोध की समीक्षा करें",
|
||||||
|
joinRequestDismiss: "अनुरोध खारिज करें",
|
||||||
roleMember: "सदस्य",
|
roleMember: "सदस्य",
|
||||||
qrTitle: "निजी चैट में शामिल होने के लिए स्कैन करें",
|
qrTitle: "निजी चैट में शामिल होने के लिए स्कैन करें",
|
||||||
qrHint: "क्रू सदस्य इसे फ़ोन कैमरे से स्कैन करते हैं। लिंक निजी चैट टैब खोलता है।",
|
qrHint: "क्रू सदस्य इसे फ़ोन कैमरे से स्कैन करते हैं। लिंक निजी चैट टैब खोलता है।",
|
||||||
|
|
|
||||||
|
|
@ -338,6 +338,19 @@ const driftStrings = {
|
||||||
members: "Anggota",
|
members: "Anggota",
|
||||||
roleFounder: "Pendiri",
|
roleFounder: "Pendiri",
|
||||||
roleAdmin: "Admin",
|
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",
|
roleMember: "Anggota",
|
||||||
qrTitle: "Pindai untuk bergabung ke chat privat",
|
qrTitle: "Pindai untuk bergabung ke chat privat",
|
||||||
qrHint: "Anggota crew memindai ini dengan kamera ponsel. Tautan membuka tab Chat privat.",
|
qrHint: "Anggota crew memindai ini dengan kamera ponsel. Tautan membuka tab Chat privat.",
|
||||||
|
|
|
||||||
|
|
@ -344,6 +344,19 @@ const drift = {
|
||||||
members: "Membri",
|
members: "Membri",
|
||||||
roleFounder: "Fondatore",
|
roleFounder: "Fondatore",
|
||||||
roleAdmin: "Admin",
|
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",
|
roleMember: "Membro",
|
||||||
qrTitle: "Scansiona per unirti alla chat privata",
|
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.",
|
qrHint: "I membri della crew lo scansionano con la fotocamera del telefono. Il link apre la scheda Chat privata.",
|
||||||
|
|
|
||||||
|
|
@ -354,6 +354,19 @@ const drift: Dict = {
|
||||||
members: "メンバー",
|
members: "メンバー",
|
||||||
roleFounder: "創設者",
|
roleFounder: "創設者",
|
||||||
roleAdmin: "管理者",
|
roleAdmin: "管理者",
|
||||||
|
makeAdmin: "管理者にする",
|
||||||
|
removeAdmin: "管理者を解除",
|
||||||
|
adminAdded: "メンバーを管理者にしました。",
|
||||||
|
adminRemoved: "管理者権限を解除しました。",
|
||||||
|
roleUpdateFailed: "役割を更新できませんでした。もう一度お試しください。",
|
||||||
|
addReaction: "リアクションを追加",
|
||||||
|
deleteMessage: "メッセージを削除",
|
||||||
|
deleteMessageConfirm: "このメッセージを全員から削除しますか?",
|
||||||
|
readBy: "{{count}}人が既読",
|
||||||
|
joinRequestTitle: "プライベートチャットのリクエスト",
|
||||||
|
joinRequestFor: "{{crew}}への参加リクエスト",
|
||||||
|
joinRequestReview: "リクエストを確認",
|
||||||
|
joinRequestDismiss: "リクエストを却下",
|
||||||
roleMember: "メンバー",
|
roleMember: "メンバー",
|
||||||
qrTitle: "スキャンしてプライベートチャットに参加",
|
qrTitle: "スキャンしてプライベートチャットに参加",
|
||||||
qrHint: "クルーメンバーがスマートフォンのカメラでスキャンします。リンクからプライベートチャットタブが開きます。",
|
qrHint: "クルーメンバーがスマートフォンのカメラでスキャンします。リンクからプライベートチャットタブが開きます。",
|
||||||
|
|
|
||||||
|
|
@ -354,6 +354,19 @@ const drift: Dict = {
|
||||||
members: "멤버",
|
members: "멤버",
|
||||||
roleFounder: "창립자",
|
roleFounder: "창립자",
|
||||||
roleAdmin: "관리자",
|
roleAdmin: "관리자",
|
||||||
|
makeAdmin: "관리자로 지정",
|
||||||
|
removeAdmin: "관리자 해제",
|
||||||
|
adminAdded: "멤버를 관리자로 승격했습니다.",
|
||||||
|
adminRemoved: "관리자 권한을 해제했습니다.",
|
||||||
|
roleUpdateFailed: "역할을 변경하지 못했습니다. 다시 시도하세요.",
|
||||||
|
addReaction: "반응 추가",
|
||||||
|
deleteMessage: "메시지 삭제",
|
||||||
|
deleteMessageConfirm: "이 메시지를 모든 사람에게서 삭제할까요?",
|
||||||
|
readBy: "{{count}}명이 읽음",
|
||||||
|
joinRequestTitle: "비공개 채팅 요청",
|
||||||
|
joinRequestFor: "{{crew}} 참여 요청",
|
||||||
|
joinRequestReview: "요청 검토",
|
||||||
|
joinRequestDismiss: "요청 무시",
|
||||||
roleMember: "멤버",
|
roleMember: "멤버",
|
||||||
qrTitle: "스캔하여 비공개 채팅 참여",
|
qrTitle: "스캔하여 비공개 채팅 참여",
|
||||||
qrHint: "크루 멤버가 휴대폰 카메라로 스캔합니다. 링크를 열면 비공개 채팅 탭으로 이동합니다.",
|
qrHint: "크루 멤버가 휴대폰 카메라로 스캔합니다. 링크를 열면 비공개 채팅 탭으로 이동합니다.",
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,19 @@ const cordnUi = {
|
||||||
members: 'Members',
|
members: 'Members',
|
||||||
roleFounder: 'Founder',
|
roleFounder: 'Founder',
|
||||||
roleAdmin: 'Admin',
|
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',
|
roleMember: 'Member',
|
||||||
qrTitle: 'Scan to join the private chat',
|
qrTitle: 'Scan to join the private chat',
|
||||||
qrHint:
|
qrHint:
|
||||||
|
|
@ -156,6 +169,19 @@ const cordnUi = {
|
||||||
members: 'Miembros',
|
members: 'Miembros',
|
||||||
roleFounder: 'Fundador',
|
roleFounder: 'Fundador',
|
||||||
roleAdmin: 'Admin',
|
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',
|
roleMember: 'Miembro',
|
||||||
qrTitle: 'Escanea para entrar al chat privado',
|
qrTitle: 'Escanea para entrar al chat privado',
|
||||||
qrHint:
|
qrHint:
|
||||||
|
|
|
||||||
|
|
@ -342,6 +342,19 @@ const drift = {
|
||||||
members: "Leden",
|
members: "Leden",
|
||||||
roleFounder: "Oprichter",
|
roleFounder: "Oprichter",
|
||||||
roleAdmin: "Admin",
|
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",
|
roleMember: "Lid",
|
||||||
qrTitle: "Scan om deel te nemen aan de privéchat",
|
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.",
|
qrHint: "Crewleden scannen dit met de camera van hun telefoon. De link opent het tabblad Privéchat.",
|
||||||
|
|
|
||||||
|
|
@ -342,6 +342,19 @@ const drift = {
|
||||||
members: "Członkowie",
|
members: "Członkowie",
|
||||||
roleFounder: "Założyciel",
|
roleFounder: "Założyciel",
|
||||||
roleAdmin: "Administrator",
|
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",
|
roleMember: "Członek",
|
||||||
qrTitle: "Zeskanuj, aby dołączyć do prywatnego czatu",
|
qrTitle: "Zeskanuj, aby dołączyć do prywatnego czatu",
|
||||||
qrHint: "Członkowie crew skanują to aparatem telefonu. Link otwiera kartę Prywatny czat.",
|
qrHint: "Członkowie crew skanują to aparatem telefonu. Link otwiera kartę Prywatny czat.",
|
||||||
|
|
|
||||||
|
|
@ -344,6 +344,19 @@ const drift = {
|
||||||
members: "Membros",
|
members: "Membros",
|
||||||
roleFounder: "Fundador",
|
roleFounder: "Fundador",
|
||||||
roleAdmin: "Admin",
|
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",
|
roleMember: "Membro",
|
||||||
qrTitle: "Escaneie para entrar no chat privado",
|
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.",
|
qrHint: "Os membros da crew escaneiam com a câmera do celular. O link abre a aba Chat privado.",
|
||||||
|
|
|
||||||
|
|
@ -342,6 +342,19 @@ const drift = {
|
||||||
members: "Membri",
|
members: "Membri",
|
||||||
roleFounder: "Fondator",
|
roleFounder: "Fondator",
|
||||||
roleAdmin: "Admin",
|
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",
|
roleMember: "Membru",
|
||||||
qrTitle: "Scanează pentru a intra în chatul privat",
|
qrTitle: "Scanează pentru a intra în chatul privat",
|
||||||
qrHint: "Membrii crew-ului scanează cu camera telefonului. Linkul deschide fila Chat privat.",
|
qrHint: "Membrii crew-ului scanează cu camera telefonului. Linkul deschide fila Chat privat.",
|
||||||
|
|
|
||||||
|
|
@ -346,6 +346,19 @@ const drift = {
|
||||||
members: "Участники",
|
members: "Участники",
|
||||||
roleFounder: "Основатель",
|
roleFounder: "Основатель",
|
||||||
roleAdmin: "Администратор",
|
roleAdmin: "Администратор",
|
||||||
|
makeAdmin: "Назначить админом",
|
||||||
|
removeAdmin: "Снять админа",
|
||||||
|
adminAdded: "Участник повышен до админа.",
|
||||||
|
adminRemoved: "Роль админа снята.",
|
||||||
|
roleUpdateFailed: "Не удалось изменить роль. Попробуйте ещё раз.",
|
||||||
|
addReaction: "Добавить реакцию",
|
||||||
|
deleteMessage: "Удалить сообщение",
|
||||||
|
deleteMessageConfirm: "Удалить это сообщение для всех?",
|
||||||
|
readBy: "Прочитали: {{count}}",
|
||||||
|
joinRequestTitle: "Запрос на приватный чат",
|
||||||
|
joinRequestFor: "Новый запрос на вступление в {{crew}}",
|
||||||
|
joinRequestReview: "Проверить запрос",
|
||||||
|
joinRequestDismiss: "Отклонить запрос",
|
||||||
roleMember: "Участник",
|
roleMember: "Участник",
|
||||||
qrTitle: "Отсканируйте, чтобы войти в приватный чат",
|
qrTitle: "Отсканируйте, чтобы войти в приватный чат",
|
||||||
qrHint: "Участники Crew сканируют код камерой телефона. Ссылка открывает вкладку «Приватный чат».",
|
qrHint: "Участники Crew сканируют код камерой телефона. Ссылка открывает вкладку «Приватный чат».",
|
||||||
|
|
|
||||||
|
|
@ -346,6 +346,19 @@ const drift = {
|
||||||
members: "Medlemmar",
|
members: "Medlemmar",
|
||||||
roleFounder: "Grundare",
|
roleFounder: "Grundare",
|
||||||
roleAdmin: "Admin",
|
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",
|
roleMember: "Medlem",
|
||||||
qrTitle: "Skanna för att gå med i den privata chatten",
|
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.",
|
qrHint: "Crewmedlemmar skannar det här med telefonkameran. Länken öppnar fliken Privat chatt.",
|
||||||
|
|
|
||||||
|
|
@ -338,6 +338,19 @@ const driftStrings = {
|
||||||
members: "สมาชิก",
|
members: "สมาชิก",
|
||||||
roleFounder: "ผู้ก่อตั้ง",
|
roleFounder: "ผู้ก่อตั้ง",
|
||||||
roleAdmin: "แอดมิน",
|
roleAdmin: "แอดมิน",
|
||||||
|
makeAdmin: "ตั้งเป็นแอดมิน",
|
||||||
|
removeAdmin: "ถอดแอดมิน",
|
||||||
|
adminAdded: "เลื่อนสมาชิกเป็นแอดมินแล้ว",
|
||||||
|
adminRemoved: "ถอดบทบาทแอดมินแล้ว",
|
||||||
|
roleUpdateFailed: "อัปเดตบทบาทไม่สำเร็จ ลองอีกครั้ง",
|
||||||
|
addReaction: "เพิ่มรีแอ็กชัน",
|
||||||
|
deleteMessage: "ลบข้อความ",
|
||||||
|
deleteMessageConfirm: "ลบข้อความนี้สำหรับทุกคนหรือไม่",
|
||||||
|
readBy: "อ่านโดย {{count}}",
|
||||||
|
joinRequestTitle: "คำขอแชทส่วนตัว",
|
||||||
|
joinRequestFor: "คำขอใหม่เพื่อเข้าร่วม {{crew}}",
|
||||||
|
joinRequestReview: "ตรวจสอบคำขอ",
|
||||||
|
joinRequestDismiss: "ปิดคำขอ",
|
||||||
roleMember: "สมาชิก",
|
roleMember: "สมาชิก",
|
||||||
qrTitle: "สแกนเพื่อเข้าร่วมแชทส่วนตัว",
|
qrTitle: "สแกนเพื่อเข้าร่วมแชทส่วนตัว",
|
||||||
qrHint: "สมาชิก crew สแกนด้วยกล้องโทรศัพท์ ลิงก์จะเปิดแท็บแชทส่วนตัว",
|
qrHint: "สมาชิก crew สแกนด้วยกล้องโทรศัพท์ ลิงก์จะเปิดแท็บแชทส่วนตัว",
|
||||||
|
|
|
||||||
|
|
@ -338,6 +338,19 @@ const driftStrings = {
|
||||||
members: "Üyeler",
|
members: "Üyeler",
|
||||||
roleFounder: "Kurucu",
|
roleFounder: "Kurucu",
|
||||||
roleAdmin: "Yönetici",
|
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",
|
roleMember: "Üye",
|
||||||
qrTitle: "Özel sohbete katılmak için tara",
|
qrTitle: "Özel sohbete katılmak için tara",
|
||||||
qrHint: "Crew üyeleri bunu telefon kamerasıyla tarar. Bağlantı Özel sohbet sekmesini açar.",
|
qrHint: "Crew üyeleri bunu telefon kamerasıyla tarar. Bağlantı Özel sohbet sekmesini açar.",
|
||||||
|
|
|
||||||
|
|
@ -346,6 +346,19 @@ const drift = {
|
||||||
members: "Учасники",
|
members: "Учасники",
|
||||||
roleFounder: "Засновник",
|
roleFounder: "Засновник",
|
||||||
roleAdmin: "Адміністратор",
|
roleAdmin: "Адміністратор",
|
||||||
|
makeAdmin: "Зробити адміністратором",
|
||||||
|
removeAdmin: "Зняти адміністратора",
|
||||||
|
adminAdded: "Учасника підвищено до адміністратора.",
|
||||||
|
adminRemoved: "Роль адміністратора знято.",
|
||||||
|
roleUpdateFailed: "Не вдалося змінити роль. Спробуйте ще раз.",
|
||||||
|
addReaction: "Додати реакцію",
|
||||||
|
deleteMessage: "Видалити повідомлення",
|
||||||
|
deleteMessageConfirm: "Видалити це повідомлення для всіх?",
|
||||||
|
readBy: "Прочитали: {{count}}",
|
||||||
|
joinRequestTitle: "Запит на приватний чат",
|
||||||
|
joinRequestFor: "Новий запит на приєднання до {{crew}}",
|
||||||
|
joinRequestReview: "Переглянути запит",
|
||||||
|
joinRequestDismiss: "Відхилити запит",
|
||||||
roleMember: "Учасник",
|
roleMember: "Учасник",
|
||||||
qrTitle: "Відскануйте, щоб увійти до приватного чату",
|
qrTitle: "Відскануйте, щоб увійти до приватного чату",
|
||||||
qrHint: "Учасники команди сканують код камерою телефона. Посилання відкриває вкладку «Приватний чат».",
|
qrHint: "Учасники команди сканують код камерою телефона. Посилання відкриває вкладку «Приватний чат».",
|
||||||
|
|
|
||||||
|
|
@ -338,6 +338,19 @@ const driftStrings = {
|
||||||
members: "Thành viên",
|
members: "Thành viên",
|
||||||
roleFounder: "Người sáng lập",
|
roleFounder: "Người sáng lập",
|
||||||
roleAdmin: "Quản trị viên",
|
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",
|
roleMember: "Thành viên",
|
||||||
qrTitle: "Quét để tham gia chat riêng tư",
|
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ư.",
|
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ư.",
|
||||||
|
|
|
||||||
|
|
@ -354,6 +354,19 @@ const drift: Dict = {
|
||||||
members: "成员",
|
members: "成员",
|
||||||
roleFounder: "创始人",
|
roleFounder: "创始人",
|
||||||
roleAdmin: "管理员",
|
roleAdmin: "管理员",
|
||||||
|
makeAdmin: "设为管理员",
|
||||||
|
removeAdmin: "取消管理员",
|
||||||
|
adminAdded: "成员已提升为管理员。",
|
||||||
|
adminRemoved: "已移除管理员角色。",
|
||||||
|
roleUpdateFailed: "无法更新角色,请重试。",
|
||||||
|
addReaction: "添加回应",
|
||||||
|
deleteMessage: "删除消息",
|
||||||
|
deleteMessageConfirm: "为所有人删除这条消息?",
|
||||||
|
readBy: "{{count}} 人已读",
|
||||||
|
joinRequestTitle: "私聊请求",
|
||||||
|
joinRequestFor: "加入 {{crew}} 的新请求",
|
||||||
|
joinRequestReview: "查看请求",
|
||||||
|
joinRequestDismiss: "忽略请求",
|
||||||
roleMember: "成员",
|
roleMember: "成员",
|
||||||
qrTitle: "扫描加入私密聊天",
|
qrTitle: "扫描加入私密聊天",
|
||||||
qrHint: "Crew 成员用手机摄像头扫描。链接会打开“私密聊天”标签页。",
|
qrHint: "Crew 成员用手机摄像头扫描。链接会打开“私密聊天”标签页。",
|
||||||
|
|
|
||||||
|
|
@ -354,6 +354,19 @@ const drift: Dict = {
|
||||||
members: "成員",
|
members: "成員",
|
||||||
roleFounder: "創始人",
|
roleFounder: "創始人",
|
||||||
roleAdmin: "管理員",
|
roleAdmin: "管理員",
|
||||||
|
makeAdmin: "設為管理員",
|
||||||
|
removeAdmin: "取消管理員",
|
||||||
|
adminAdded: "成員已升為管理員。",
|
||||||
|
adminRemoved: "已移除管理員角色。",
|
||||||
|
roleUpdateFailed: "無法更新角色,請再試一次。",
|
||||||
|
addReaction: "新增回應",
|
||||||
|
deleteMessage: "刪除訊息",
|
||||||
|
deleteMessageConfirm: "為所有人刪除這則訊息?",
|
||||||
|
readBy: "{{count}} 人已讀",
|
||||||
|
joinRequestTitle: "私聊請求",
|
||||||
|
joinRequestFor: "加入 {{crew}} 的新請求",
|
||||||
|
joinRequestReview: "查看請求",
|
||||||
|
joinRequestDismiss: "忽略請求",
|
||||||
roleMember: "成員",
|
roleMember: "成員",
|
||||||
qrTitle: "掃描加入私密聊天",
|
qrTitle: "掃描加入私密聊天",
|
||||||
qrHint: "Crew 成員用手機相機掃描。連結會開啟「私密聊天」分頁。",
|
qrHint: "Crew 成員用手機相機掃描。連結會開啟「私密聊天」分頁。",
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,8 @@ export interface CordnChatMessage {
|
||||||
direction: 'inbound' | 'outbound';
|
direction: 'inbound' | 'outbound';
|
||||||
/** Envelope ids this message causally follows (`prev` tags). */
|
/** Envelope ids this message causally follows (`prev` tags). */
|
||||||
prev: string[];
|
prev: string[];
|
||||||
|
/** Decrypted envelope tags (reactions / deletes / read receipts are control tags). */
|
||||||
|
tags?: string[][];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CordnGroupInfo {
|
export interface CordnGroupInfo {
|
||||||
|
|
@ -488,17 +490,25 @@ 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, imageUrl?: string): Promise<CordnChatMessage> {
|
async send(
|
||||||
|
gid: string,
|
||||||
|
content: string,
|
||||||
|
imageUrl?: string,
|
||||||
|
tags?: 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,
|
imageUrl,
|
||||||
tags: causalPrevTags(
|
tags: [
|
||||||
group.messages.map((m) => m.id),
|
...causalPrevTags(
|
||||||
group.messages.map((m) => m.prev),
|
group.messages.map((m) => m.id),
|
||||||
),
|
group.messages.map((m) => m.prev),
|
||||||
|
),
|
||||||
|
...(tags ?? []),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
const sealed = await sealForCoordinator({ state: group.state, messageBase64 });
|
const sealed = await sealForCoordinator({ state: group.state, messageBase64 });
|
||||||
const posted = await this.getClient().postGroupMessage(group.gid, sealed);
|
const posted = await this.getClient().postGroupMessage(group.gid, sealed);
|
||||||
|
|
@ -513,6 +523,7 @@ export class CordnSession {
|
||||||
cursor: posted.cursor,
|
cursor: posted.cursor,
|
||||||
direction: 'outbound',
|
direction: 'outbound',
|
||||||
prev: envelope.tags.filter((t) => t[0] === 'prev').map((t) => t[1]),
|
prev: envelope.tags.filter((t) => t[0] === 'prev').map((t) => t[1]),
|
||||||
|
tags: envelope.tags,
|
||||||
};
|
};
|
||||||
group.messages.push(stored);
|
group.messages.push(stored);
|
||||||
group.cursor = Math.max(group.cursor, posted.cursor);
|
group.cursor = Math.max(group.cursor, posted.cursor);
|
||||||
|
|
@ -520,6 +531,11 @@ export class CordnSession {
|
||||||
return stored;
|
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. */
|
/** Bounded catch-up: fetch and decrypt everything after this group's cursor. */
|
||||||
async fetch(gid: string): Promise<CordnChatMessage[]> {
|
async fetch(gid: string): Promise<CordnChatMessage[]> {
|
||||||
const group = this.requireGroup(gid);
|
const group = this.requireGroup(gid);
|
||||||
|
|
@ -550,6 +566,7 @@ export class CordnSession {
|
||||||
cursor: message.cursor,
|
cursor: message.cursor,
|
||||||
direction: sender === this.identity.pubkey ? 'outbound' : 'inbound',
|
direction: sender === this.identity.pubkey ? 'outbound' : 'inbound',
|
||||||
prev: envelope.tags.filter((t) => t[0] === 'prev').map((t) => t[1]),
|
prev: envelope.tags.filter((t) => t[0] === 'prev').map((t) => t[1]),
|
||||||
|
tags: envelope.tags,
|
||||||
};
|
};
|
||||||
group.messages.push(stored);
|
group.messages.push(stored);
|
||||||
received.push(stored);
|
received.push(stored);
|
||||||
|
|
|
||||||
27
src/lib/cordn/watch.ts
Normal file
27
src/lib/cordn/watch.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,6 @@ import { Link, useSearchParams } from 'react-router-dom';
|
||||||
import { EditProfileForm } from '@/components/EditProfileForm';
|
import { EditProfileForm } from '@/components/EditProfileForm';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { useSasquatchHidden } from '@/hooks/useSasquatchHidden';
|
import { useSasquatchHidden } from '@/hooks/useSasquatchHidden';
|
||||||
import { useMapFooterHidden } from '@/hooks/useMapFooterHidden';
|
|
||||||
import { SkaterDirectoryCard } from '@/components/SkaterDirectoryCard';
|
import { SkaterDirectoryCard } from '@/components/SkaterDirectoryCard';
|
||||||
import { ProfilePreviewCard } from '@/components/ProfilePreviewCard';
|
import { ProfilePreviewCard } from '@/components/ProfilePreviewCard';
|
||||||
import { RelayListManager } from '@/components/RelayListManager';
|
import { RelayListManager } from '@/components/RelayListManager';
|
||||||
|
|
@ -618,7 +617,6 @@ function RelaysScreen({ onBack }: { onBack: () => void }) {
|
||||||
function AppearanceScreen({ onBack }: { onBack: () => void }) {
|
function AppearanceScreen({ onBack }: { onBack: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [sasquatchHidden, setSasquatchHidden] = useSasquatchHidden();
|
const [sasquatchHidden, setSasquatchHidden] = useSasquatchHidden();
|
||||||
const [mapFooterHidden, setMapFooterHidden] = useMapFooterHidden();
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<BackHeader title={t('settingsUi.settingsPage.appearance')} onBack={onBack} />
|
<BackHeader title={t('settingsUi.settingsPage.appearance')} onBack={onBack} />
|
||||||
|
|
@ -648,13 +646,6 @@ function AppearanceScreen({ onBack }: { onBack: () => void }) {
|
||||||
</div>
|
</div>
|
||||||
<Switch checked={!sasquatchHidden} onCheckedChange={(v) => setSasquatchHidden(!v)} aria-label={t('settingsUi.settingsPage.showSasquatch')} />
|
<Switch checked={!sasquatchHidden} onCheckedChange={(v) => setSasquatchHidden(!v)} aria-label={t('settingsUi.settingsPage.showSasquatch')} />
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="rounded-2xl border border-white/8 p-5 bg-white/2 text-center">
|
<div className="rounded-2xl border border-white/8 p-5 bg-white/2 text-center">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue