feat(community): add the app-wide Cordn room and always-on bot

This commit is contained in:
SkateSpots 2026-10-27 17:37:09 +07:00
parent 27f3434475
commit 2ec861befd
42 changed files with 1295 additions and 43 deletions

3
.gitignore vendored
View file

@ -64,3 +64,6 @@ cvm-server/package.json
# Native plugin gradle build output (local plugins) # Native plugin gradle build output (local plugins)
packages/*/android/build/ packages/*/android/build/
packages/*/android/.gradle/ packages/*/android/.gradle/
# Community bot build output (deploy to the relay box, never commit)
build/

View file

@ -1494,6 +1494,30 @@ session with **no other memory of this project**. So:
`https://skate-spots.org/#/crew/<pk>/<id>?tab=mls`; use the Cordn link for `https://skate-spots.org/#/crew/<pk>/<id>?tab=mls`; use the Cordn link for
Staircase/Cordn clients. ⚠️ cordn.net's web client still stalls after NIP-42 Staircase/Cordn clients. ⚠️ cordn.net's web client still stalls after NIP-42
(upstream issue), so it is not the primary onboarding path yet. (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/<cordn1…>` 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 - **Map site-links strip (2026-10-05):** the desktop footer is hidden on the map
(full-bleed), so `components/MapFooterStrip.tsx` surfaces the same links (full-bleed), so `components/MapFooterStrip.tsx` surfaces the same links
(Newsletter/License/Privacy/Collaborators + "not sponsored by OpenSats") as an (Newsletter/License/Privacy/Collaborators + "not sponsored by OpenSats") as an

15
scripts/build-community-bot.sh Executable file
View file

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

214
scripts/community-bot.ts Normal file
View file

@ -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:<founder>: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<CordnSnapshot | null> {
try {
return JSON.parse(await readFile(this.path, 'utf8')) as CordnSnapshot;
} catch {
return null;
}
}
async save(snapshot: CordnSnapshot): Promise<void> {
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<void> {
await writeFile(this.path, 'null', { mode: 0o600 });
}
}
let session!: CordnSession;
const hasRoom = () => session.listGroups().some((group) => group.gid === GID);
async function joinRoom(): Promise<boolean> {
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<string>();
const lastAttempt = new Map<string, number>();
let lastJoinRequestAt = 0;
let lastError = '';
async function cycle(): Promise<void> {
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<void> {
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<void> {
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);
});

View file

@ -137,6 +137,7 @@ const ROUTES = [
'/become/clothing', '/become/clothing',
'/leaderboards', '/leaderboards',
'/bounties', '/bounties',
'/community',
'/settings', '/settings',
'/settings?section=profile', '/settings?section=profile',
'/settings?section=coordinator', '/settings?section=coordinator',

View file

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

View file

@ -54,6 +54,7 @@ const AboutPage = lazyWithRetry(() => import("./pages/AboutPage"));
const FAQPage = lazyWithRetry(() => import("./pages/FAQPage")); const FAQPage = lazyWithRetry(() => import("./pages/FAQPage"));
const FoodPage = lazyWithRetry(() => import("./pages/FoodPage")); const FoodPage = lazyWithRetry(() => import("./pages/FoodPage"));
const CommunityStatsPage = lazyWithRetry(() => import("./pages/CommunityStatsPage")); const CommunityStatsPage = lazyWithRetry(() => import("./pages/CommunityStatsPage"));
const CommunityPage = lazyWithRetry(() => import("./pages/CommunityPage"));
const SpotCollectionsPage = lazyWithRetry(() => import("./pages/SpotCollectionsPage")); const SpotCollectionsPage = lazyWithRetry(() => import("./pages/SpotCollectionsPage"));
const CollectionDetailPage = lazyWithRetry(() => import("./pages/CollectionDetailPage")); const CollectionDetailPage = lazyWithRetry(() => import("./pages/CollectionDetailPage"));
const DirectMessagesPage = lazyWithRetry(() => import("./pages/DirectMessagesPage")); const DirectMessagesPage = lazyWithRetry(() => import("./pages/DirectMessagesPage"));
@ -143,6 +144,7 @@ export function AppRouter() {
<Route path="/listing/:pubkey/:identifier" element={<ListingDetailPage />} /> <Route path="/listing/:pubkey/:identifier" element={<ListingDetailPage />} />
<Route path="/trades" element={<TradesPage />} /> <Route path="/trades" element={<TradesPage />} />
<Route path="/stats" element={<CommunityStatsPage />} /> <Route path="/stats" element={<CommunityStatsPage />} />
<Route path="/community" element={<CommunityPage />} />
<Route path="/about" element={<AboutPage />} /> <Route path="/about" element={<AboutPage />} />
<Route path="/trust" element={<TrustPage />} /> <Route path="/trust" element={<TrustPage />} />
<Route path="/alerts" element={<NetworkAlertsPage />} /> <Route path="/alerts" element={<NetworkAlertsPage />} />

View file

@ -48,6 +48,7 @@ import {
BookOpen, BookOpen,
PersonStanding, PersonStanding,
Languages, Languages,
MessagesSquare,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
} from 'lucide-react'; } from 'lucide-react';
@ -123,6 +124,7 @@ const navItemKeys = [
{ href: '/lab', labelKey: 'nav.lab', icon: GraduationCap }, { href: '/lab', labelKey: 'nav.lab', icon: GraduationCap },
{ href: '/events', labelKey: 'nav.events', icon: Calendar }, { href: '/events', labelKey: 'nav.events', icon: Calendar },
{ href: '/crews', labelKey: 'nav.crews', icon: Users }, { 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. * section can never be missing on mobile just because it wasn't hard-coded.
*/ */
const MOBILE_MENU_LISTED_HREFS = [ const MOBILE_MENU_LISTED_HREFS = [
'/', '/lab', '/events', '/crews', '/skaters', '/messages', '/contacts', '/', '/lab', '/events', '/crews', '/community', '/skaters', '/messages', '/contacts',
'/collections', '/stash', '/stats', '/glossary', '/about', '/card', '/collections', '/stash', '/stats', '/glossary', '/about', '/card',
'/supporters', '/newsletter', '/settings', '/wallet', '/supporters', '/newsletter', '/settings', '/wallet',
]; ];

View file

@ -42,14 +42,24 @@ import { shortNpub } from '@/lib/coordinator';
import { isNativePlatform } from '@/lib/platform'; 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 { SkateCrew } from '@/lib/types'; import type { CordnRoom } from '@/lib/cordn/room';
type RoomScope = 'crew' | 'community';
interface CordnNativePanelProps { interface CordnNativePanelProps {
crew: SkateCrew; crew: CordnRoom;
/** Founder/admin — may create the group and approve join requests. */ /** Founder/admin — may create the group and approve join requests. */
canManage: boolean; canManage: boolean;
/** False for bot-owned rooms: the bot creates, admins still approve. */
canCreate?: boolean;
/** Founder or listed member. */ /** Founder or listed member. */
isMember: boolean; 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'; type MemberRole = 'founder' | 'admin' | 'member';
@ -64,8 +74,58 @@ interface PendingMessage {
* The crew's **native Cordn (MLS) chat** — real end-to-end encryption between * The crew's **native Cordn (MLS) chat** — real end-to-end encryption between
* members, routed through the crew's coordinator. See `lib/cordn/native/`. * 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 { 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 { user } = useCurrentUser();
const native = useCordnNative({ crew, enabled: !!user && isMember }); const native = useCordnNative({ crew, enabled: !!user && isMember });
// Stable callbacks (the hook's return object is recreated each render). // 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 { mutateAsync: uploadFile, isPending: uploading } = useUploadFile();
const inviteUrl = useMemo(() => { const inviteUrl = useMemo(() => {
if (inviteUrlProp) return inviteUrlProp;
// In the Android/iOS shell the page origin is `https://localhost`, which is // 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 // useless in a QR code. Share the public site there; keep the live origin on
// the web (including previews). // the web (including previews).
const origin = isNativePlatform() ? 'https://skate-spots.org' : window.location.origin; const origin = isNativePlatform() ? 'https://skate-spots.org' : window.location.origin;
return `${origin}/#/crew/${crew.pubkey}/${crew.identifier}?tab=mls`; return `${origin}/#/crew/${crew.pubkey}/${crew.identifier}?tab=mls`;
}, [crew.pubkey, crew.identifier]); }, [crew.pubkey, crew.identifier, inviteUrlProp]);
useEffect(() => { useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
@ -259,7 +320,7 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
if (navigator.share) { if (navigator.share) {
await navigator.share({ await navigator.share({
title: crew.name, title: crew.name,
text: t('cordnUi.shareInviteText', { crew: crew.name }), text: copy.shareInviteText,
url: inviteUrl, url: inviteUrl,
}); });
return; return;
@ -270,10 +331,10 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
} catch { } catch {
// share cancelled or clipboard blocked — nothing to do // share cancelled or clipboard blocked — nothing to do
} }
}, [crew.name, inviteUrl, t]); }, [crew.name, copy.shareInviteText, inviteUrl, t]);
if (!user) { if (!user) {
return <Notice icon={<Lock className="h-5 w-5" />} text={t('cordnUi.notLoggedIn')} />; return <Notice icon={<Lock className="h-5 w-5" />} text={copy.notLoggedIn} />;
} }
if (!isMember) { if (!isMember) {
return <Notice icon={<Lock className="h-5 w-5" />} text={t('cordnUi.membersOnly')} />; return <Notice icon={<Lock className="h-5 w-5" />} 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 status = native.ready ? t('cordnUi.ready') : t('cordnUi.connecting');
const steps = canManage const steps = canCreate ? copy.founderSteps : copy.memberSteps;
? [
t('cordnUi.onboardingFounder1'),
t('cordnUi.onboardingFounder2'),
t('cordnUi.onboardingFounder3'),
]
: [
t('cordnUi.onboardingMember1'),
t('cordnUi.onboardingMember2'),
t('cordnUi.onboardingMember3'),
];
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';
@ -303,7 +354,7 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
<Lock className="h-4 w-4 text-teal-400" /> {t('cordnUi.title')} <Lock className="h-4 w-4 text-teal-400" /> {copy.title}
</CardTitle> </CardTitle>
<div className="flex flex-wrap items-center justify-end gap-2"> <div className="flex flex-wrap items-center justify-end gap-2">
{native.group && ( {native.group && (
@ -365,7 +416,7 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
)} )}
</div> </div>
</div> </div>
<p className="pt-1 text-xs text-gray-500">{t('cordnUi.subtitle')}</p> <p className="pt-1 text-xs text-gray-500">{copy.subtitle}</p>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
@ -396,11 +447,11 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
{!native.group ? ( {!native.group ? (
<div className="space-y-4 py-4 text-center"> <div className="space-y-4 py-4 text-center">
<p className="text-sm text-gray-400"> <p className="text-sm text-gray-400">
{canManage {canCreate
? t('cordnUi.noGroupAdmin') ? copy.noGroupAdmin
: native.keyPackageReady : native.keyPackageReady
? t('cordnUi.noGroupMember') ? copy.noGroupMember
: t('cordnUi.setupDeviceBody')} : copy.setupDeviceBody}
</p> </p>
<ol className="mx-auto max-w-sm space-y-2 text-left"> <ol className="mx-auto max-w-sm space-y-2 text-left">
@ -415,11 +466,11 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
</ol> </ol>
<p className="mx-auto max-w-sm text-[11px] text-gray-500"> <p className="mx-auto max-w-sm text-[11px] text-gray-500">
{t('cordnUi.encryptedNote')} {copy.encryptedNote}
</p> </p>
<div className="flex flex-wrap items-center justify-center gap-2"> <div className="flex flex-wrap items-center justify-center gap-2">
{canManage ? ( {canCreate ? (
<Button <Button
size="sm" size="sm"
disabled={!native.ready || native.busy} disabled={!native.ready || native.busy}
@ -431,7 +482,7 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
) : ( ) : (
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
)} )}
{t('cordnUi.create')} {copy.create}
</Button> </Button>
) : ( ) : (
<> <>
@ -497,6 +548,9 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
pubkey={member.pubkey} pubkey={member.pubkey}
role={roleOf(member.pubkey)} role={roleOf(member.pubkey)}
isSelf={member.pubkey === user.pubkey} isSelf={member.pubkey === user.pubkey}
founderLabel={copy.owner}
botPubkey={botPubkey}
botName={copy.botName}
/> />
))} ))}
</div> </div>
@ -700,6 +754,8 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
pubkey={req.pk} pubkey={req.pk}
disabled={native.busy} disabled={native.busy}
onApprove={() => void native.approve(req.pk)} onApprove={() => void native.approve(req.pk)}
botPubkey={botPubkey}
botName={copy.botName}
/> />
)) ))
)} )}
@ -713,9 +769,9 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
<DialogContent className="max-w-sm border-white/10 bg-zinc-950"> <DialogContent className="max-w-sm border-white/10 bg-zinc-950">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<QrCode className="h-4 w-4 text-teal-400" /> {t('cordnUi.qrTitle')} <QrCode className="h-4 w-4 text-teal-400" /> {copy.qrTitle}
</DialogTitle> </DialogTitle>
<DialogDescription>{t('cordnUi.qrHint')}</DialogDescription> <DialogDescription>{copy.qrHint}</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex justify-center rounded-xl bg-white p-3"> <div className="flex justify-center rounded-xl bg-white p-3">
<QRCodeCanvas value={inviteUrl} size={220} level="M" /> <QRCodeCanvas value={inviteUrl} size={220} level="M" />
@ -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 { t } = useTranslation();
const { data } = useAuthor(pubkey); const { data } = useAuthor(pubkey);
const name = data?.metadata?.display_name || data?.metadata?.name || shortNpub(pubkey); const isBot = !!botPubkey && pubkey === botPubkey;
const picture = data?.metadata?.picture; 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(); const initials = name.replace(/^@/, '').slice(0, 2).toUpperCase();
return ( return (
@ -872,21 +941,27 @@ function MemberRow({
pubkey, pubkey,
role, role,
isSelf, isSelf,
founderLabel,
botPubkey,
botName,
}: { }: {
pubkey: string; pubkey: string;
role: MemberRole; role: MemberRole;
isSelf: boolean; isSelf: boolean;
founderLabel: string;
botPubkey?: string;
botName?: string;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const label = const label =
role === 'founder' role === 'founder'
? t('cordnUi.roleFounder') ? founderLabel
: role === 'admin' : role === 'admin'
? t('cordnUi.roleAdmin') ? t('cordnUi.roleAdmin')
: t('cordnUi.roleMember'); : t('cordnUi.roleMember');
return ( return (
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<PersonLabel pubkey={pubkey} isSelf={isSelf} /> <PersonLabel pubkey={pubkey} isSelf={isSelf} botPubkey={botPubkey} botName={botName} />
<Badge <Badge
variant="outline" variant="outline"
className={cn( className={cn(
@ -906,15 +981,19 @@ function RequestRow({
pubkey, pubkey,
disabled, disabled,
onApprove, onApprove,
botPubkey,
botName,
}: { }: {
pubkey: string; pubkey: string;
disabled: boolean; disabled: boolean;
onApprove: () => void; onApprove: () => void;
botPubkey?: string;
botName?: string;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<PersonLabel pubkey={pubkey} /> <PersonLabel pubkey={pubkey} botPubkey={botPubkey} botName={botName} />
<Button <Button
size="sm" size="sm"
disabled={disabled} disabled={disabled}

View file

@ -16,7 +16,7 @@ import type {
CordnSession, CordnSession,
} from '@/lib/cordn/native'; } from '@/lib/cordn/native';
import { isInteractiveSigner } from '@/lib/signerCapabilities'; import { isInteractiveSigner } from '@/lib/signerCapabilities';
import type { SkateCrew } from '@/lib/types'; import type { CordnRoom } from '@/lib/cordn/room';
/** Resolved coordinator endpoint for the native (ContextVM) client. */ /** Resolved coordinator endpoint for the native (ContextVM) client. */
export interface NativeCoordinator { export interface NativeCoordinator {
@ -30,7 +30,7 @@ export interface NativeCoordinator {
* Settings → Coordinator, otherwise SkateSpots' provided coordinator. * Settings → Coordinator, otherwise SkateSpots' provided coordinator.
*/ */
export function resolveNativeCoordinator( export function resolveNativeCoordinator(
crew: SkateCrew, crew: CordnRoom,
fallback: { pubkey: string; relays: string[] } | null, fallback: { pubkey: string; relays: string[] } | null,
): NativeCoordinator | null { ): NativeCoordinator | null {
const endpoint = resolveCordnEndpoint(crew); const endpoint = resolveCordnEndpoint(crew);
@ -94,7 +94,7 @@ export function useCordnNative({
crew, crew,
enabled, enabled,
}: { }: {
crew: SkateCrew | null; crew: CordnRoom | null;
enabled: boolean; enabled: boolean;
}): UseCordnNativeResult { }): UseCordnNativeResult {
const { user } = useCurrentUser(); const { user } = useCurrentUser();
@ -112,7 +112,7 @@ export function useCordnNative({
[crew, userCoordinator], [crew, userCoordinator],
); );
const groupId = crew ? `35006:${crew.pubkey}:${crew.identifier}` : ''; const groupId = crew ? (crew.gid ?? `35006:${crew.pubkey}:${crew.identifier}`) : '';
const sessionRef = useRef<CordnSession | null>(null); const sessionRef = useRef<CordnSession | null>(null);
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);

View file

@ -8,6 +8,7 @@ import {
Package, Package,
Bookmark, Bookmark,
MessageCircle, MessageCircle,
MessagesSquare,
BarChart3, BarChart3,
Coins, Coins,
Handshake, Handshake,
@ -60,6 +61,7 @@ export const NAV_CATALOG: NavTab[] = [
{ id: 'lab', href: '/lab', labelKey: 'nav.lab', label: 'The Lab', icon: GraduationCap }, { id: 'lab', href: '/lab', labelKey: 'nav.lab', label: 'The Lab', icon: GraduationCap },
{ id: 'events', href: '/events', labelKey: 'nav.events', label: 'Events', icon: Calendar }, { id: 'events', href: '/events', labelKey: 'nav.events', label: 'Events', icon: Calendar },
{ id: 'crews', href: '/crews', labelKey: 'nav.crews', label: 'Crews', icon: Users }, { id: 'crews', href: '/crews', labelKey: 'nav.crews', label: 'Crews', icon: Users },
{ id: 'community', href: '/community', labelKey: 'communityUi.link', label: 'Community', icon: MessagesSquare },
{ id: 'wallet', href: '/wallet', labelKey: 'nav.wallet', label: 'Wallet', icon: Wallet, authOnly: true }, { id: 'wallet', href: '/wallet', labelKey: 'nav.wallet', label: 'Wallet', icon: Wallet, authOnly: true },
{ id: 'mints', href: '/mints', labelKey: 'nav.mints', label: 'Mints', icon: Coins }, { id: 'mints', href: '/mints', labelKey: 'nav.mints', label: 'Mints', icon: Coins },
{ id: 'app-shop', href: '/app-shop', labelKey: 'nav.appShop', label: 'App Shop', icon: ShoppingBag }, { id: 'app-shop', href: '/app-shop', labelKey: 'nav.appShop', label: 'App Shop', icon: ShoppingBag },

View file

@ -976,6 +976,34 @@ const batch1 = {
unreachable: "Inabastable", unreachable: "Inabastable",
dismiss: "Tanca", dismiss: "Tanca",
}, },
communityUi: {
"link": "Comunitat",
"title": "Xat de la comunitat",
"subtitle": "Fes preguntes, comparteix comentaris i parla amb la comunitat de SkateSpots: xifrat d'extrem a extrem.",
"heading": "Comunitat SkateSpots",
"eyebrow": "Preguntes i comentaris",
"seoTitle": "Xat de la comunitat de SkateSpots",
"seoDescription": "Preguntes, comentaris i xerrada comunitària: xifrats d'extrem a extrem.",
"notLoggedIn": "Inicia sessió per entrar al xat de la comunitat.",
"noGroupAdmin": "Encara no s'ha creat la sala de la comunitat.",
"noGroupMember": "Encara no ets a la sala de la comunitat.",
"create": "Crea la sala de la comunitat",
"setupDeviceBody": "Això dóna a aquest dispositiu una clau per a la sala de la comunitat. Un administrador o el bot l'aprova una vegada.",
"onboardingFounder1": "Crea la sala de la comunitat.",
"onboardingFounder2": "Aprova el bot de la comunitat i els membres nous.",
"onboardingFounder3": "Respon preguntes i recull comentaris.",
"onboardingMember1": "Demana d'entrar a la sala de la comunitat.",
"onboardingMember2": "Un administrador o el bot de la comunitat aprova el teu dispositiu.",
"onboardingMember3": "Fes preguntes, comparteix comentaris o simplement saluda.",
"encryptedNote": "Només els membres de la sala ho poden llegir; el coordinador només retransmet missatges xifrats.",
"shareInviteText": "Uneix-te al xat de la comunitat de SkateSpots",
"qrTitle": "Escaneja per entrar al xat de la comunitat",
"qrHint": "Escaneja amb la càmera del mòbil per obrir el xat de la comunitat i demanar d'entrar.",
"owner": "Propietari",
"botName": "Bot de la comunitat",
"howItWorks": "Un administrador o el bot de la comunitat aprova cada dispositiu nou una vegada. Els missatges enviats abans que t'hi uneixis no els veus.",
"feedbackNote": "Les preguntes i els comentaris són benvinguts. Sigues amable: és una sala compartida.",
},
"trustUi": { "trustUi": {
"alertsTitle": "Alertes de la xarxa", "alertsTitle": "Alertes de la xarxa",
"alertsDesc": "Comptes que segueixes que persones de confiança han denunciat o silenciat.", "alertsDesc": "Comptes que segueixes que persones de confiança han denunciat o silenciat.",

View file

@ -354,6 +354,34 @@ const drift = {
unreachable: "Nedostupné", unreachable: "Nedostupné",
dismiss: "Zavřít", dismiss: "Zavřít",
}, },
communityUi: {
"link": "Komunita",
"title": "Komunitní chat",
"subtitle": "Ptej se, sdílej zpětnou vazbu a mluv s komunitou SkateSpots — end-to-end šifrované.",
"heading": "Komunita SkateSpots",
"eyebrow": "Otázky a zpětná vazba",
"seoTitle": "Komunitní chat SkateSpots",
"seoDescription": "Otázky, zpětná vazba a komunitní povídání — end-to-end šifrované.",
"notLoggedIn": "Přihlas se, abys mohl vstoupit do komunitního chatu.",
"noGroupAdmin": "Komunitní místnost ještě nebyla vytvořena.",
"noGroupMember": "V komunitní místnosti zatím nejsi.",
"create": "Vytvořit komunitní místnost",
"setupDeviceBody": "Toto zařízení tím získá klíč do komunitní místnosti. Schválí ho jednou administrátor nebo bot.",
"onboardingFounder1": "Vytvoř komunitní místnost.",
"onboardingFounder2": "Schvaluj komunitního bota a nové členy.",
"onboardingFounder3": "Odpovídej na otázky a sbírej zpětnou vazbu.",
"onboardingMember1": "Požádej o vstup do komunitní místnosti.",
"onboardingMember2": "Tvé zařízení schválí administrátor nebo komunitní bot.",
"onboardingMember3": "Ptej se, sdílej zpětnou vazbu, nebo se prostě ozvi.",
"encryptedNote": "Číst to mohou jen členové místnosti; koordinátor pouze přenáší šifrované zprávy.",
"shareInviteText": "Přidej se ke komunitnímu chatu SkateSpots",
"qrTitle": "Naskenuj a připoj se ke komunitnímu chatu",
"qrHint": "Naskenuj kamerou telefonu, otevři komunitní chat a požádej o vstup.",
"owner": "Vlastník",
"botName": "Komunitní bot",
"howItWorks": "Nová zařízení jednou schválí administrátor nebo komunitní bot. Zprávy odeslané před tvým připojením pro tebe nejsou viditelné.",
"feedbackNote": "Otázky a zpětná vazba jsou vítány. Buď milý — je to společná místnost.",
},
translateUi: { translateUi: {
title: "Překlad zpráv", title: "Překlad zpráv",
translate: "Přeložit", translate: "Přeložit",

View file

@ -356,6 +356,34 @@ const drift = {
unreachable: "Nicht erreichbar", unreachable: "Nicht erreichbar",
dismiss: "Schließen", dismiss: "Schließen",
}, },
communityUi: {
link: "Community",
title: "Community-Chat",
subtitle: "Stell Fragen, gib Feedback und sprich mit der SkateSpots-Community – Ende-zu-Ende verschlüsselt.",
heading: "SkateSpots Community",
eyebrow: "Fragen & Feedback",
seoTitle: "SkateSpots Community-Chat",
seoDescription: "Fragen, Feedback und Community-Austausch – Ende-zu-Ende verschlüsselt.",
notLoggedIn: "Melde dich an, um am Community-Chat teilzunehmen.",
noGroupAdmin: "Der Community-Raum wurde noch nicht erstellt.",
noGroupMember: "Du bist noch nicht im Community-Raum.",
create: "Community-Raum erstellen",
setupDeviceBody: "Damit erhält dieses Gerät einen Schlüssel für den Community-Raum. Ein Admin oder der Bot bestätigt ihn einmal.",
onboardingFounder1: "Erstelle den Community-Raum.",
onboardingFounder2: "Bestätige den Community-Bot und neue Mitglieder.",
onboardingFounder3: "Beantworte Fragen und sammle Feedback.",
onboardingMember1: "Bitte um Zugang zum Community-Raum.",
onboardingMember2: "Ein Admin oder der Community-Bot bestätigt dein Gerät.",
onboardingMember3: "Stell Fragen, gib Feedback oder sag einfach Hallo.",
encryptedNote: "Nur Mitglieder des Raums können das lesen; der Koordinator leitet ausschließlich verschlüsselte Nachrichten weiter.",
shareInviteText: "Komm in den SkateSpots-Community-Chat",
qrTitle: "Scannen und dem Community-Chat beitreten",
qrHint: "Scanne mit der Handykamera, um den Community-Chat zu öffnen und Zugang zu erbitten.",
owner: "Eigentümer",
botName: "Community-Bot",
howItWorks: "Neue Geräte werden einmal von einem Admin oder dem Community-Bot bestätigt. Nachrichten, die vor deinem Beitritt gesendet wurden, kannst du nicht sehen.",
feedbackNote: "Fragen und Feedback sind willkommen. Sei freundlich – es ist ein gemeinsamer Raum.",
},
translateUi: { translateUi: {
title: "Nachrichtenübersetzung", title: "Nachrichtenübersetzung",
translate: "Übersetzen", translate: "Übersetzen",

View file

@ -358,6 +358,34 @@ const drift = {
unreachable: "Μη προσβάσιμο", unreachable: "Μη προσβάσιμο",
dismiss: "Κλείσιμο", dismiss: "Κλείσιμο",
}, },
communityUi: {
link: "Κοινότητα",
title: "Συνομιλία κοινότητας",
subtitle: "Κάνε ερωτήσεις, μοιράσου σχόλια και μίλα με την κοινότητα SkateSpots — με κρυπτογράφηση από άκρο σε άκρο.",
heading: "Κοινότητα SkateSpots",
eyebrow: "Ερωτήσεις & σχόλια",
seoTitle: "Συνομιλία κοινότητας SkateSpots",
seoDescription: "Ερωτήσεις, σχόλια και κουβέντα στην κοινότητα — με κρυπτογράφηση από άκρο σε άκρο.",
notLoggedIn: "Συνδέσου για να μπεις στη συνομιλία της κοινότητας.",
noGroupAdmin: "Το δωμάτιο της κοινότητας δεν έχει δημιουργηθεί ακόμη.",
noGroupMember: "Δεν είσαι ακόμη στο δωμάτιο της κοινότητας.",
create: "Δημιουργία δωματίου κοινότητας",
setupDeviceBody: "Αυτό δίνει σε αυτή τη συσκευή ένα κλειδί για το δωμάτιο της κοινότητας. Ένας διαχειριστής ή το bot το εγκρίνει μία φορά.",
onboardingFounder1: "Δημιούργησε το δωμάτιο της κοινότητας.",
onboardingFounder2: "Έγκρινε το bot της κοινότητας και τα νέα μέλη.",
onboardingFounder3: "Απάντησε σε ερωτήσεις και συγκέντρωσε σχόλια.",
onboardingMember1: "Ζήτησε να μπεις στο δωμάτιο της κοινότητας.",
onboardingMember2: "Ένας διαχειριστής ή το bot της κοινότητας εγκρίνει τη συσκευή σου.",
onboardingMember3: "Κάνε ερωτήσεις, μοιράσου σχόλια ή απλώς χαιρέτα.",
encryptedNote: "Μόνο τα μέλη του δωματίου μπορούν να το διαβάσουν· ο συντονιστής απλώς μεταφέρει κρυπτογραφημένα μηνύματα.",
shareInviteText: "Μπες στη συνομιλία της κοινότητας SkateSpots",
qrTitle: "Σάρωσε για να μπεις στη συνομιλία της κοινότητας",
qrHint: "Σάρωσε με την κάμερα του κινητού για να ανοίξεις τη συνομιλία της κοινότητας και να ζητήσεις πρόσβαση.",
owner: "Ιδιοκτήτης",
botName: "Bot κοινότητας",
howItWorks: "Κάθε νέα συσκευή εγκρίνεται μία φορά από έναν διαχειριστή ή το bot της κοινότητας. Τα μηνύματα που στάλθηκαν πριν μπεις δεν είναι ορατά σε εσένα.",
feedbackNote: "Οι ερωτήσεις και τα σχόλια είναι ευπρόσδεκτα. Να είσαι ευγενικός — είναι κοινός χώρος.",
},
translateUi: { translateUi: {
title: "Μετάφραση μηνυμάτων", title: "Μετάφραση μηνυμάτων",
translate: "Μετάφραση", translate: "Μετάφραση",

View file

@ -976,6 +976,34 @@ const batch1 = {
unreachable: "Ezin iritsi", unreachable: "Ezin iritsi",
dismiss: "Itxi", dismiss: "Itxi",
}, },
communityUi: {
link: "Komunitatea",
title: "Komunitate txata",
subtitle: "Egin galderak, partekatu iritziak eta hitz egin SkateSpots komunitatearekin — muturretik muturrerako enkriptatua.",
heading: "SkateSpots Komunitatea",
eyebrow: "Galderak eta iritziak",
seoTitle: "SkateSpots komunitate txata",
seoDescription: "Galderak, iritziak eta komunitateko solasaldia — muturretik muturrerako enkriptatua.",
notLoggedIn: "Hasi saioa komunitate txatean sartzeko.",
noGroupAdmin: "Komunitate gela oraindik ez da sortu.",
noGroupMember: "Oraindik ez zaude komunitate gelan.",
create: "Sortu komunitate gela",
setupDeviceBody: "Honek gailu honi komunitate gelarako gako bat ematen dio. Administratzaile batek edo botak behin onartzen du.",
onboardingFounder1: "Sortu komunitate gela.",
onboardingFounder2: "Onartu komunitate bota eta kide berriak.",
onboardingFounder3: "Erantzun galderei eta bildu iritziak.",
onboardingMember1: "Eskatu komunitate gelan sartzea.",
onboardingMember2: "Administratzaile batek edo komunitate botak onartzen du zure gailua.",
onboardingMember3: "Egin galderak, partekatu iritziak edo besterik gabe agurtu.",
encryptedNote: "Gelako kideek soilik irakur dezakete hau; koordinatzaileak mezu enkriptatuak baino ez ditu igarotzen.",
shareInviteText: "Sartu SkateSpots komunitate txatean",
qrTitle: "Eskaneatu komunitate txatean sartzeko",
qrHint: "Eskaneatu telefonoko kamerarekin komunitate txata irekitzeko eta sartzeko eskaera egiteko.",
owner: "Jabea",
botName: "Komunitate bota",
howItWorks: "Gailu berriak administratzaile batek edo komunitate botak onartzen ditu behin. Sartu aurretik bidalitako mezuak ez dituzu ikusten.",
feedbackNote: "Galderak eta iritziak ongi etorriak dira. Izan atsegina — partekatutako gela da.",
},
trustUi: { trustUi: {
alertsTitle: "Sareko alertak", alertsTitle: "Sareko alertak",
alertsDesc: "Jarraitzen dituzun kontuak, pertsona fidagarriek salatu edo isilarazi dituztenak.", alertsDesc: "Jarraitzen dituzun kontuak, pertsona fidagarriek salatu edo isilarazi dituztenak.",

View file

@ -356,6 +356,34 @@ const drift = {
unreachable: "Injoignable", unreachable: "Injoignable",
dismiss: "Fermer", dismiss: "Fermer",
}, },
communityUi: {
link: "Communauté",
title: "Chat de la communauté",
subtitle: "Pose des questions, partage tes retours et échange avec la communauté SkateSpots — chiffré de bout en bout.",
heading: "Communauté SkateSpots",
eyebrow: "Questions et retours",
seoTitle: "Chat de la communauté SkateSpots",
seoDescription: "Questions, retours et discussions communautaires — chiffrés de bout en bout.",
notLoggedIn: "Connecte-toi pour rejoindre le chat de la communauté.",
noGroupAdmin: "Le salon de la communauté n’a pas encore été créé.",
noGroupMember: "Tu n’es pas encore dans le salon de la communauté.",
create: "Créer le salon de la communauté",
setupDeviceBody: "Cela donne à cet appareil une clé pour le salon de la communauté. Un admin ou le bot l’approuve une fois.",
onboardingFounder1: "Crée le salon de la communauté.",
onboardingFounder2: "Approuve le bot de la communauté et les nouveaux membres.",
onboardingFounder3: "Réponds aux questions et recueille les retours.",
onboardingMember1: "Demande à rejoindre le salon de la communauté.",
onboardingMember2: "Un admin ou le bot de la communauté approuve ton appareil.",
onboardingMember3: "Pose des questions, partage tes retours ou dis simplement bonjour.",
encryptedNote: "Seuls les membres du salon peuvent lire ceci ; le coordinateur ne relaie que des messages chiffrés.",
shareInviteText: "Rejoins le chat de la communauté SkateSpots",
qrTitle: "Scanne pour rejoindre le chat de la communauté",
qrHint: "Scanne avec l’appareil photo de ton téléphone pour ouvrir le chat de la communauté et demander à le rejoindre.",
owner: "Propriétaire",
botName: "Bot de la communauté",
howItWorks: "Les nouveaux appareils sont approuvés une fois par un admin ou le bot de la communauté. Les messages envoyés avant ton arrivée ne te sont pas visibles.",
feedbackNote: "Les questions et les retours sont les bienvenus. Reste courtois — c’est un salon partagé.",
},
translateUi: { translateUi: {
title: "Traduction des messages", title: "Traduction des messages",
translate: "Traduire", translate: "Traduire",

View file

@ -976,6 +976,34 @@ const batch1 = {
unreachable: "Inaccesible", unreachable: "Inaccesible",
dismiss: "Pechar", dismiss: "Pechar",
}, },
communityUi: {
link: "Comunidade",
title: "Chat da comunidade",
subtitle: "Fai preguntas, comparte comentarios e fala coa comunidade de SkateSpots — cifrado de extremo a extremo.",
heading: "Comunidade SkateSpots",
eyebrow: "Preguntas e comentarios",
seoTitle: "Chat da comunidade de SkateSpots",
seoDescription: "Preguntas, comentarios e conversa da comunidade — cifrados de extremo a extremo.",
notLoggedIn: "Inicia sesión para entrar no chat da comunidade.",
noGroupAdmin: "A sala da comunidade aínda non se creou.",
noGroupMember: "Aínda non estás na sala da comunidade.",
create: "Crear a sala da comunidade",
setupDeviceBody: "Isto dálle a este dispositivo unha chave para a sala da comunidade. Un administrador ou o bot apruxaa unha vez.",
onboardingFounder1: "Crea a sala da comunidade.",
onboardingFounder2: "Aproba o bot da comunidade e os novos membros.",
onboardingFounder3: "Responde preguntas e recolle comentarios.",
onboardingMember1: "Pide entrar na sala da comunidade.",
onboardingMember2: "Un administrador ou o bot da comunidade aproba o teu dispositivo.",
onboardingMember3: "Fai preguntas, comparte comentarios ou simplemente saúda.",
encryptedNote: "Só os membros da sala poden lelo; o coordinador só transmite mensaxes cifradas.",
shareInviteText: "Únete ao chat da comunidade de SkateSpots",
qrTitle: "Escanea para entrar no chat da comunidade",
qrHint: "Escanea coa cámara do móbil para abrir o chat da comunidade e pedir entrar.",
owner: "Propietario",
botName: "Bot da comunidade",
howItWorks: "Un administrador ou o bot da comunidade aproba cada dispositivo novo unha vez. As mensaxes enviadas antes de que te unas non che aparecen.",
feedbackNote: "As preguntas e os comentarios son benvidos. Sé amable — é unha sala compartida.",
},
trustUi: { trustUi: {
alertsTitle: "Avisos da rede", alertsTitle: "Avisos da rede",
alertsDesc: "Contas que segues e que persoas de confianza denunciaron ou silenciaron.", alertsDesc: "Contas que segues e que persoas de confianza denunciaron ou silenciaron.",

View file

@ -976,6 +976,34 @@ const batch1 = {
unreachable: "पहुँच से बाहर", unreachable: "पहुँच से बाहर",
dismiss: "बंद करें", dismiss: "बंद करें",
}, },
communityUi: {
"link": "कम्युनिटी",
"title": "कम्युनिटी चैट",
"subtitle": "सवाल पूछें, फ़ीडबैक साझा करें और SkateSpots कम्युनिटी से बात करें — एंड-टू-एंड एन्क्रिप्टेड।",
"heading": "SkateSpots कम्युनिटी",
"eyebrow": "सवाल और फ़ीडबैक",
"seoTitle": "SkateSpots कम्युनिटी चैट",
"seoDescription": "सवाल, फ़ीडबैक और कम्युनिटी की बातचीत — एंड-टू-एंड एन्क्रिप्टेड।",
"notLoggedIn": "कम्युनिटी चैट में शामिल होने के लिए लॉग इन करें।",
"noGroupAdmin": "कम्युनिटी रूम अभी नहीं बनाया गया है।",
"noGroupMember": "आप अभी कम्युनिटी रूम में नहीं हैं।",
"create": "कम्युनिटी रूम बनाएँ",
"setupDeviceBody": "इससे इस डिवाइस को कम्युनिटी रूम की एक कुंजी मिलती है। कोई एडमिन या बॉट इसे एक बार मंज़ूर करता है।",
"onboardingFounder1": "कम्युनिटी रूम बनाएँ।",
"onboardingFounder2": "कम्युनिटी बॉट और नए सदस्यों को मंज़ूर करें।",
"onboardingFounder3": "सवालों के जवाब दें और फ़ीडबैक इकट्ठा करें।",
"onboardingMember1": "कम्युनिटी रूम में शामिल होने का अनुरोध करें।",
"onboardingMember2": "कोई एडमिन या कम्युनिटी बॉट आपका डिवाइस मंज़ूर करता है।",
"onboardingMember3": "सवाल पूछें, फ़ीडबैक साझा करें या बस हैलो कहें।",
"encryptedNote": "इसे सिर्फ़ रूम के सदस्य पढ़ सकते हैं; कोऑर्डिनेटर सिर्फ़ एन्क्रिप्टेड संदेश आगे भेजता है।",
"shareInviteText": "SkateSpots कम्युनिटी चैट से जुड़ें",
"qrTitle": "कम्युनिटी चैट से जुड़ने के लिए स्कैन करें",
"qrHint": "कम्युनिटी चैट खोलने और शामिल होने का अनुरोध करने के लिए फ़ोन के कैमरे से स्कैन करें।",
"owner": "मालिक",
"botName": "कम्युनिटी बॉट",
"howItWorks": "नए डिवाइस को कोई एडमिन या कम्युनिटी बॉट एक बार मंज़ूर करता है। आपके जुड़ने से पहले भेजे गए संदेश आपको नहीं दिखते।",
"feedbackNote": "सवाल और फ़ीडबैक का स्वागत है। विनम्र रहें — यह साझा रूम है।",
},
"trustUi": { "trustUi": {
"alertsTitle": "नेटवर्क अलर्ट", "alertsTitle": "नेटवर्क अलर्ट",
"alertsDesc": "आप जिन खातों को फ़ॉलो करते हैं, उन्हें भरोसेमंद लोगों ने रिपोर्ट या म्यूट किया है।", "alertsDesc": "आप जिन खातों को फ़ॉलो करते हैं, उन्हें भरोसेमंद लोगों ने रिपोर्ट या म्यूट किया है।",

View file

@ -350,6 +350,34 @@ const driftStrings = {
unreachable: "Tidak terjangkau", unreachable: "Tidak terjangkau",
dismiss: "Tutup", dismiss: "Tutup",
}, },
communityUi: {
link: "Komunitas",
title: "Chat komunitas",
subtitle: "Ajukan pertanyaan, bagikan masukan, dan mengobrol dengan komunitas SkateSpots — terenkripsi end-to-end.",
heading: "Komunitas SkateSpots",
eyebrow: "Pertanyaan & masukan",
seoTitle: "Chat komunitas SkateSpots",
seoDescription: "Pertanyaan, masukan, dan obrolan komunitas — terenkripsi end-to-end.",
notLoggedIn: "Masuk untuk bergabung ke chat komunitas.",
noGroupAdmin: "Ruang komunitas belum dibuat.",
noGroupMember: "Kamu belum berada di ruang komunitas.",
create: "Buat ruang komunitas",
setupDeviceBody: "Ini memberi perangkat ini kunci untuk ruang komunitas. Admin atau bot menyetujuinya satu kali.",
onboardingFounder1: "Buat ruang komunitas.",
onboardingFounder2: "Setujui bot komunitas dan anggota baru.",
onboardingFounder3: "Jawab pertanyaan dan kumpulkan masukan.",
onboardingMember1: "Minta bergabung ke ruang komunitas.",
onboardingMember2: "Admin atau bot komunitas menyetujui perangkatmu.",
onboardingMember3: "Ajukan pertanyaan, bagikan masukan, atau sekadar menyapa.",
encryptedNote: "Hanya anggota ruang yang dapat membaca ini; koordinator hanya meneruskan pesan terenkripsi.",
shareInviteText: "Gabung ke chat komunitas SkateSpots",
qrTitle: "Pindai untuk bergabung ke chat komunitas",
qrHint: "Pindai dengan kamera ponsel untuk membuka chat komunitas dan meminta bergabung.",
owner: "Pemilik",
botName: "Bot komunitas",
howItWorks: "Perangkat baru disetujui satu kali oleh admin atau bot komunitas. Pesan yang dikirim sebelum kamu bergabung tidak terlihat olehmu.",
feedbackNote: "Pertanyaan dan masukan sangat diterima. Bersikap baiklah — ini ruang bersama.",
},
translateUi: { translateUi: {
title: 'Terjemahan pesan', title: 'Terjemahan pesan',
translate: 'Terjemahkan', translate: 'Terjemahkan',

View file

@ -356,6 +356,34 @@ const drift = {
unreachable: "Non raggiungibile", unreachable: "Non raggiungibile",
dismiss: "Chiudi", dismiss: "Chiudi",
}, },
communityUi: {
link: "Community",
title: "Chat della community",
subtitle: "Fai domande, condividi feedback e parla con la community di SkateSpots — crittografato end-to-end.",
heading: "Community SkateSpots",
eyebrow: "Domande e feedback",
seoTitle: "Chat della community SkateSpots",
seoDescription: "Domande, feedback e chiacchiere della community — crittografate end-to-end.",
notLoggedIn: "Accedi per entrare nella chat della community.",
noGroupAdmin: "La stanza della community non è ancora stata creata.",
noGroupMember: "Non sei ancora nella stanza della community.",
create: "Crea la stanza della community",
setupDeviceBody: "Questo assegna a questo dispositivo una chiave per la stanza della community. Un admin o il bot la approva una volta.",
onboardingFounder1: "Crea la stanza della community.",
onboardingFounder2: "Approva il bot della community e i nuovi membri.",
onboardingFounder3: "Rispondi alle domande e raccogli feedback.",
onboardingMember1: "Chiedi di entrare nella stanza della community.",
onboardingMember2: "Un admin o il bot della community approva il tuo dispositivo.",
onboardingMember3: "Fai domande, condividi feedback o saluta e basta.",
encryptedNote: "Solo i membri della stanza possono leggerlo; il coordinatore inoltra solo messaggi crittografati.",
shareInviteText: "Entra nella chat della community SkateSpots",
qrTitle: "Scansiona per entrare nella chat della community",
qrHint: "Scansiona con la fotocamera del telefono per aprire la chat della community e chiedere di entrare.",
owner: "Proprietario",
botName: "Bot della community",
howItWorks: "I nuovi dispositivi vengono approvati una volta da un admin o dal bot della community. I messaggi inviati prima che tu entri non ti sono visibili.",
feedbackNote: "Domande e feedback sono benvenuti. Sii gentile — è una stanza condivisa.",
},
translateUi: { translateUi: {
title: "Traduzione dei messaggi", title: "Traduzione dei messaggi",
translate: "Traduci", translate: "Traduci",

View file

@ -366,6 +366,34 @@ const drift: Dict = {
unreachable: "接続不可", unreachable: "接続不可",
dismiss: "閉じる", dismiss: "閉じる",
}, },
communityUi: {
link: "コミュニティ",
title: "コミュニティチャット",
subtitle: "質問したり、フィードバックを共有したり、SkateSpots コミュニティと話しましょう — エンドツーエンド暗号化。",
heading: "SkateSpots コミュニティ",
eyebrow: "質問とフィードバック",
seoTitle: "SkateSpots コミュニティチャット",
seoDescription: "質問、フィードバック、コミュニティの会話 — エンドツーエンド暗号化。",
notLoggedIn: "コミュニティチャットに参加するにはログインしてください。",
noGroupAdmin: "コミュニティルームはまだ作成されていません。",
noGroupMember: "あなたはまだコミュニティルームに参加していません。",
create: "コミュニティルームを作成",
setupDeviceBody: "このデバイスにコミュニティルームの鍵を付与します。管理者またはボットが一度だけ承認します。",
onboardingFounder1: "コミュニティルームを作成します。",
onboardingFounder2: "コミュニティボットと新しいメンバーを承認します。",
onboardingFounder3: "質問に答え、フィードバックを集めます。",
onboardingMember1: "コミュニティルームへの参加をリクエストします。",
onboardingMember2: "管理者またはコミュニティボットがあなたのデバイスを承認します。",
onboardingMember3: "質問したり、フィードバックを共有したり、あいさつするだけでもどうぞ。",
encryptedNote: "ルームのメンバーだけが読めます。コーディネーターは暗号化されたメッセージを中継するだけです。",
shareInviteText: "SkateSpots コミュニティチャットに参加",
qrTitle: "スキャンしてコミュニティチャットに参加",
qrHint: "スマートフォンのカメラでスキャンして、コミュニティチャットを開き参加をリクエストします。",
owner: "オーナー",
botName: "コミュニティボット",
howItWorks: "新しいデバイスは管理者またはコミュニティボットが一度だけ承認します。参加前に送られたメッセージは表示されません。",
feedbackNote: "質問もフィードバックも歓迎です。みんなのルームなので、やさしくお願いします。",
},
translateUi: { translateUi: {
title: 'メッセージの翻訳', title: 'メッセージの翻訳',
translate: '翻訳', translate: '翻訳',

View file

@ -366,6 +366,34 @@ const drift: Dict = {
unreachable: "연결 불가", unreachable: "연결 불가",
dismiss: "닫기", dismiss: "닫기",
}, },
communityUi: {
link: "커뮤니티",
title: "커뮤니티 채팅",
subtitle: "질문하고 피드백을 나누며 SkateSpots 커뮤니티와 이야기하세요 — 종단간 암호화.",
heading: "SkateSpots 커뮤니티",
eyebrow: "질문 및 피드백",
seoTitle: "SkateSpots 커뮤니티 채팅",
seoDescription: "질문, 피드백, 커뮤니티 이야기 — 종단간 암호화.",
notLoggedIn: "커뮤니티 채팅에 참여하려면 로그인하세요.",
noGroupAdmin: "커뮤니티 방이 아직 만들어지지 않았습니다.",
noGroupMember: "아직 커뮤니티 방에 참여하지 않았습니다.",
create: "커뮤니티 방 만들기",
setupDeviceBody: "이 기기에 커뮤니티 방 열쇠를 부여합니다. 관리자나 봇이 한 번 승인합니다.",
onboardingFounder1: "커뮤니티 방을 만드세요.",
onboardingFounder2: "커뮤니티 봇과 새 멤버를 승인하세요.",
onboardingFounder3: "질문에 답하고 피드백을 모으세요.",
onboardingMember1: "커뮤니티 방 참여를 요청하세요.",
onboardingMember2: "관리자나 커뮤니티 봇이 기기를 승인합니다.",
onboardingMember3: "질문하고, 피드백을 나누거나, 그냥 인사해도 좋습니다.",
encryptedNote: "방 멤버만 읽을 수 있으며, 코디네이터는 암호화된 메시지만 중계합니다.",
shareInviteText: "SkateSpots 커뮤니티 채팅에 참여하세요",
qrTitle: "스캔하여 커뮤니티 채팅 참여",
qrHint: "휴대폰 카메라로 스캔해 커뮤니티 채팅을 열고 참여를 요청하세요.",
owner: "소유자",
botName: "커뮤니티 봇",
howItWorks: "새 기기는 관리자나 커뮤니티 봇이 한 번 승인합니다. 참여하기 전에 보낸 메시지는 표시되지 않습니다.",
feedbackNote: "질문과 피드백을 환영합니다. 모두의 공간이니 친절하게 대해 주세요.",
},
translateUi: { translateUi: {
title: '메시지 번역', title: '메시지 번역',
translate: '번역', translate: '번역',

View file

@ -0,0 +1,74 @@
/**
* `communityUi.*` — the app-wide SkateSpots Community Cordn room (questions,
* feedback). Kept in the same `{ en, es }` fragment pattern as `cordnUi.ts`.
*/
const communityUi = {
en: {
link: 'Community',
title: 'Community chat',
subtitle:
'Ask questions, share feedback, and talk with the SkateSpots community — end-to-end encrypted.',
heading: 'SkateSpots Community',
eyebrow: 'Questions & feedback',
seoTitle: 'SkateSpots Community chat',
seoDescription: 'Questions, feedback, and community talk — end-to-end encrypted.',
notLoggedIn: 'Log in to join the community chat.',
noGroupAdmin: 'The community room hasn’t been created yet.',
noGroupMember: 'You’re not in the community room yet.',
create: 'Create the community room',
setupDeviceBody:
'This gives this device a key for the community room. An admin or the bot approves it once.',
onboardingFounder1: 'Create the community room.',
onboardingFounder2: 'Approve the community bot and new members.',
onboardingFounder3: 'Answer questions and collect feedback.',
onboardingMember1: 'Ask to join the community room.',
onboardingMember2: 'An admin or the community bot approves your device.',
onboardingMember3: 'Ask questions, share feedback, or just say hi.',
encryptedNote:
'Only room members can read this; the coordinator only relays encrypted messages.',
shareInviteText: 'Join the SkateSpots community chat',
qrTitle: 'Scan to join the community chat',
qrHint: 'Scan with your phone camera to open the community chat and ask to join.',
owner: 'Owner',
botName: 'Community bot',
howItWorks:
'New devices are approved once by an admin or the community bot. Messages sent before you join aren’t visible to you.',
feedbackNote: 'Questions and feedback are welcome. Be kind — it’s a shared room.',
},
es: {
link: 'Comunidad',
title: 'Chat de la comunidad',
subtitle:
'Haz preguntas, comparte comentarios y habla con la comunidad de SkateSpots: cifrado de extremo a extremo.',
heading: 'Comunidad SkateSpots',
eyebrow: 'Preguntas y comentarios',
seoTitle: 'Chat de la comunidad de SkateSpots',
seoDescription:
'Preguntas, comentarios y charla comunitaria: cifrado de extremo a extremo.',
notLoggedIn: 'Inicia sesión para entrar al chat de la comunidad.',
noGroupAdmin: 'Aún no se ha creado la sala de la comunidad.',
noGroupMember: 'Aún no estás en la sala de la comunidad.',
create: 'Crear la sala de la comunidad',
setupDeviceBody:
'Esto da a este dispositivo una clave para la sala de la comunidad. Un administrador o el bot la aprueba una vez.',
onboardingFounder1: 'Crea la sala de la comunidad.',
onboardingFounder2: 'Aprueba al bot de la comunidad y a los nuevos miembros.',
onboardingFounder3: 'Responde preguntas y recoge comentarios.',
onboardingMember1: 'Pide entrar en la sala de la comunidad.',
onboardingMember2: 'Un administrador o el bot de la comunidad aprueba tu dispositivo.',
onboardingMember3: 'Haz preguntas, comparte comentarios o simplemente saluda.',
encryptedNote:
'Solo los miembros de la sala pueden leerlo; el coordinador solo retransmite mensajes cifrados.',
shareInviteText: 'Únete al chat de la comunidad de SkateSpots',
qrTitle: 'Escanea para entrar al chat de la comunidad',
qrHint:
'Escanea con la cámara del móvil para abrir el chat de la comunidad y pedir entrar.',
owner: 'Propietario',
botName: 'Bot de la comunidad',
howItWorks:
'Un administrador o el bot de la comunidad aprueba cada dispositivo nuevo una vez. Los mensajes enviados antes de unirte no son visibles para ti.',
feedbackNote: 'Las preguntas y los comentarios son bienvenidos. Sé amable: es una sala compartida.',
},
};
export default communityUi;

View file

@ -5,6 +5,7 @@ import skaterLabels from './skaterLabels';
import notify from './notify'; import notify from './notify';
import misc from './misc'; import misc from './misc';
import cordnUi from './cordnUi'; import cordnUi from './cordnUi';
import communityUi from './communityUi';
/** /**
* Namespaces added by the hardcoded-string sweep (2026-10-07), spread into * Namespaces added by the hardcoded-string sweep (2026-10-07), spread into
@ -19,6 +20,7 @@ export const legacyEn = {
notifyUi: notify.en, notifyUi: notify.en,
legacyMiscUi: misc.en, legacyMiscUi: misc.en,
cordnUi: cordnUi.en, cordnUi: cordnUi.en,
communityUi: communityUi.en,
}; };
export const legacyEs = { export const legacyEs = {
@ -29,4 +31,5 @@ export const legacyEs = {
notifyUi: notify.es, notifyUi: notify.es,
legacyMiscUi: misc.es, legacyMiscUi: misc.es,
cordnUi: cordnUi.es, cordnUi: cordnUi.es,
communityUi: communityUi.es,
}; };

View file

@ -354,6 +354,34 @@ const drift = {
unreachable: "Onbereikbaar", unreachable: "Onbereikbaar",
dismiss: "Sluiten", dismiss: "Sluiten",
}, },
communityUi: {
"link": "Community",
"title": "Communitychat",
"subtitle": "Stel vragen, deel feedback en praat met de SkateSpots-community — end-to-end versleuteld.",
"heading": "SkateSpots Community",
"eyebrow": "Vragen & feedback",
"seoTitle": "SkateSpots-communitychat",
"seoDescription": "Vragen, feedback en communitygesprekken — end-to-end versleuteld.",
"notLoggedIn": "Log in om mee te doen aan de communitychat.",
"noGroupAdmin": "De communityruimte is nog niet aangemaakt.",
"noGroupMember": "Je bent nog niet in de communityruimte.",
"create": "Communityruimte aanmaken",
"setupDeviceBody": "Dit geeft dit apparaat een sleutel voor de communityruimte. Een beheerder of de bot keurt die één keer goed.",
"onboardingFounder1": "Maak de communityruimte aan.",
"onboardingFounder2": "Keur de communitybot en nieuwe leden goed.",
"onboardingFounder3": "Beantwoord vragen en verzamel feedback.",
"onboardingMember1": "Vraag om lid te worden van de communityruimte.",
"onboardingMember2": "Een beheerder of de communitybot keurt je apparaat goed.",
"onboardingMember3": "Stel vragen, deel feedback of zeg gewoon hallo.",
"encryptedNote": "Alleen leden van de ruimte kunnen dit lezen; de coördinator geeft alleen versleutelde berichten door.",
"shareInviteText": "Doe mee met de SkateSpots-communitychat",
"qrTitle": "Scan om mee te doen aan de communitychat",
"qrHint": "Scan met de camera van je telefoon om de communitychat te openen en om deelname te vragen.",
"owner": "Eigenaar",
"botName": "Communitybot",
"howItWorks": "Nieuwe apparaten worden één keer goedgekeurd door een beheerder of de communitybot. Berichten van vóór je deelname zijn niet zichtbaar voor jou.",
"feedbackNote": "Vragen en feedback zijn welkom. Wees aardig — het is een gedeelde ruimte.",
},
translateUi: { translateUi: {
title: "Berichtvertaling", title: "Berichtvertaling",
translate: "Vertalen", translate: "Vertalen",

View file

@ -354,6 +354,34 @@ const drift = {
unreachable: "Nieosiągalne", unreachable: "Nieosiągalne",
dismiss: "Zamknij", dismiss: "Zamknij",
}, },
communityUi: {
"link": "Społeczność",
"title": "Czat społeczności",
"subtitle": "Zadawaj pytania, dziel się opinią i rozmawiaj ze społecznością SkateSpots — szyfrowanie end-to-end.",
"heading": "Społeczność SkateSpots",
"eyebrow": "Pytania i opinie",
"seoTitle": "Czat społeczności SkateSpots",
"seoDescription": "Pytania, opinie i rozmowy społeczności — szyfrowane end-to-end.",
"notLoggedIn": "Zaloguj się, aby dołączyć do czatu społeczności.",
"noGroupAdmin": "Pokój społeczności nie został jeszcze utworzony.",
"noGroupMember": "Nie jesteś jeszcze w pokoju społeczności.",
"create": "Utwórz pokój społeczności",
"setupDeviceBody": "To daje temu urządzeniu klucz do pokoju społeczności. Administrator lub bot zatwierdza go jednorazowo.",
"onboardingFounder1": "Utwórz pokój społeczności.",
"onboardingFounder2": "Zatwierdź bota społeczności i nowych członków.",
"onboardingFounder3": "Odpowiadaj na pytania i zbieraj opinie.",
"onboardingMember1": "Poproś o dołączenie do pokoju społeczności.",
"onboardingMember2": "Administrator lub bot społeczności zatwierdza Twoje urządzenie.",
"onboardingMember3": "Zadawaj pytania, dziel się opinią albo po prostu się przywitaj.",
"encryptedNote": "Tylko członkowie pokoju mogą to czytać; koordynator przekazuje wyłącznie zaszyfrowane wiadomości.",
"shareInviteText": "Dołącz do czatu społeczności SkateSpots",
"qrTitle": "Zeskanuj, aby dołączyć do czatu społeczności",
"qrHint": "Zeskanuj aparatem telefonu, aby otworzyć czat społeczności i poprosić o dołączenie.",
"owner": "Właściciel",
"botName": "Bot społeczności",
"howItWorks": "Nowe urządzenia są jednorazowo zatwierdzane przez administratora lub bota społeczności. Wiadomości wysłane przed dołączeniem nie są dla Ciebie widoczne.",
"feedbackNote": "Pytania i opinie są mile widziane. Bądź życzliwy — to wspólny pokój.",
},
translateUi: { translateUi: {
title: "Tłumaczenie wiadomości", title: "Tłumaczenie wiadomości",
translate: "Przetłumacz", translate: "Przetłumacz",

View file

@ -356,6 +356,34 @@ const drift = {
unreachable: "Inacessível", unreachable: "Inacessível",
dismiss: "Fechar", dismiss: "Fechar",
}, },
communityUi: {
link: "Comunidade",
title: "Chat da comunidade",
subtitle: "Tire dúvidas, compartilhe feedback e converse com a comunidade do SkateSpots — criptografado de ponta a ponta.",
heading: "Comunidade SkateSpots",
eyebrow: "Dúvidas e feedback",
seoTitle: "Chat da comunidade SkateSpots",
seoDescription: "Dúvidas, feedback e conversa da comunidade — criptografados de ponta a ponta.",
notLoggedIn: "Entre para participar do chat da comunidade.",
noGroupAdmin: "A sala da comunidade ainda não foi criada.",
noGroupMember: "Você ainda não está na sala da comunidade.",
create: "Criar a sala da comunidade",
setupDeviceBody: "Isso dá a este dispositivo uma chave para a sala da comunidade. Um admin ou o bot aprova uma vez.",
onboardingFounder1: "Crie a sala da comunidade.",
onboardingFounder2: "Aprove o bot da comunidade e os novos membros.",
onboardingFounder3: "Responda perguntas e colete feedback.",
onboardingMember1: "Peça para entrar na sala da comunidade.",
onboardingMember2: "Um admin ou o bot da comunidade aprova seu dispositivo.",
onboardingMember3: "Tire dúvidas, compartilhe feedback ou apenas diga oi.",
encryptedNote: "Só os membros da sala podem ler isto; o coordenador apenas retransmite mensagens criptografadas.",
shareInviteText: "Participe do chat da comunidade SkateSpots",
qrTitle: "Escaneie para entrar no chat da comunidade",
qrHint: "Escaneie com a câmera do celular para abrir o chat da comunidade e pedir para entrar.",
owner: "Proprietário",
botName: "Bot da comunidade",
howItWorks: "Cada dispositivo novo é aprovado uma vez por um admin ou pelo bot da comunidade. Mensagens enviadas antes de você entrar não ficam visíveis para você.",
feedbackNote: "Dúvidas e feedback são bem-vindos. Seja gentil — é uma sala compartilhada.",
},
translateUi: { translateUi: {
title: "Tradução de mensagens", title: "Tradução de mensagens",
translate: "Traduzir", translate: "Traduzir",

View file

@ -354,6 +354,34 @@ const drift = {
unreachable: "Inaccesibil", unreachable: "Inaccesibil",
dismiss: "Închide", dismiss: "Închide",
}, },
communityUi: {
"link": "Comunitate",
"title": "Chatul comunității",
"subtitle": "Pune întrebări, împărtășește păreri și vorbește cu comunitatea SkateSpots — criptat cap-coadă.",
"heading": "Comunitatea SkateSpots",
"eyebrow": "Întrebări și păreri",
"seoTitle": "Chatul comunității SkateSpots",
"seoDescription": "Întrebări, păreri și discuții în comunitate — criptate cap-coadă.",
"notLoggedIn": "Autentifică-te pentru a intra în chatul comunității.",
"noGroupAdmin": "Camera comunității nu a fost încă creată.",
"noGroupMember": "Nu ești încă în camera comunității.",
"create": "Creează camera comunității",
"setupDeviceBody": "Aceasta oferă acestui dispozitiv o cheie pentru camera comunității. Un administrator sau botul o aprobă o dată.",
"onboardingFounder1": "Creează camera comunității.",
"onboardingFounder2": "Aprobă botul comunității și membrii noi.",
"onboardingFounder3": "Răspunde la întrebări și adună păreri.",
"onboardingMember1": "Cere să intri în camera comunității.",
"onboardingMember2": "Un administrator sau botul comunității îți aprobă dispozitivul.",
"onboardingMember3": "Pune întrebări, împărtășește păreri sau doar salută.",
"encryptedNote": "Doar membrii camerei pot citi asta; coordonatorul retransmite doar mesaje criptate.",
"shareInviteText": "Alătură-te chatului comunității SkateSpots",
"qrTitle": "Scanează pentru a intra în chatul comunității",
"qrHint": "Scanează cu camera telefonului pentru a deschide chatul comunității și a cere să intri.",
"owner": "Proprietar",
"botName": "Botul comunității",
"howItWorks": "Dispozitivele noi sunt aprobate o dată de un administrator sau de botul comunității. Mesajele trimise înainte de a intra nu îți sunt vizibile.",
"feedbackNote": "Întrebările și părerile sunt binevenite. Fii amabil — este o cameră comună.",
},
translateUi: { translateUi: {
title: "Traducerea mesajelor", title: "Traducerea mesajelor",
translate: "Tradu", translate: "Tradu",

View file

@ -358,6 +358,34 @@ const drift = {
unreachable: "Недоступно", unreachable: "Недоступно",
dismiss: "Закрыть", dismiss: "Закрыть",
}, },
communityUi: {
link: "Сообщество",
title: "Чат сообщества",
subtitle: "Задавай вопросы, делись отзывами и общайся с сообществом SkateSpots — со сквозным шифрованием.",
heading: "Сообщество SkateSpots",
eyebrow: "Вопросы и отзывы",
seoTitle: "Чат сообщества SkateSpots",
seoDescription: "Вопросы, отзывы и общение сообщества — со сквозным шифрованием.",
notLoggedIn: "Войди, чтобы присоединиться к чату сообщества.",
noGroupAdmin: "Комната сообщества ещё не создана.",
noGroupMember: "Ты ещё не в комнате сообщества.",
create: "Создать комнату сообщества",
setupDeviceBody: "Это даёт устройству ключ от комнаты сообщества. Администратор или бот подтверждает его один раз.",
onboardingFounder1: "Создай комнату сообщества.",
onboardingFounder2: "Подтверждай бота сообщества и новых участников.",
onboardingFounder3: "Отвечай на вопросы и собирай отзывы.",
onboardingMember1: "Попроси вступить в комнату сообщества.",
onboardingMember2: "Администратор или бот сообщества подтвердит твоё устройство.",
onboardingMember3: "Задавай вопросы, делись отзывами или просто поздоровайся.",
encryptedNote: "Читать это могут только участники комнаты; координатор передаёт лишь зашифрованные сообщения.",
shareInviteText: "Присоединяйся к чату сообщества SkateSpots",
qrTitle: "Сканируй, чтобы войти в чат сообщества",
qrHint: "Отсканируй камерой телефона, чтобы открыть чат сообщества и попросить о вступлении.",
owner: "Владелец",
botName: "Бот сообщества",
howItWorks: "Новые устройства один раз подтверждает администратор или бот сообщества. Сообщения, отправленные до твоего вступления, тебе не видны.",
feedbackNote: "Вопросы и отзывы приветствуются. Будь доброжелателен — это общая комната.",
},
translateUi: { translateUi: {
title: "Перевод сообщений", title: "Перевод сообщений",
translate: "Перевести", translate: "Перевести",

View file

@ -358,6 +358,34 @@ const drift = {
unreachable: "Kan inte nås", unreachable: "Kan inte nås",
dismiss: "Stäng", dismiss: "Stäng",
}, },
communityUi: {
link: "Community",
title: "Communitychatt",
subtitle: "Ställ frågor, dela feedback och prata med SkateSpots-communityn — end-to-end-krypterat.",
heading: "SkateSpots Community",
eyebrow: "Frågor & feedback",
seoTitle: "SkateSpots communitychatt",
seoDescription: "Frågor, feedback och communitysnack — end-to-end-krypterat.",
notLoggedIn: "Logga in för att gå med i communitychatten.",
noGroupAdmin: "Communityrummet har inte skapats än.",
noGroupMember: "Du är inte i communityrummet än.",
create: "Skapa communityrummet",
setupDeviceBody: "Detta ger enheten en nyckel till communityrummet. En admin eller boten godkänner den en gång.",
onboardingFounder1: "Skapa communityrummet.",
onboardingFounder2: "Godkänn communityboten och nya medlemmar.",
onboardingFounder3: "Svara på frågor och samla feedback.",
onboardingMember1: "Be om att gå med i communityrummet.",
onboardingMember2: "En admin eller communityboten godkänner din enhet.",
onboardingMember3: "Ställ frågor, dela feedback eller säg bara hej.",
encryptedNote: "Bara rummets medlemmar kan läsa detta; koordinatorn vidarebefordrar bara krypterade meddelanden.",
shareInviteText: "Gå med i SkateSpots communitychatt",
qrTitle: "Skanna för att gå med i communitychatten",
qrHint: "Skanna med telefonens kamera för att öppna communitychatten och be om att gå med.",
owner: "Ägare",
botName: "Communitybot",
howItWorks: "Nya enheter godkänns en gång av en admin eller communityboten. Meddelanden som skickades innan du gick med syns inte för dig.",
feedbackNote: "Frågor och feedback är välkomna. Var snäll — det är ett delat rum.",
},
translateUi: { translateUi: {
title: "Meddelandeöversättning", title: "Meddelandeöversättning",
translate: "Översätt", translate: "Översätt",

View file

@ -350,6 +350,34 @@ const driftStrings = {
unreachable: "เชื่อมต่อไม่ได้", unreachable: "เชื่อมต่อไม่ได้",
dismiss: "ปิด", dismiss: "ปิด",
}, },
communityUi: {
link: "ชุมชน",
title: "แชทชุมชน",
subtitle: "ถามคำถาม แบ่งปันความคิดเห็น และพูดคุยกับชุมชน SkateSpots — เข้ารหัสแบบ end-to-end",
heading: "ชุมชน SkateSpots",
eyebrow: "คำถามและความคิดเห็น",
seoTitle: "แชทชุมชน SkateSpots",
seoDescription: "คำถาม ความคิดเห็น และการพูดคุยในชุมชน — เข้ารหัสแบบ end-to-end",
notLoggedIn: "เข้าสู่ระบบเพื่อเข้าร่วมแชทชุมชน",
noGroupAdmin: "ยังไม่ได้สร้างห้องชุมชน",
noGroupMember: "คุณยังไม่ได้อยู่ในห้องชุมชน",
create: "สร้างห้องชุมชน",
setupDeviceBody: "สิ่งนี้มอบกุญแจสำหรับห้องชุมชนให้อุปกรณ์นี้ ผู้ดูแลหรือบอทจะอนุมัติหนึ่งครั้ง",
onboardingFounder1: "สร้างห้องชุมชน",
onboardingFounder2: "อนุมัติบอทชุมชนและสมาชิกใหม่",
onboardingFounder3: "ตอบคำถามและรวบรวมความคิดเห็น",
onboardingMember1: "ขอเข้าร่วมห้องชุมชน",
onboardingMember2: "ผู้ดูแลหรือบอทชุมชนจะอนุมัติอุปกรณ์ของคุณ",
onboardingMember3: "ถามคำถาม แบ่งปันความคิดเห็น หรือแค่ทักทายก็ได้",
encryptedNote: "เฉพาะสมาชิกในห้องเท่านั้นที่อ่านได้ ผู้ประสานงานเพียงส่งต่อข้อความที่เข้ารหัสแล้ว",
shareInviteText: "เข้าร่วมแชทชุมชน SkateSpots",
qrTitle: "สแกนเพื่อเข้าร่วมแชทชุมชน",
qrHint: "สแกนด้วยกล้องโทรศัพท์เพื่อเปิดแชทชุมชนและขอเข้าร่วม",
owner: "เจ้าของ",
botName: "บอทชุมชน",
howItWorks: "อุปกรณ์ใหม่จะได้รับการอนุมัติหนึ่งครั้งจากผู้ดูแลหรือบอทชุมชน ข้อความที่ส่งก่อนคุณเข้าร่วมจะไม่ปรากฏให้คุณเห็น",
feedbackNote: "ยินดีรับคำถามและความคิดเห็น โปรดสุภาพ — นี่คือห้องที่ใช้ร่วมกัน",
},
translateUi: { translateUi: {
title: 'การแปลข้อความ', title: 'การแปลข้อความ',
translate: 'แปล', translate: 'แปล',

View file

@ -350,6 +350,34 @@ const driftStrings = {
unreachable: "Ulaşılamıyor", unreachable: "Ulaşılamıyor",
dismiss: "Kapat", dismiss: "Kapat",
}, },
communityUi: {
link: "Topluluk",
title: "Topluluk sohbeti",
subtitle: "Soru sor, geri bildirim paylaş ve SkateSpots topluluğuyla konuş — uçtan uca şifreli.",
heading: "SkateSpots Topluluğu",
eyebrow: "Sorular ve geri bildirim",
seoTitle: "SkateSpots topluluk sohbeti",
seoDescription: "Sorular, geri bildirimler ve topluluk sohbeti — uçtan uca şifreli.",
notLoggedIn: "Topluluk sohbetine katılmak için giriş yap.",
noGroupAdmin: "Topluluk odası henüz oluşturulmadı.",
noGroupMember: "Henüz topluluk odasında değilsin.",
create: "Topluluk odasını oluştur",
setupDeviceBody: "Bu, bu cihaza topluluk odası için bir anahtar verir. Bir yönetici veya bot bunu bir kez onaylar.",
onboardingFounder1: "Topluluk odasını oluştur.",
onboardingFounder2: "Topluluk botunu ve yeni üyeleri onayla.",
onboardingFounder3: "Soruları yanıtla ve geri bildirim topla.",
onboardingMember1: "Topluluk odasına katılmayı iste.",
onboardingMember2: "Bir yönetici veya topluluk botu cihazını onaylar.",
onboardingMember3: "Soru sor, geri bildirim paylaş ya da sadece selam ver.",
encryptedNote: "Bunu yalnızca oda üyeleri okuyabilir; koordinatör yalnızca şifreli mesajları iletir.",
shareInviteText: "SkateSpots topluluk sohbetine katıl",
qrTitle: "Topluluk sohbetine katılmak için tara",
qrHint: "Topluluk sohbetini açmak ve katılma isteği göndermek için telefonunun kamerasıyla tara.",
owner: "Sahip",
botName: "Topluluk botu",
howItWorks: "Yeni cihazlar bir yönetici veya topluluk botu tarafından bir kez onaylanır. Katılmadan önce gönderilen mesajlar senin için görünmez.",
feedbackNote: "Sorular ve geri bildirimler memnuniyetle karşılanır. Kibar ol — burası ortak bir oda.",
},
translateUi: { translateUi: {
title: 'Mesaj çevirisi', title: 'Mesaj çevirisi',
translate: 'Çevir', translate: 'Çevir',

View file

@ -358,6 +358,34 @@ const drift = {
unreachable: "Недоступно", unreachable: "Недоступно",
dismiss: "Закрити", dismiss: "Закрити",
}, },
communityUi: {
link: "Спільнота",
title: "Чат спільноти",
subtitle: "Став запитання, ділися відгуками й спілкуйся зі спільнотою SkateSpots — з наскрізним шифруванням.",
heading: "Спільнота SkateSpots",
eyebrow: "Запитання та відгуки",
seoTitle: "Чат спільноти SkateSpots",
seoDescription: "Запитання, відгуки та спілкування спільноти — з наскрізним шифруванням.",
notLoggedIn: "Увійди, щоб приєднатися до чату спільноти.",
noGroupAdmin: "Кімнату спільноти ще не створено.",
noGroupMember: "Ти ще не в кімнаті спільноти.",
create: "Створити кімнату спільноти",
setupDeviceBody: "Це дає цьому пристрою ключ до кімнати спільноти. Адміністратор або бот підтверджує його один раз.",
onboardingFounder1: "Створи кімнату спільноти.",
onboardingFounder2: "Підтверджуй бота спільноти та нових учасників.",
onboardingFounder3: "Відповідай на запитання та збирай відгуки.",
onboardingMember1: "Попроси приєднатися до кімнати спільноти.",
onboardingMember2: "Адміністратор або бот спільноти підтвердить твій пристрій.",
onboardingMember3: "Став запитання, ділися відгуками або просто привітайся.",
encryptedNote: "Читати це можуть лише учасники кімнати; координатор передає тільки зашифровані повідомлення.",
shareInviteText: "Приєднуйся до чату спільноти SkateSpots",
qrTitle: "Скануй, щоб увійти до чату спільноти",
qrHint: "Відскануй камерою телефона, щоб відкрити чат спільноти й попросити про приєднання.",
owner: "Власник",
botName: "Бот спільноти",
howItWorks: "Нові пристрої один раз підтверджує адміністратор або бот спільноти. Повідомлення, надіслані до твого приєднання, тобі не видно.",
feedbackNote: "Запитання та відгуки вітаються. Будь доброзичливим — це спільна кімната.",
},
translateUi: { translateUi: {
title: "Переклад повідомлень", title: "Переклад повідомлень",
translate: "Перекласти", translate: "Перекласти",

View file

@ -350,6 +350,34 @@ const driftStrings = {
unreachable: "Không thể kết nối", unreachable: "Không thể kết nối",
dismiss: "Đóng", dismiss: "Đóng",
}, },
communityUi: {
link: "Cộng đồng",
title: "Trò chuyện cộng đồng",
subtitle: "Đặt câu hỏi, chia sẻ phản hồi và trò chuyện với cộng đồng SkateSpots — mã hóa đầu cuối.",
heading: "Cộng đồng SkateSpots",
eyebrow: "Câu hỏi và phản hồi",
seoTitle: "Trò chuyện cộng đồng SkateSpots",
seoDescription: "Câu hỏi, phản hồi và trò chuyện cộng đồng — mã hóa đầu cuối.",
notLoggedIn: "Đăng nhập để tham gia trò chuyện cộng đồng.",
noGroupAdmin: "Phòng cộng đồng chưa được tạo.",
noGroupMember: "Bạn chưa ở trong phòng cộng đồng.",
create: "Tạo phòng cộng đồng",
setupDeviceBody: "Việc này cấp cho thiết bị này một khóa cho phòng cộng đồng. Quản trị viên hoặc bot phê duyệt một lần.",
onboardingFounder1: "Tạo phòng cộng đồng.",
onboardingFounder2: "Phê duyệt bot cộng đồng và thành viên mới.",
onboardingFounder3: "Trả lời câu hỏi và thu thập phản hồi.",
onboardingMember1: "Yêu cầu tham gia phòng cộng đồng.",
onboardingMember2: "Quản trị viên hoặc bot cộng đồng phê duyệt thiết bị của bạn.",
onboardingMember3: "Đặt câu hỏi, chia sẻ phản hồi hoặc chỉ cần chào hỏi.",
encryptedNote: "Chỉ thành viên phòng mới đọc được nội dung này; điều phối viên chỉ chuyển tiếp tin nhắn đã mã hóa.",
shareInviteText: "Tham gia trò chuyện cộng đồng SkateSpots",
qrTitle: "Quét để tham gia trò chuyện cộng đồng",
qrHint: "Quét bằng camera điện thoại để mở trò chuyện cộng đồng và yêu cầu tham gia.",
owner: "Chủ sở hữu",
botName: "Bot cộng đồng",
howItWorks: "Thiết bị mới được quản trị viên hoặc bot cộng đồng phê duyệt một lần. Tin nhắn gửi trước khi bạn tham gia sẽ không hiển thị với bạn.",
feedbackNote: "Câu hỏi và phản hồi luôn được hoan nghênh. Hãy tử tế — đây là phòng chung.",
},
translateUi: { translateUi: {
title: 'Dịch tin nhắn', title: 'Dịch tin nhắn',
translate: 'Dịch', translate: 'Dịch',

View file

@ -366,6 +366,34 @@ const drift: Dict = {
unreachable: "无法连接", unreachable: "无法连接",
dismiss: "关闭", dismiss: "关闭",
}, },
communityUi: {
link: "社区",
title: "社区聊天",
subtitle: "提问、分享反馈,与 SkateSpots 社区交流 — 端到端加密。",
heading: "SkateSpots 社区",
eyebrow: "问题与反馈",
seoTitle: "SkateSpots 社区聊天",
seoDescription: "问题、反馈与社区交流 — 端到端加密。",
notLoggedIn: "登录后即可加入社区聊天。",
noGroupAdmin: "社区房间尚未创建。",
noGroupMember: "你还没有加入社区房间。",
create: "创建社区房间",
setupDeviceBody: "这会为此设备提供社区房间的密钥,管理员或机器人会批准一次。",
onboardingFounder1: "创建社区房间。",
onboardingFounder2: "批准社区机器人和新成员。",
onboardingFounder3: "回答问题并收集反馈。",
onboardingMember1: "申请加入社区房间。",
onboardingMember2: "管理员或社区机器人批准你的设备。",
onboardingMember3: "提问、分享反馈,或者只是打个招呼。",
encryptedNote: "只有房间成员可以阅读;协调器只转发加密消息。",
shareInviteText: "加入 SkateSpots 社区聊天",
qrTitle: "扫描加入社区聊天",
qrHint: "用手机相机扫描,打开社区聊天并申请加入。",
owner: "所有者",
botName: "社区机器人",
howItWorks: "新设备会由管理员或社区机器人批准一次。你加入之前发送的消息对你不可见。",
feedbackNote: "欢迎提问和反馈。请保持友善 — 这是共享房间。",
},
translateUi: { translateUi: {
title: '消息翻译', title: '消息翻译',
translate: '翻译', translate: '翻译',

View file

@ -366,6 +366,34 @@ const drift: Dict = {
unreachable: "無法連線", unreachable: "無法連線",
dismiss: "關閉", dismiss: "關閉",
}, },
communityUi: {
link: "社群",
title: "社群聊天",
subtitle: "提問、分享回饋,與 SkateSpots 社群交流 — 端對端加密。",
heading: "SkateSpots 社群",
eyebrow: "問題與回饋",
seoTitle: "SkateSpots 社群聊天",
seoDescription: "問題、回饋與社群交流 — 端對端加密。",
notLoggedIn: "登入後即可加入社群聊天。",
noGroupAdmin: "社群房間尚未建立。",
noGroupMember: "你還沒有加入社群房間。",
create: "建立社群房間",
setupDeviceBody: "這會為此裝置提供社群房間的金鑰,管理員或機器人會核准一次。",
onboardingFounder1: "建立社群房間。",
onboardingFounder2: "核准社群機器人和新成員。",
onboardingFounder3: "回答問題並收集回饋。",
onboardingMember1: "申請加入社群房間。",
onboardingMember2: "管理員或社群機器人會核准你的裝置。",
onboardingMember3: "提問、分享回饋,或只是打個招呼。",
encryptedNote: "只有房間成員可以閱讀;協調器只轉送加密訊息。",
shareInviteText: "加入 SkateSpots 社群聊天",
qrTitle: "掃描以加入社群聊天",
qrHint: "用手機相機掃描,開啟社群聊天並申請加入。",
owner: "擁有者",
botName: "社群機器人",
howItWorks: "新裝置會由管理員或社群機器人核准一次。你加入之前傳送的訊息不會對你顯示。",
feedbackNote: "歡迎提問和回饋。請保持友善 — 這是共享房間。",
},
translateUi: { translateUi: {
title: '訊息翻譯', title: '訊息翻譯',
translate: '翻譯', translate: '翻譯',

View file

@ -0,0 +1,29 @@
import { ADMIN_PUBKEYS } from '@/lib/admins';
import { SKATESPOTS_COORDINATOR } from '@/lib/coordinator';
import type { CordnRoom } from './room';
/**
* The app-wide community / feedback room.
*
* The gid is a stable literal, so the share link never changes when the bot key
* is rotated. The bot creates the room once and becomes the always-on approver;
* admins can still approve manually if the bot is down.
*/
/** Public key of the always-on community bot (private key lives on the VPS). */
export const COMMUNITY_BOT_PUBKEY = 'f9788a7dc43a2b227fd8a0d4ad8752b6f2a9dcd4bce48365acb21675ed1015f0';
export const COMMUNITY_ROOM: CordnRoom = {
// The bot is the room creator/owner; admins can approve as a fallback.
pubkey: COMMUNITY_BOT_PUBKEY,
identifier: 'community',
gid: 'skatespots:community',
name: 'SkateSpots Community',
admins: [...ADMIN_PUBKEYS, COMMUNITY_BOT_PUBKEY],
coordinator: {
url: SKATESPOTS_COORDINATOR.relays[0],
pubkey: SKATESPOTS_COORDINATOR.pubkey,
},
};
/** App/browser share link; the QR points here, not at `localhost`. */
export const COMMUNITY_INVITE_PATH = '/community';

View file

@ -1,4 +1,5 @@
import { SKATE_CREW_KIND, type SkateCrew } from '@/lib/types'; import { SKATE_CREW_KIND } from '@/lib/types';
import type { CordnRoom } from './room';
import type { CordnEndpoint } from './types'; import type { CordnEndpoint } from './types';
/** Default ContextVM relay a coordinator listens on when only an https URL is given. */ /** Default ContextVM relay a coordinator listens on when only an https URL is given. */
@ -17,7 +18,7 @@ const DEFAULT_CVM_RELAY = 'wss://relay.contextvm.org';
* *
* Returns null when the crew has no coordinator configured. * Returns null when the crew has no coordinator configured.
*/ */
export function resolveCordnEndpoint(crew: SkateCrew): CordnEndpoint | null { export function resolveCordnEndpoint(crew: CordnRoom): CordnEndpoint | null {
const coord = crew.coordinator; const coord = crew.coordinator;
if (!coord?.url) return null; if (!coord?.url) return null;

18
src/lib/cordn/room.ts Normal file
View file

@ -0,0 +1,18 @@
import type { CrewCoordinator } from '@/lib/types';
/**
* The minimum a native Cordn chat needs. A `SkateCrew` satisfies this, and so
* does a standalone room (e.g. the app-wide SkateSpots Community room).
*/
export interface CordnRoom {
/** Founder/owner label used in the deterministic gid. */
pubkey: string;
/** Stable room identifier — `community` for the public room. */
identifier: string;
/** Optional explicit gid (standalone rooms use a stable literal). */
gid?: string;
name: string;
/** Pubkeys shown with the admin badge (the founder is shown as Owner). */
admins: string[];
coordinator?: CrewCoordinator;
}

View file

@ -182,7 +182,17 @@ Version: v0.1.0`;
npub1yrnuj56rnen08zp2h9h7p74ghgjx6ma39spmpj6w9hzxywutevsst7k5cx npub1yrnuj56rnen08zp2h9h7p74ghgjx6ma39spmpj6w9hzxywutevsst7k5cx
</code> </code>
</div> </div>
<div className="flex gap-2 flex-shrink-0"> <div className="flex flex-wrap gap-2">
<Link to="/community">
<Button
size="sm"
variant="outline"
className="gap-1.5 border-[#ccff00]/50 text-[#ccff00] hover:bg-[#ccff00]/10"
>
<MessageCircle className="h-3.5 w-3.5" />
{t('communityUi.link')}
</Button>
</Link>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"

View file

@ -0,0 +1,78 @@
import { useMemo } from 'react';
import { useSeoMeta } from '@unhead/react';
import { Link } from 'react-router-dom';
import { ArrowLeft, HeartHandshake, Lock, MessagesSquare } from 'lucide-react';
import { CordnNativePanel } from '@/components/crew/CordnNativePanel';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useTranslation } from '@/contexts/language';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { isAdminPubkey } from '@/lib/admins';
import { COMMUNITY_BOT_PUBKEY, COMMUNITY_ROOM } from '@/lib/cordn/community';
import { isNativePlatform } from '@/lib/platform';
/**
* Public, app-wide Cordn room for questions and feedback. Unlike a crew room it
* has no crew membership gate; the coordinator only carries ciphertext, and an
* admin or the community bot approves each new device.
*/
export default function CommunityPage() {
const { t } = useTranslation();
const { user } = useCurrentUser();
const canManage = isAdminPubkey(user?.pubkey);
const inviteUrl = useMemo(() => {
const origin = isNativePlatform() ? 'https://skate-spots.org' : window.location.origin;
return `${origin}/#/community`;
}, []);
useSeoMeta({
title: t('communityUi.seoTitle'),
description: t('communityUi.seoDescription'),
});
return (
<div className="container mx-auto max-w-2xl px-4 py-6">
<Button asChild variant="ghost" size="sm" className="mb-4 -ml-2 gap-1.5 text-gray-400">
<Link to="/">
<ArrowLeft className="h-4 w-4" /> {t('common.back')}
</Link>
</Button>
<div className="mb-5">
<div className="mb-2 flex items-center gap-2 text-[#ccff00]">
<MessagesSquare className="h-5 w-5" />
<span className="text-[10px] font-black uppercase tracking-widest">
{t('communityUi.eyebrow')}
</span>
</div>
<h1 className="text-4xl font-black uppercase leading-none text-white md:text-5xl">
{t('communityUi.heading')}
</h1>
<p className="mt-2 text-sm text-gray-400">{t('communityUi.subtitle')}</p>
</div>
<Card className="mb-4 border-white/10 bg-black/40">
<CardContent className="flex items-start gap-3 py-3">
<Lock className="mt-0.5 h-4 w-4 shrink-0 text-teal-400" />
<p className="text-xs text-gray-400">{t('communityUi.howItWorks')}</p>
</CardContent>
</Card>
<CordnNativePanel
crew={COMMUNITY_ROOM}
canManage={canManage}
canCreate={false}
isMember
scope="community"
inviteUrl={inviteUrl}
botPubkey={COMMUNITY_BOT_PUBKEY || undefined}
/>
<div className="mt-5 flex items-center justify-center gap-2 text-xs text-gray-500">
<HeartHandshake className="h-4 w-4 text-[#ccff00]/70" />
<span>{t('communityUi.feedbackNote')}</span>
</div>
</div>
);
}