From 2ec861befd243215f7f0ab89ffe86168da5eb609 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Tue, 27 Oct 2026 17:37:09 +0700 Subject: [PATCH] feat(community): add the app-wide Cordn room and always-on bot --- .gitignore | 3 + AGENTS.md | 24 +++ scripts/build-community-bot.sh | 15 ++ scripts/community-bot.ts | 214 +++++++++++++++++++ scripts/smoke-test.mjs | 1 + scripts/systemd/skatespots-community.service | 25 +++ src/AppRouter.tsx | 2 + src/components/Layout.tsx | 4 +- src/components/crew/CordnNativePanel.tsx | 149 ++++++++++--- src/hooks/useCordnNative.ts | 8 +- src/hooks/useNavPreferences.ts | 2 + src/i18n/ca/batch1.ts | 28 +++ src/i18n/cs/drift.ts | 28 +++ src/i18n/de/drift.ts | 28 +++ src/i18n/el/drift.ts | 28 +++ src/i18n/eu/batch1.ts | 28 +++ src/i18n/fr/drift.ts | 28 +++ src/i18n/gl/batch1.ts | 28 +++ src/i18n/hi/batch1.ts | 28 +++ src/i18n/id/drift.ts | 28 +++ src/i18n/it/drift.ts | 28 +++ src/i18n/ja/drift.ts | 28 +++ src/i18n/ko/drift.ts | 28 +++ src/i18n/legacy/communityUi.ts | 74 +++++++ src/i18n/legacy/index.ts | 3 + src/i18n/nl/drift.ts | 28 +++ src/i18n/pl/drift.ts | 28 +++ src/i18n/pt/drift.ts | 28 +++ src/i18n/ro/drift.ts | 28 +++ src/i18n/ru/drift.ts | 28 +++ src/i18n/sv/drift.ts | 28 +++ src/i18n/th/drift.ts | 28 +++ src/i18n/tr/drift.ts | 28 +++ src/i18n/uk/drift.ts | 28 +++ src/i18n/vi/drift.ts | 28 +++ src/i18n/zh-CN/drift.ts | 28 +++ src/i18n/zh-TW/drift.ts | 28 +++ src/lib/cordn/community.ts | 29 +++ src/lib/cordn/endpoint.ts | 5 +- src/lib/cordn/room.ts | 18 ++ src/pages/AboutPage.tsx | 12 +- src/pages/CommunityPage.tsx | 78 +++++++ 42 files changed, 1295 insertions(+), 43 deletions(-) create mode 100755 scripts/build-community-bot.sh create mode 100644 scripts/community-bot.ts create mode 100644 scripts/systemd/skatespots-community.service create mode 100644 src/i18n/legacy/communityUi.ts create mode 100644 src/lib/cordn/community.ts create mode 100644 src/lib/cordn/room.ts create mode 100644 src/pages/CommunityPage.tsx diff --git a/.gitignore b/.gitignore index 5d9f734..e2b339c 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,6 @@ cvm-server/package.json # Native plugin gradle build output (local plugins) packages/*/android/build/ packages/*/android/.gradle/ + +# Community bot build output (deploy to the relay box, never commit) +build/ diff --git a/AGENTS.md b/AGENTS.md index 0ca19bb..99521c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1494,6 +1494,30 @@ session with **no other memory of this project**. So: `https://skate-spots.org/#/crew//?tab=mls`; use the Cordn link for Staircase/Cordn clients. ⚠️ cordn.net's web client still stalls after NIP-42 (upstream issue), so it is not the primary onboarding path yet. +- **SkateSpots Community room (2026-10-27):** `/community` is a standalone + `CordnRoom`, not a crew. Its gid is the stable literal `skatespots:community` + (no owner key in the gid), so the bot key can rotate without changing links. + Owner/`crew.pubkey` = the bot `f978…f0`, admins = `ADMIN_PUBKEYS` + bot. The + page passes `canCreate={false}`: everyone (admins included) uses the normal + "Ask to join" flow; the bot auto-approves. After joining, admins still get the + requests panel as a fallback. Links: `/#/community` (app/QR) and the Advanced + `https://cordn.net/chat/` for Staircase/Cordn clients. Entry points: + nav catalog/burger + About feedback card. + - **Bot:** `scripts/community-bot.ts`, built to CJS with + `scripts/build-community-bot.sh` (Node 20 needs the `ws` global shim), run by + `scripts/systemd/skatespots-community.service` on the relay box: + `/opt/skatespots-community/community-bot.cjs`, env + `/etc/skatespots/community.env` (600), state + `/var/lib/skatespots-community/state.json` (600). It created the room once + with `COMMUNITY_CREATE=1`; **leave `COMMUNITY_CREATE=0` afterward** — a lost + state plus create=1 would fork the room. It polls every 15s, auto-approves + up to 10 requests/cycle, skips its own request, and backs off failed + approvals for 5 minutes. + - **Secrets/backup:** bot key in `~/.config/skatespots/community.env` (600) + and the VPS env; state copy in + `~/.config/skatespots/community-state.json` (600). Never commit/print either. + - **Copy:** `communityUi.*` (en/es + all 24; 100%); bot rows are labeled + "Community bot" by pubkey, no profile event required. - **Map site-links strip (2026-10-05):** the desktop footer is hidden on the map (full-bleed), so `components/MapFooterStrip.tsx` surfaces the same links (Newsletter/License/Privacy/Collaborators + "not sponsored by OpenSats") as an diff --git a/scripts/build-community-bot.sh b/scripts/build-community-bot.sh new file mode 100755 index 0000000..0fbf6fe --- /dev/null +++ b/scripts/build-community-bot.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Build the always-on SkateSpots Community Cordn bot for the relay box. +# Output: build/community-bot.cjs (not committed; deploy to /opt/skatespots-community). +set -euo pipefail +cd "$(dirname "$0")/.." +mkdir -p build +npx esbuild scripts/community-bot.ts \ + --bundle \ + --platform=node \ + --format=cjs \ + --target=node20 \ + --alias:@=./src \ + --outfile=build/community-bot.cjs \ + --log-level=warning +sha256sum build/community-bot.cjs diff --git a/scripts/community-bot.ts b/scripts/community-bot.ts new file mode 100644 index 0000000..0357f23 --- /dev/null +++ b/scripts/community-bot.ts @@ -0,0 +1,214 @@ +/** + * SkateSpots Community — always-on Cordn room bot. + * + * The room itself is founder-owned; the bot is a normal MLS member that joins + * once and then approves new join requests. It never creates a group (that + * would risk a split if its state were lost): if there is no local group it + * publishes a key package and asks to join, then waits for an admin's Welcome. + * + * Secrets: COMMUNITY_BOT_KEY (hex) comes from a 600 env file. The MLS snapshot + * on disk also contains group private material, so keep the state file 600 and + * back it up. + * + * Run locally: + * COMMUNITY_BOT_KEY=... COMMUNITY_GID=... npx tsx scripts/community-bot.ts + * + * Env: + * COMMUNITY_BOT_KEY 32-byte hex (required) + * COMMUNITY_GID e.g. 35006::community (required) + * COMMUNITY_STATE snapshot path (default /var/lib/skatespots-community/state.json) + * COMMUNITY_COORDINATOR coordinator pubkey (default SkateSpots') + * COMMUNITY_RELAY coordinator relay (default wss://relay.skate-spots.org) + * COMMUNITY_POLL_MS poll interval (default 15000) + * COMMUNITY_AUTO_APPROVE=0 leave requests for admins instead of adding + * COMMUNITY_ONCE=1 one cycle then exit (for a dry run) + */ +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import WebSocket from 'ws'; +import { CordnSession, type CordnSnapshot, type CordnStore } from '../src/lib/cordn/native/session'; + +// Node 20 (the relay box) has no global WebSocket. The ContextVM transport +// looks for one at connect time, so install `ws` before opening a session. +(globalThis as unknown as { WebSocket?: unknown }).WebSocket = WebSocket; + +const COORDINATOR_PUBKEY = + process.env.COMMUNITY_COORDINATOR ?? + '02816a05b1d4e70a7df9c712ef6f7272774269d65bd8a2f262ce0330d0885693'; +const RELAY = process.env.COMMUNITY_RELAY ?? 'wss://relay.skate-spots.org'; +const GID = process.env.COMMUNITY_GID ?? ''; +const BOT_KEY = (process.env.COMMUNITY_BOT_KEY ?? '').trim().toLowerCase(); +const STATE_PATH = + process.env.COMMUNITY_STATE ?? '/var/lib/skatespots-community/state.json'; +const POLL_MS = Number(process.env.COMMUNITY_POLL_MS ?? 15_000); +const AUTO_APPROVE = process.env.COMMUNITY_AUTO_APPROVE !== '0'; +const CREATE = process.env.COMMUNITY_CREATE === '1'; +const ONCE = process.env.COMMUNITY_ONCE === '1'; + +if (!/^[0-9a-f]{64}$/.test(BOT_KEY)) { + console.error('[community] COMMUNITY_BOT_KEY must be 64 lowercase hex chars'); + process.exit(1); +} +if (!GID) { + console.error('[community] COMMUNITY_GID is required'); + process.exit(1); +} + +const started = Date.now(); +const log = (...parts: unknown[]) => { + const uptime = Math.round((Date.now() - started) / 1000); + console.log(`[community +${uptime}s]`, ...parts); +}; +const short = (pk: string) => `${pk.slice(0, 8)}…`; +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** JSON snapshot store. Atomic writes, 600, and no duplicated bot private key. */ +class FileCordnStore implements CordnStore { + constructor(private readonly path: string) {} + + async load(): Promise { + try { + return JSON.parse(await readFile(this.path, 'utf8')) as CordnSnapshot; + } catch { + return null; + } + } + + async save(snapshot: CordnSnapshot): Promise { + await mkdir(dirname(this.path), { recursive: true, mode: 0o700 }); + // The bot key lives in the env file; don't duplicate it in the state. + const { privateKeyHex, ...safe } = snapshot; + void privateKeyHex; + const tmp = `${this.path}.${process.pid}.tmp`; + await writeFile(tmp, JSON.stringify(safe), { mode: 0o600 }); + await rename(tmp, this.path); + } + + async clear(): Promise { + await writeFile(this.path, 'null', { mode: 0o600 }); + } +} + +let session!: CordnSession; + +const hasRoom = () => session.listGroups().some((group) => group.gid === GID); + +async function joinRoom(): Promise { + if (hasRoom()) return true; + + // A Welcome already waiting? Accept it before asking again, so a restart + // between approval and pickup does not create a duplicate request. + const welcomes = await session.fetchWelcomes(); + for (const welcome of welcomes) { + try { + const joined = await session.acceptWelcome(welcome, 'SkateSpots Community'); + if (joined.gid === GID) { + log('joined the room as a member'); + return true; + } + } catch { + // Welcome for a key package this bot no longer has — skip. + } + } + + // Ask the founder/admin to add us. `requestJoin` publishes a key package if + // one is not already waiting. + await session.requestJoin(GID); + lastJoinRequestAt = Date.now(); + log('join request sent; waiting for an admin to approve this bot'); + return false; +} + +const approved = new Set(); +const lastAttempt = new Map(); +let lastJoinRequestAt = 0; +let lastError = ''; + +async function cycle(): Promise { + try { + if (!hasRoom()) { + if (CREATE) { + await session.createGroup({ groupId: GID, name: 'SkateSpots Community' }); + log('created the room (bootstrap); set COMMUNITY_CREATE=0 and restart'); + } + const joined = await joinRoom(); + if (!joined) { + // Retry the request every 5 minutes; a fresh key package may be needed + // if the original one was consumed or expired. + if (Date.now() - lastJoinRequestAt > 5 * 60_000) { + await session.requestJoin(GID); + lastJoinRequestAt = Date.now(); + log('join request refreshed'); + } + return; + } + } + + // Keep the bot's MLS state current with commits from other members. + await session.fetch(GID); + + if (!AUTO_APPROVE) return; + + const requests = await session.fetchJoinRequests(GID); + for (const request of requests.slice(0, 10)) { + if (request.pk === session.pubkey) continue; // our own boot request + if (approved.has(request.pk)) continue; + const last = lastAttempt.get(request.pk) ?? 0; + if (Date.now() - last < 5 * 60_000) continue; + try { + await session.addMemberByIdentifier(GID, request.pk); + approved.add(request.pk); + log('approved member', short(request.pk)); + await sleep(1500); // be gentle with the coordinator + } catch (error) { + lastAttempt.set(request.pk, Date.now()); + log('could not approve', short(request.pk), error instanceof Error ? error.message : String(error)); + await sleep(1500); + } + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message !== lastError) { + lastError = message; + log('cycle error', message); + } + } +} + +let stopping = false; +async function stop(signal: string): Promise { + if (stopping) return; + stopping = true; + log(`stopping (${signal})`); + try { + if (session) await session.disconnect(); + } finally { + process.exit(0); + } +} +process.on('SIGINT', () => void stop('SIGINT')); +process.on('SIGTERM', () => void stop('SIGTERM')); + +async function main(): Promise { + session = await CordnSession.open({ + coordinator: { serverPubkey: COORDINATOR_PUBKEY, relays: [RELAY] }, + privateKeyHex: BOT_KEY, + store: new FileCordnStore(STATE_PATH), + }); + log('bot pubkey', session.pubkey, 'room', GID); + + do { + await cycle(); + if (ONCE) break; + await sleep(POLL_MS); + } while (!stopping); + + // `ONCE` is used for setup/dry runs and tests; close the ContextVM client so + // the process can exit instead of hanging on the WebSocket. + if (ONCE) await session.disconnect(); +} + +void main().catch((error) => { + console.error('[community] fatal', error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs index 1632ecb..5d7b3ca 100755 --- a/scripts/smoke-test.mjs +++ b/scripts/smoke-test.mjs @@ -137,6 +137,7 @@ const ROUTES = [ '/become/clothing', '/leaderboards', '/bounties', + '/community', '/settings', '/settings?section=profile', '/settings?section=coordinator', diff --git a/scripts/systemd/skatespots-community.service b/scripts/systemd/skatespots-community.service new file mode 100644 index 0000000..52413a1 --- /dev/null +++ b/scripts/systemd/skatespots-community.service @@ -0,0 +1,25 @@ +[Unit] +Description=SkateSpots Community Cordn room bot +Documentation=file:///home/sovereignsk8/SkateSpots/scripts/community-bot.ts +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=ubuntu +Group=ubuntu +WorkingDirectory=/opt/skatespots-community +EnvironmentFile=/etc/skatespots/community.env +ExecStart=/usr/bin/node /opt/skatespots-community/community-bot.cjs +Restart=always +RestartSec=5 +TimeoutStopSec=20 +StateDirectory=skatespots-community +NoNewPrivileges=true +PrivateTmp=true +ProtectHome=true +ProtectSystem=strict +SyslogIdentifier=skatespots-community + +[Install] +WantedBy=multi-user.target diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index cac29e4..bf4efa7 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -54,6 +54,7 @@ const AboutPage = lazyWithRetry(() => import("./pages/AboutPage")); const FAQPage = lazyWithRetry(() => import("./pages/FAQPage")); const FoodPage = lazyWithRetry(() => import("./pages/FoodPage")); const CommunityStatsPage = lazyWithRetry(() => import("./pages/CommunityStatsPage")); +const CommunityPage = lazyWithRetry(() => import("./pages/CommunityPage")); const SpotCollectionsPage = lazyWithRetry(() => import("./pages/SpotCollectionsPage")); const CollectionDetailPage = lazyWithRetry(() => import("./pages/CollectionDetailPage")); const DirectMessagesPage = lazyWithRetry(() => import("./pages/DirectMessagesPage")); @@ -143,6 +144,7 @@ export function AppRouter() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 141e56e..6032d76 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -48,6 +48,7 @@ import { BookOpen, PersonStanding, Languages, + MessagesSquare, ChevronLeft, ChevronRight, } from 'lucide-react'; @@ -123,6 +124,7 @@ const navItemKeys = [ { href: '/lab', labelKey: 'nav.lab', icon: GraduationCap }, { href: '/events', labelKey: 'nav.events', icon: Calendar }, { href: '/crews', labelKey: 'nav.crews', icon: Users }, + { href: '/community', labelKey: 'communityUi.link', icon: MessagesSquare }, ]; /** @@ -131,7 +133,7 @@ const navItemKeys = [ * section can never be missing on mobile just because it wasn't hard-coded. */ const MOBILE_MENU_LISTED_HREFS = [ - '/', '/lab', '/events', '/crews', '/skaters', '/messages', '/contacts', + '/', '/lab', '/events', '/crews', '/community', '/skaters', '/messages', '/contacts', '/collections', '/stash', '/stats', '/glossary', '/about', '/card', '/supporters', '/newsletter', '/settings', '/wallet', ]; diff --git a/src/components/crew/CordnNativePanel.tsx b/src/components/crew/CordnNativePanel.tsx index e8d2b2f..0cdf29b 100644 --- a/src/components/crew/CordnNativePanel.tsx +++ b/src/components/crew/CordnNativePanel.tsx @@ -42,14 +42,24 @@ import { shortNpub } from '@/lib/coordinator'; import { isNativePlatform } from '@/lib/platform'; import { formatDistanceToNow } from '@/lib/dateFnsI18n'; import { cn } from '@/lib/utils'; -import type { SkateCrew } from '@/lib/types'; +import type { CordnRoom } from '@/lib/cordn/room'; + +type RoomScope = 'crew' | 'community'; interface CordnNativePanelProps { - crew: SkateCrew; + crew: CordnRoom; /** Founder/admin — may create the group and approve join requests. */ canManage: boolean; + /** False for bot-owned rooms: the bot creates, admins still approve. */ + canCreate?: boolean; /** Founder or listed member. */ isMember: boolean; + /** Switches the room-specific copy (crew vs the app-wide community room). */ + scope?: RoomScope; + /** Optional share/QR URL override (the community page uses `/#/community`). */ + inviteUrl?: string; + /** Community bot pubkey — shown with a friendly label in requests/members. */ + botPubkey?: string; } type MemberRole = 'founder' | 'admin' | 'member'; @@ -64,8 +74,58 @@ interface PendingMessage { * The crew's **native Cordn (MLS) chat** — real end-to-end encryption between * members, routed through the crew's coordinator. See `lib/cordn/native/`. */ -export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanelProps) { +export function CordnNativePanel({ + crew, + canManage, + canCreate = canManage, + isMember, + scope = 'crew', + inviteUrl: inviteUrlProp, + botPubkey, +}: CordnNativePanelProps) { const { t } = useTranslation(); + const isCommunity = scope === 'community'; + const copy = { + title: isCommunity ? t('communityUi.title') : t('cordnUi.title'), + subtitle: isCommunity ? t('communityUi.subtitle') : t('cordnUi.subtitle'), + notLoggedIn: isCommunity ? t('communityUi.notLoggedIn') : t('cordnUi.notLoggedIn'), + noGroupAdmin: isCommunity ? t('communityUi.noGroupAdmin') : t('cordnUi.noGroupAdmin'), + noGroupMember: isCommunity ? t('communityUi.noGroupMember') : t('cordnUi.noGroupMember'), + create: isCommunity ? t('communityUi.create') : t('cordnUi.create'), + setupDeviceBody: isCommunity + ? t('communityUi.setupDeviceBody') + : t('cordnUi.setupDeviceBody'), + encryptedNote: isCommunity ? t('communityUi.encryptedNote') : t('cordnUi.encryptedNote'), + shareInviteText: isCommunity + ? t('communityUi.shareInviteText') + : t('cordnUi.shareInviteText', { crew: crew.name }), + qrTitle: isCommunity ? t('communityUi.qrTitle') : t('cordnUi.qrTitle'), + qrHint: isCommunity ? t('communityUi.qrHint') : t('cordnUi.qrHint'), + owner: isCommunity ? t('communityUi.owner') : t('cordnUi.roleFounder'), + botName: t('communityUi.botName'), + founderSteps: isCommunity + ? [ + t('communityUi.onboardingFounder1'), + t('communityUi.onboardingFounder2'), + t('communityUi.onboardingFounder3'), + ] + : [ + t('cordnUi.onboardingFounder1'), + t('cordnUi.onboardingFounder2'), + t('cordnUi.onboardingFounder3'), + ], + memberSteps: isCommunity + ? [ + t('communityUi.onboardingMember1'), + t('communityUi.onboardingMember2'), + t('communityUi.onboardingMember3'), + ] + : [ + t('cordnUi.onboardingMember1'), + t('cordnUi.onboardingMember2'), + t('cordnUi.onboardingMember3'), + ], + }; const { user } = useCurrentUser(); const native = useCordnNative({ crew, enabled: !!user && isMember }); // Stable callbacks (the hook's return object is recreated each render). @@ -86,12 +146,13 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel const { mutateAsync: uploadFile, isPending: uploading } = useUploadFile(); const inviteUrl = useMemo(() => { + if (inviteUrlProp) return inviteUrlProp; // In the Android/iOS shell the page origin is `https://localhost`, which is // useless in a QR code. Share the public site there; keep the live origin on // the web (including previews). const origin = isNativePlatform() ? 'https://skate-spots.org' : window.location.origin; return `${origin}/#/crew/${crew.pubkey}/${crew.identifier}?tab=mls`; - }, [crew.pubkey, crew.identifier]); + }, [crew.pubkey, crew.identifier, inviteUrlProp]); useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); @@ -259,7 +320,7 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel if (navigator.share) { await navigator.share({ title: crew.name, - text: t('cordnUi.shareInviteText', { crew: crew.name }), + text: copy.shareInviteText, url: inviteUrl, }); return; @@ -270,10 +331,10 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel } catch { // share cancelled or clipboard blocked — nothing to do } - }, [crew.name, inviteUrl, t]); + }, [crew.name, copy.shareInviteText, inviteUrl, t]); if (!user) { - return } text={t('cordnUi.notLoggedIn')} />; + return } text={copy.notLoggedIn} />; } if (!isMember) { return } text={t('cordnUi.membersOnly')} />; @@ -283,17 +344,7 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel } const status = native.ready ? t('cordnUi.ready') : t('cordnUi.connecting'); - const steps = canManage - ? [ - t('cordnUi.onboardingFounder1'), - t('cordnUi.onboardingFounder2'), - t('cordnUi.onboardingFounder3'), - ] - : [ - t('cordnUi.onboardingMember1'), - t('cordnUi.onboardingMember2'), - t('cordnUi.onboardingMember3'), - ]; + const steps = canCreate ? copy.founderSteps : copy.memberSteps; const roleOf = (pubkey: string): MemberRole => pubkey === crew.pubkey ? 'founder' : crew.admins.includes(pubkey) ? 'admin' : 'member'; @@ -303,7 +354,7 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
- {t('cordnUi.title')} + {copy.title}
{native.group && ( @@ -365,7 +416,7 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel )}
-

{t('cordnUi.subtitle')}

+

{copy.subtitle}

@@ -396,11 +447,11 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel {!native.group ? (

- {canManage - ? t('cordnUi.noGroupAdmin') + {canCreate + ? copy.noGroupAdmin : native.keyPackageReady - ? t('cordnUi.noGroupMember') - : t('cordnUi.setupDeviceBody')} + ? copy.noGroupMember + : copy.setupDeviceBody}

    @@ -415,11 +466,11 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel

- {t('cordnUi.encryptedNote')} + {copy.encryptedNote}

- {canManage ? ( + {canCreate ? ( ) : ( <> @@ -497,6 +548,9 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel pubkey={member.pubkey} role={roleOf(member.pubkey)} isSelf={member.pubkey === user.pubkey} + founderLabel={copy.owner} + botPubkey={botPubkey} + botName={copy.botName} /> ))}
@@ -700,6 +754,8 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel pubkey={req.pk} disabled={native.busy} onApprove={() => void native.approve(req.pk)} + botPubkey={botPubkey} + botName={copy.botName} /> )) )} @@ -713,9 +769,9 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel - {t('cordnUi.qrTitle')} + {copy.qrTitle} - {t('cordnUi.qrHint')} + {copy.qrHint}
@@ -845,11 +901,24 @@ function AdvancedPanel({ ); } -function PersonLabel({ pubkey, isSelf }: { pubkey: string; isSelf?: boolean }) { +function PersonLabel({ + pubkey, + isSelf, + botPubkey, + botName, +}: { + pubkey: string; + isSelf?: boolean; + botPubkey?: string; + botName?: string; +}) { const { t } = useTranslation(); const { data } = useAuthor(pubkey); - const name = data?.metadata?.display_name || data?.metadata?.name || shortNpub(pubkey); - const picture = data?.metadata?.picture; + const isBot = !!botPubkey && pubkey === botPubkey; + const name = isBot + ? (botName ?? shortNpub(pubkey)) + : data?.metadata?.display_name || data?.metadata?.name || shortNpub(pubkey); + const picture = isBot ? undefined : data?.metadata?.picture; const initials = name.replace(/^@/, '').slice(0, 2).toUpperCase(); return ( @@ -872,21 +941,27 @@ function MemberRow({ pubkey, role, isSelf, + founderLabel, + botPubkey, + botName, }: { pubkey: string; role: MemberRole; isSelf: boolean; + founderLabel: string; + botPubkey?: string; + botName?: string; }) { const { t } = useTranslation(); const label = role === 'founder' - ? t('cordnUi.roleFounder') + ? founderLabel : role === 'admin' ? t('cordnUi.roleAdmin') : t('cordnUi.roleMember'); return (
- + void; + botPubkey?: string; + botName?: string; }) { const { t } = useTranslation(); return (
- +
-
+
+ + + + +
+
+ + + {t('communityUi.eyebrow')} + +
+

+ {t('communityUi.heading')} +

+

{t('communityUi.subtitle')}

+
+ + + + +

{t('communityUi.howItWorks')}

+
+
+ + + +
+ + {t('communityUi.feedbackNote')} +
+
+ ); +}