fix: make community joins self-healing and keep /c in the app

This commit is contained in:
SkateSpots 2026-10-29 13:27:12 +07:00
parent 56c718d216
commit 89e6438ee1
32 changed files with 435 additions and 29 deletions

View file

@ -23,8 +23,10 @@
Cache-Control = "public, max-age=31536000, immutable"
# Human-friendly short links.
# /community -> the app's Community page (HashRouter route).
# /c -> the Cordn room reference (short link for sharing).
# /community and /c -> the app's Community page (HashRouter route).
# /cordn is deliberately the raw Cordn room reference for Staircase/Cordn
# clients; normal users should use /c, which stays inside the app's own native
# MLS flow (Cordn web currently stalls after NIP-42).
# Keep these BEFORE the SPA fallback.
[[redirects]]
from = "/community"
@ -34,7 +36,7 @@
[[redirects]]
from = "/c"
to = "https://cordn.net/chat/cordn1qgdhwumn8ghj7un9d3shjtnnddshgefdwdcx7arn9ehhyecpyqpgz6s9k82wwznal8r39mm0wfe8wsnf6eda3ghjvt8qxvxs3ptfxqq5wd4kzar9wdcx7arn8f3k7mtdw4hxjare8hqt74"
to = "/#/community"
status = 302
force = true

View file

@ -21,6 +21,8 @@
* 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_MAX_MEMBERS stop auto-approving at this group size (default 250)
* COMMUNITY_MAX_DEVICES_PER_PUBKEY per-account device cap (default 5)
* COMMUNITY_ONCE=1 one cycle then exit (for a dry run)
*/
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
@ -42,6 +44,14 @@ 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 parsedMaxMembers = Number(process.env.COMMUNITY_MAX_MEMBERS ?? 250);
const MAX_MEMBERS = Number.isFinite(parsedMaxMembers) && parsedMaxMembers >= 2
? Math.floor(parsedMaxMembers)
: 250;
const parsedMaxDevices = Number(process.env.COMMUNITY_MAX_DEVICES_PER_PUBKEY ?? 5);
const MAX_DEVICES_PER_PUBKEY = Number.isFinite(parsedMaxDevices) && parsedMaxDevices >= 1
? Math.floor(parsedMaxDevices)
: 5;
const CREATE = process.env.COMMUNITY_CREATE === '1';
const ONCE = process.env.COMMUNITY_ONCE === '1';
@ -119,10 +129,17 @@ async function joinRoom(): Promise<boolean> {
return false;
}
const approved = new Set<string>();
// Keyed by the requester's key-package ref, not just their pubkey: one account
// can legitimately join from a second device, and a consumed key package must
// not be retried forever. Entries expire so a re-request after a long gap works.
const APPROVAL_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const approved = new Map<string, number>();
const lastAttempt = new Map<string, number>();
let lastJoinRequestAt = 0;
let lastError = '';
let lastPending = 0;
let capLogged = false;
const deviceCapLogged = new Set<string>();
async function cycle(): Promise<void> {
try {
@ -149,23 +166,104 @@ async function cycle(): Promise<void> {
if (!AUTO_APPROVE) return;
const requests = await session.fetchJoinRequests(GID);
const room = session.listGroups().find((group) => group.gid === GID);
const memberCount = room?.members.length ?? 0;
const requests = (await session.fetchJoinRequests(GID))
.filter((request) => request.pk !== session.pubkey); // our own boot request
if (requests.length !== lastPending && requests.length > 0) {
log('pending join requests', requests.length);
}
lastPending = requests.length;
// A public room can be flooded by sybil key packages; MLS also gets more
// expensive for every member. Stop auto-adding at a sane cap and leave the
// queue for a human admin to review (raise the cap via env if needed).
if (memberCount >= MAX_MEMBERS) {
if (!capLogged) {
capLogged = true;
log('member cap reached', memberCount, '/', MAX_MEMBERS, '— leaving requests for admins');
}
return;
}
if (capLogged) {
capLogged = false;
log('member cap cleared; auto-approval resumed');
}
const handled: Array<{ pk: string; at: number }> = [];
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 (!/^[0-9a-f]{64}$/i.test(request.pk)) {
log('skipping malformed join request');
continue;
}
const currentRoom = session.listGroups().find((group) => group.gid === GID);
if ((currentRoom?.members.length ?? 0) >= MAX_MEMBERS) {
if (!capLogged) {
capLogged = true;
log('member cap reached', currentRoom?.members.length ?? 0, '/', MAX_MEMBERS, '— stopping this cycle');
}
break;
}
const devices = currentRoom?.members.filter((member) => member.pubkey === request.pk).length ?? 0;
if (devices >= MAX_DEVICES_PER_PUBKEY) {
if (!deviceCapLogged.has(request.pk)) {
deviceCapLogged.add(request.pk);
log('device cap reached for', short(request.pk), devices, '/', MAX_DEVICES_PER_PUBKEY);
}
continue;
}
const approvalKey = request.kp_ref || request.pk;
const approvedAt = approved.get(approvalKey) ?? 0;
if (Date.now() - approvedAt < APPROVAL_TTL_MS) continue;
const last = lastAttempt.get(approvalKey) ?? 0;
if (Date.now() - last < 5 * 60_000) continue;
try {
await session.addMemberByIdentifier(GID, request.pk);
approved.add(request.pk);
// Prefer the exact key package the requester published. Falling back to
// the pubkey keeps older coordinator responses working.
try {
await session.addMemberByIdentifier(GID, approvalKey);
} catch (error) {
if (request.kp_ref && request.kp_ref !== request.pk) {
await session.addMemberByIdentifier(GID, request.pk);
} else {
throw error;
}
}
approved.set(approvalKey, Date.now());
handled.push({ pk: request.pk, at: request.at });
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));
const message = error instanceof Error ? error.message : String(error);
// A stale request for an account already in the group cannot be added
// again. Retire it instead of retrying forever (and let the user use
// the Link-device flow for a second device).
if (/already in the group/i.test(message)) {
approved.set(approvalKey, Date.now());
handled.push({ pk: request.pk, at: request.at });
log('request already satisfied', short(request.pk));
continue;
}
lastAttempt.set(approvalKey, Date.now());
log('could not approve', short(request.pk), message);
await sleep(1500);
}
}
if (handled.length > 0) {
try {
await session.fetchJoinRequests(GID, handled);
log('retired handled requests', handled.length);
} catch (error) {
log('could not retire handled requests', error instanceof Error ? error.message : String(error));
}
}
// Keep the in-memory approval memory bounded.
const cutoff = Date.now() - APPROVAL_TTL_MS;
for (const [key, at] of approved) {
if (at < cutoff) approved.delete(key);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message !== lastError) {
@ -195,7 +293,12 @@ async function main(): Promise<void> {
privateKeyHex: BOT_KEY,
store: new FileCordnStore(STATE_PATH),
});
log('bot pubkey', session.pubkey, 'room', GID);
log(
'bot pubkey', session.pubkey, 'room', GID,
'auto-approve', AUTO_APPROVE ? 'on' : 'off',
'max', MAX_MEMBERS,
'devices/account', MAX_DEVICES_PER_PUBKEY,
);
do {
await cycle();

View file

@ -112,6 +112,12 @@ export function CordnNativePanel({
const queryClient = useQueryClient();
const [roleBusy, setRoleBusy] = useState<string | null>(null);
const isCommunity = scope === 'community';
const waitingApprovalKey = isCommunity ? 'communityUi.waitingApproval' : 'cordnUi.waitingApproval';
const waitingApprovalManualKey = isCommunity
? 'communityUi.waitingApprovalManual'
: 'cordnUi.waitingApprovalManual';
const noInviteKey = isCommunity ? 'communityUi.noInvite' : 'cordnUi.noInvite';
const sourceCrew = crew as SkateCrew;
const canChangeRoles = !isCommunity && user?.pubkey === crew.pubkey && !!sourceCrew.event;
const copy = {
@ -451,8 +457,8 @@ export function CordnNativePanel({
setAwaiting(false);
return;
}
if (!awaiting) setNotice(t('cordnUi.noInvite'));
}, [checkNow, awaiting, t, native.busy, user?.pubkey, native.groupId]);
if (!awaiting) setNotice(t(noInviteKey));
}, [checkNow, awaiting, t, noInviteKey, native.busy, user?.pubkey, native.groupId]);
// Keep the latest checker in a ref: the poll must not restart just because a
// parent re-render gave the hook callbacks new identities.
@ -621,6 +627,12 @@ export function CordnNativePanel({
</CardHeader>
<CardContent className="space-y-3">
{isCommunity && native.group && botPubkey && !native.group.members.some((member) => member.pubkey === botPubkey) && (
<div role="status" className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
{t('communityUi.botMissing')}
</div>
)}
{native.error && (
<div className="rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-200">
<div className="flex flex-wrap items-center justify-between gap-2">
@ -726,16 +738,33 @@ export function CordnNativePanel({
</>
) : native.interactiveHintVisible ? (
<>
<RefreshCw className="h-3.5 w-3.5" /> {t('cordnUi.waitingApprovalManual')}
<RefreshCw className="h-3.5 w-3.5" /> {t(waitingApprovalManualKey)}
</>
) : (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t('cordnUi.waitingApproval')}
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t(waitingApprovalKey)}
</>
)}
</p>
)}
{awaiting && !deviceLinkPending && (
<button
type="button"
disabled={!native.ready || native.busy}
onClick={() => void handleRequestJoin()}
className="mx-auto block text-[11px] text-teal-300 underline underline-offset-2 hover:text-teal-200 disabled:opacity-50"
>
{t('cordnUi.requestAgain')}
</button>
)}
{isCommunity && (
<p className="mx-auto max-w-sm text-center text-[11px] text-gray-500">
{t('communityUi.secondDeviceHint')}
</p>
)}
{notice && <p className="text-center text-xs text-teal-300">{notice}</p>}
<AdvancedPanel
@ -1070,7 +1099,10 @@ export function CordnNativePanel({
/>
{canManage && (
<div className="space-y-2 rounded-md border border-white/10 bg-black/20 p-2">
<div
id={isCommunity ? 'community-requests' : undefined}
className="scroll-mt-24 space-y-2 rounded-md border border-white/10 bg-black/20 p-2"
>
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-300">
{t('cordnUi.requests')}
@ -1097,7 +1129,7 @@ export function CordnNativePanel({
key={`${req.pk}-${req.kp_ref}`}
pubkey={req.pk}
disabled={native.busy}
onApprove={() => void native.approve(req.pk)}
onApprove={() => void native.approve(req.kp_ref || req.pk, req.pk, { pk: req.pk, at: req.at })}
botPubkey={botPubkey}
botName={copy.botName}
/>

View file

@ -83,8 +83,12 @@ export interface UseCordnNativeResult {
loadRequests: () => Promise<void>;
/** Background check that never toggles `busy` (for a tab/request badge). */
loadRequestsQuiet: () => Promise<void>;
/** Founder/admin: add a requester (by pubkey) and deliver a welcome. */
approve: (identifier: string) => Promise<boolean>;
/** Founder/admin: add a requester by key-package ref, falling back to pubkey. */
approve: (
identifier: string,
fallbackIdentifier?: string,
handled?: { pk: string; at: number },
) => Promise<boolean>;
/** Founder/admin: remove a member leaf (device) from the group. */
removeMember: (leafIndex: number) => Promise<boolean>;
/** New device: publish a key package and return the QR payload. */
@ -484,12 +488,30 @@ export function useCordnNative({
);
const approve = useCallback(
async (identifier: string) => {
async (identifier: string, fallbackIdentifier?: string, handled?: { pk: string; at: number }) => {
const session = sessionRef.current;
if (!session || !group) return false;
const approved = await run(async () => {
await session.addMemberByIdentifier(group.gid, identifier);
const pending = await session.fetchJoinRequests(group.gid);
// Prefer the requester's exact key package; older coordinators may only
// resolve the pubkey, so keep that as a fallback.
try {
await session.addMemberByIdentifier(group.gid, identifier);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
// A stale request for an account already in the group: retire it
// instead of leaving it in the queue forever.
if (/already in the group/i.test(message)) {
if (handled) await session.fetchJoinRequests(group.gid, [handled]);
setRequests((current) => current.filter((request) => request.pk !== handled?.pk));
return true;
}
if (!fallbackIdentifier || fallbackIdentifier === identifier) throw error;
await session.addMemberByIdentifier(group.gid, fallbackIdentifier);
}
const pending = await session.fetchJoinRequests(
group.gid,
handled ? [handled] : [],
);
setRequests(pending);
return true;
});

View file

@ -918,6 +918,7 @@ const batch1 = {
"noGroupMember": "Encara no ets al xat privat d'aquesta colla.",
"create": "Crea el xat privat",
"requestJoin": "Demana d'unir-t'hi",
"requestAgain": "Request again",
"checkInvite": "Comprova si hi ha invitació",
"requested": "Sol·licitud enviada: un administrador de la colla l'aprovarà.",
"noInvite": "Encara no hi ha cap invitació. Demana a un administrador que aprovi la teva sol·licitud.",
@ -1012,6 +1013,13 @@ const batch1 = {
"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.",
"waitingApproval": "Esperant que el bot de la comunitat o un administrador ho aprovi…",
"waitingApprovalManual": "Mode manual. Toca «Comprova si hi ha invitació» quan estigui aprovat.",
"noInvite": "Encara no hi ha invitació. El bot normalment aprova en un minut; després toca «Comprova si hi ha invitació».",
"pendingRequests": "{{count}} sol·licituds d'entrada en espera",
"pendingRequestsDesc": "El bot normalment aprova automàticament. Si s'ha encallat, obre la sala de sota i toca Comprova les sol·licituds.",
"botMissing": "The community bot isn’t in this room right now, so an admin will review join requests.",
"secondDeviceHint": "Already a member on another device? Use “Link device” instead — the bot can’t add a second key to the same account.",
"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.",

View file

@ -307,6 +307,7 @@ const drift = {
"noGroupMember": "V soukromém chatu této crew zatím nejsi.",
"create": "Vytvořit soukromý chat",
"requestJoin": "Požádat o připojení",
"requestAgain": "Request again",
"checkInvite": "Zkontrolovat pozvánku",
"requested": "Žádost odeslána — administrátor crew ji schválí.",
"noInvite": "Zatím nečeká žádná pozvánka. Požádej administrátora o schválení žádosti.",
@ -398,6 +399,13 @@ const drift = {
"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.",
"waitingApproval": "Čeká se na schválení komunitním botem nebo administrátorem…",
"waitingApprovalManual": "Ruční režim. Až bude schváleno, klepni na „Zkontrolovat pozvánku“.",
"noInvite": "Zatím žádná pozvánka. Bot obvykle schválí do minuty — potom klepni na „Zkontrolovat pozvánku“.",
"pendingRequests": "{{count}} čekajících žádostí o připojení",
"pendingRequestsDesc": "Bot obvykle schvaluje automaticky. Pokud se zasekl, otevři níže místnost a klepni na Zkontrolovat žádosti.",
"botMissing": "The community bot isn’t in this room right now, so an admin will review join requests.",
"secondDeviceHint": "Already a member on another device? Use “Link device” instead — the bot can’t add a second key to the same account.",
"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.",

View file

@ -309,6 +309,7 @@ const drift = {
noGroupMember: "Du bist noch nicht im privaten Chat dieser Crew.",
create: "Privaten Chat erstellen",
requestJoin: "Um Beitritt bitten",
requestAgain: "Request again",
checkInvite: "Nach Einladung suchen",
requested: "Anfrage gesendet – ein Crew-Admin wird sie genehmigen.",
noInvite: "Noch keine Einladung. Bitte einen Admin, deine Anfrage zu genehmigen.",
@ -400,6 +401,13 @@ const drift = {
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.",
waitingApproval: "Warte auf die Freigabe durch den Community-Bot oder einen Admin…",
waitingApprovalManual: "Manueller Modus. Tippe auf „Nach Einladung suchen“, sobald es freigegeben ist.",
noInvite: "Noch keine Einladung. Der Bot genehmigt normalerweise innerhalb einer Minute – tippe danach auf „Nach Einladung suchen“.",
pendingRequests: "{{count}} offene Beitrittsanfragen",
pendingRequestsDesc: "Der Bot genehmigt normalerweise automatisch. Wenn es hängt, öffne unten den Raum und tippe auf Anfragen prüfen.",
botMissing: "The community bot isn’t in this room right now, so an admin will review join requests.",
secondDeviceHint: "Already a member on another device? Use “Link device” instead — the bot can’t add a second key to the same account.",
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.",

View file

@ -311,6 +311,7 @@ const drift = {
noGroupMember: "Δεν είσαι ακόμη στην ιδιωτική συνομιλία αυτού του crew.",
create: "Δημιουργία ιδιωτικής συνομιλίας",
requestJoin: "Ζήτα συμμετοχή",
requestAgain: "Request again",
checkInvite: "Έλεγχος πρόσκλησης",
requested: "Το αίτημα στάλθηκε — ένας διαχειριστής του crew θα το εγκρίνει.",
noInvite: "Δεν περιμένει ακόμη πρόσκληση. Ζήτα από έναν διαχειριστή να εγκρίνει το αίτημά σου.",
@ -402,6 +403,13 @@ const drift = {
notLoggedIn: "Συνδέσου για να μπεις στη συνομιλία της κοινότητας.",
noGroupAdmin: "Το δωμάτιο της κοινότητας δεν έχει δημιουργηθεί ακόμη.",
noGroupMember: "Δεν είσαι ακόμη στο δωμάτιο της κοινότητας.",
waitingApproval: "Αναμονή έγκρισης από το bot της κοινότητας ή έναν διαχειριστή…",
waitingApprovalManual: "Χειροκίνητη λειτουργία. Πάτησε «Έλεγχος πρόσκλησης» μόλις εγκριθεί.",
noInvite: "Δεν υπάρχει ακόμη πρόσκληση. Το bot εγκρίνει συνήθως μέσα σε ένα λεπτό — μετά πάτησε «Έλεγχος πρόσκλησης».",
pendingRequests: "{{count}} αιτήματα συμμετοχής σε αναμονή",
pendingRequestsDesc: "Το bot εγκρίνει συνήθως αυτόματα. Αν έχει κολλήσει, άνοιξε το δωμάτιο παρακάτω και πάτησε Έλεγχος αιτημάτων.",
botMissing: "The community bot isn’t in this room right now, so an admin will review join requests.",
secondDeviceHint: "Already a member on another device? Use “Link device” instead — the bot can’t add a second key to the same account.",
create: "Δημιουργία δωματίου κοινότητας",
setupDeviceBody: "Αυτό δίνει σε αυτή τη συσκευή ένα κλειδί για το δωμάτιο της κοινότητας. Ένας διαχειριστής ή το bot το εγκρίνει μία φορά.",
onboardingFounder1: "Δημιούργησε το δωμάτιο της κοινότητας.",

View file

@ -918,6 +918,7 @@ const batch1 = {
noGroupMember: "Oraindik ez zaude talde honen txat pribatuan.",
create: "Sortu txat pribatua",
requestJoin: "Eskatu sartzea",
requestAgain: "Request again",
checkInvite: "Egiaztatu gonbidapena",
requested: "Eskaera bidali da — taldeko administratzaile batek onartuko du.",
noInvite: "Oraindik ez dago gonbidapenik zain. Eskatu administratzaile bati zure eskaera onartzeko.",
@ -1012,6 +1013,13 @@ const batch1 = {
notLoggedIn: "Hasi saioa komunitate txatean sartzeko.",
noGroupAdmin: "Komunitate gela oraindik ez da sortu.",
noGroupMember: "Oraindik ez zaude komunitate gelan.",
waitingApproval: "Komunitate botaren edo administratzaile baten onarpenaren zain…",
waitingApprovalManual: "Modu manuala. Onartutakoan, sakatu «Egiaztatu gonbidapena».",
noInvite: "Oraindik ez dago gonbidapenik. Botak normalean minutu batean onartzen du — gero sakatu «Egiaztatu gonbidapena».",
pendingRequests: "{{count}} sartzeko eskaera zain",
pendingRequestsDesc: "Botak normalean automatikoki onartzen du. Blokeatuta badago, ireki beheko gela eta sakatu Egiaztatu eskaerak.",
botMissing: "The community bot isn’t in this room right now, so an admin will review join requests.",
secondDeviceHint: "Already a member on another device? Use “Link device” instead — the bot can’t add a second key to the same account.",
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.",

View file

@ -309,6 +309,7 @@ const drift = {
noGroupMember: "Vous n'êtes pas encore dans le chat privé de cette Crew.",
create: "Créer le chat privé",
requestJoin: "Demander à rejoindre",
requestAgain: "Request again",
checkInvite: "Vérifier l'invitation",
requested: "Demande envoyée — un admin de la Crew l'approuvera.",
noInvite: "Aucune invitation en attente. Demandez à un admin d'approuver votre demande.",
@ -400,6 +401,13 @@ const drift = {
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é.",
waitingApproval: "En attente de l’approbation du bot de la communauté ou d’un admin…",
waitingApprovalManual: "Mode manuel. Appuie sur « Vérifier l’invitation » une fois approuvé.",
noInvite: "Pas encore d’invitation. Le bot approuve généralement en moins d’une minute — appuie ensuite sur « Vérifier l’invitation ».",
pendingRequests: "{{count}} demande(s) d’adhésion en attente",
pendingRequestsDesc: "Le bot approuve généralement automatiquement. S’il est bloqué, ouvre le salon ci-dessous et appuie sur Vérifier les demandes.",
botMissing: "The community bot isn’t in this room right now, so an admin will review join requests.",
secondDeviceHint: "Already a member on another device? Use “Link device” instead — the bot can’t add a second key to the same account.",
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é.",

View file

@ -918,6 +918,7 @@ const batch1 = {
noGroupMember: "Aínda non estás no chat privado desta crew.",
create: "Crear o chat privado",
requestJoin: "Pedir acceso",
requestAgain: "Request again",
checkInvite: "Comprobar se hai invitación",
requested: "Solicitude enviada: un administrador da crew aprobaraa.",
noInvite: "Aínda non hai ningunha invitación. Pídele a un administrador que aprobe a túa solicitude.",
@ -1012,6 +1013,13 @@ const batch1 = {
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.",
waitingApproval: "Agardando a aprobación do bot da comunidade ou dun administrador…",
waitingApprovalManual: "Modo manual. Toca «Comprobar se hai invitación» cando se aprobe.",
noInvite: "Aínda non hai invitación. O bot adoita aprobar nun minuto; despois toca «Comprobar se hai invitación».",
pendingRequests: "{{count}} solicitudes de entrada en espera",
pendingRequestsDesc: "O bot adoita aprobar automaticamente. Se se atascou, abre a sala de abaixo e toca Comprobar solicitudes.",
botMissing: "The community bot isn’t in this room right now, so an admin will review join requests.",
secondDeviceHint: "Already a member on another device? Use “Link device” instead — the bot can’t add a second key to the same account.",
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.",

View file

@ -918,6 +918,7 @@ const batch1 = {
"noGroupMember": "आप अभी इस क्रू की निजी चैट में नहीं हैं।",
"create": "निजी चैट बनाएँ",
"requestJoin": "शामिल होने का अनुरोध करें",
"requestAgain": "Request again",
"checkInvite": "निमंत्रण जाँचें",
"requested": "अनुरोध भेजा गया — क्रू एडमिन इसे स्वीकृत करेगा।",
"noInvite": "अभी कोई निमंत्रण प्रतीक्षा में नहीं। एडमिन से अपना अनुरोध स्वीकृत करने को कहें।",
@ -1012,6 +1013,13 @@ const batch1 = {
"notLoggedIn": "कम्युनिटी चैट में शामिल होने के लिए लॉग इन करें।",
"noGroupAdmin": "कम्युनिटी रूम अभी नहीं बनाया गया है।",
"noGroupMember": "आप अभी कम्युनिटी रूम में नहीं हैं।",
"waitingApproval": "कम्युनिटी बॉट या एडमिन की मंज़ूरी का इंतज़ार है…",
"waitingApprovalManual": "मैन्युअल मोड। मंज़ूरी मिलने पर “निमंत्रण जाँचें” टैप करें।",
"noInvite": "अभी कोई निमंत्रण नहीं। बॉट आमतौर पर एक मिनट में मंज़ूरी दे देता है — उसके बाद “निमंत्रण जाँचें” टैप करें।",
"pendingRequests": "{{count}} शामिल होने के अनुरोध इंतज़ार में",
"pendingRequestsDesc": "बॉट आमतौर पर अपने आप मंज़ूरी देता है। अगर अटका हुआ है, तो नीचे रूम खोलें और “अनुरोध जाँचें” टैप करें।",
"botMissing": "The community bot isn’t in this room right now, so an admin will review join requests.",
"secondDeviceHint": "Already a member on another device? Use “Link device” instead — the bot can’t add a second key to the same account.",
"create": "कम्युनिटी रूम बनाएँ",
"setupDeviceBody": "इससे इस डिवाइस को कम्युनिटी रूम की एक कुंजी मिलती है। कोई एडमिन या बॉट इसे एक बार मंज़ूर करता है।",
"onboardingFounder1": "कम्युनिटी रूम बनाएँ।",

View file

@ -303,6 +303,7 @@ const driftStrings = {
noGroupMember: 'Kamu belum ada di chat privat crew ini.',
create: 'Buat chat privat',
requestJoin: 'Minta gabung',
requestAgain: "Request again",
checkInvite: 'Periksa undangan',
requested: 'Permintaan terkirim — admin crew akan menyetujuinya.',
noInvite: 'Belum ada undangan menunggu. Minta admin menyetujui permintaanmu.',
@ -394,6 +395,13 @@ const driftStrings = {
notLoggedIn: "Masuk untuk bergabung ke chat komunitas.",
noGroupAdmin: "Ruang komunitas belum dibuat.",
noGroupMember: "Kamu belum berada di ruang komunitas.",
waitingApproval: "Menunggu persetujuan dari bot komunitas atau admin…",
waitingApprovalManual: "Mode manual. Ketuk “Periksa undangan” setelah disetujui.",
noInvite: "Belum ada undangan. Bot biasanya menyetujui dalam satu menit — ketuk “Periksa undangan” setelahnya.",
pendingRequests: "{{count}} permintaan bergabung menunggu",
pendingRequestsDesc: "Bot biasanya menyetujui secara otomatis. Jika macet, buka ruang di bawah dan ketuk Periksa permintaan.",
botMissing: "The community bot isn’t in this room right now, so an admin will review join requests.",
secondDeviceHint: "Already a member on another device? Use “Link device” instead — the bot can’t add a second key to the same account.",
create: "Buat ruang komunitas",
setupDeviceBody: "Ini memberi perangkat ini kunci untuk ruang komunitas. Admin atau bot menyetujuinya satu kali.",
onboardingFounder1: "Buat ruang komunitas.",

View file

@ -309,6 +309,7 @@ const drift = {
noGroupMember: "Non sei ancora nella chat privata di questa crew.",
create: "Crea la chat privata",
requestJoin: "Chiedi di unirti",
requestAgain: "Request again",
checkInvite: "Controlla l'invito",
requested: "Richiesta inviata — un admin della crew la approverà.",
noInvite: "Nessun invito in attesa. Chiedi a un admin di approvare la tua richiesta.",
@ -400,6 +401,13 @@ const drift = {
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.",
waitingApproval: "In attesa dell’approvazione del bot della community o di un admin…",
waitingApprovalManual: "Modalità manuale. Tocca «Controlla l’invito» quando è approvato.",
noInvite: "Nessun invito ancora. Il bot di solito approva entro un minuto — tocca «Controlla l’invito» dopo.",
pendingRequests: "{{count}} richieste di adesione in attesa",
pendingRequestsDesc: "Il bot di solito approva automaticamente. Se è bloccato, apri la stanza qui sotto e tocca Controlla le richieste.",
botMissing: "The community bot isn’t in this room right now, so an admin will review join requests.",
secondDeviceHint: "Already a member on another device? Use “Link device” instead — the bot can’t add a second key to the same account.",
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.",

View file

@ -319,6 +319,7 @@ const drift: Dict = {
noGroupMember: 'あなたはまだこのクルーのプライベートチャットに参加していません。',
create: 'プライベートチャットを作成',
requestJoin: '参加をリクエスト',
requestAgain: "Request again",
checkInvite: '招待を確認',
requested: 'リクエストを送信しました — クルー管理者が承認します。',
noInvite: 'まだ招待はありません。管理者にリクエストの承認を依頼してください。',
@ -410,6 +411,13 @@ const drift: Dict = {
notLoggedIn: "コミュニティチャットに参加するにはログインしてください。",
noGroupAdmin: "コミュニティルームはまだ作成されていません。",
noGroupMember: "あなたはまだコミュニティルームに参加していません。",
waitingApproval: "コミュニティボットまたは管理者の承認を待っています…",
waitingApprovalManual: "手動モードです。承認されたら「招待を確認」をタップしてください。",
noInvite: "まだ招待がありません。ボットは通常1分以内に承認します。その後で「招待を確認」をタップしてください。",
pendingRequests: "参加リクエスト {{count}} 件が待機中",
pendingRequestsDesc: "ボットは通常自動で承認します。止まっている場合は、下のルームを開いて「リクエストを確認」をタップしてください。",
botMissing: "The community bot isn’t in this room right now, so an admin will review join requests.",
secondDeviceHint: "Already a member on another device? Use “Link device” instead — the bot can’t add a second key to the same account.",
create: "コミュニティルームを作成",
setupDeviceBody: "このデバイスにコミュニティルームの鍵を付与します。管理者またはボットが一度だけ承認します。",
onboardingFounder1: "コミュニティルームを作成します。",

View file

@ -319,6 +319,7 @@ const drift: Dict = {
noGroupMember: '아직 이 크루의 비공개 채팅에 참여하지 않았습니다.',
create: '비공개 채팅 만들기',
requestJoin: '참여 요청',
requestAgain: "Request again",
checkInvite: '초대 확인',
requested: '요청을 보냈습니다 — 크루 관리자가 승인합니다.',
noInvite: '아직 대기 중인 초대가 없습니다. 관리자에게 요청 승인을 부탁하세요.',
@ -410,6 +411,13 @@ const drift: Dict = {
notLoggedIn: "커뮤니티 채팅에 참여하려면 로그인하세요.",
noGroupAdmin: "커뮤니티 방이 아직 만들어지지 않았습니다.",
noGroupMember: "아직 커뮤니티 방에 참여하지 않았습니다.",
waitingApproval: "커뮤니티 봇 또는 관리자의 승인을 기다리는 중…",
waitingApprovalManual: "수동 모드입니다. 승인되면 “초대 확인”을 탭하세요.",
noInvite: "아직 초대가 없습니다. 봇은 보통 1분 이내에 승인합니다. 그 후 “초대 확인”을 탭하세요.",
pendingRequests: "참여 요청 {{count}}개 대기 중",
pendingRequestsDesc: "봇은 보통 자동으로 승인합니다. 멈춰 있으면 아래 방을 열고 요청 확인을 탭하세요.",
botMissing: "The community bot isn’t in this room right now, so an admin will review join requests.",
secondDeviceHint: "Already a member on another device? Use “Link device” instead — the bot can’t add a second key to the same account.",
create: "커뮤니티 방 만들기",
setupDeviceBody: "이 기기에 커뮤니티 방 열쇠를 부여합니다. 관리자나 봇이 한 번 승인합니다.",
onboardingFounder1: "커뮤니티 방을 만드세요.",

View file

@ -15,6 +15,13 @@ const communityUi = {
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.',
waitingApproval: 'Waiting for the community bot or an admin to approve…',
waitingApprovalManual: 'Manual mode. Tap “Check for invite” once it’s approved.',
noInvite: 'No invite yet. The bot usually approves within a minute — tap “Check for invite” after that.',
pendingRequests: '{{count}} join request(s) waiting',
pendingRequestsDesc: 'The bot usually approves automatically. If it’s stuck, open the room below and tap Check requests.',
botMissing: 'The community bot isn’t in this room right now, so an admin will review join requests.',
secondDeviceHint: 'Already a member on another device? Use “Link device” instead — the bot can’t add a second key to the same account.',
create: 'Create the community room',
setupDeviceBody:
'This gives this device a key for the community room. An admin or the bot approves it once.',
@ -48,6 +55,13 @@ const communityUi = {
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.',
waitingApproval: 'Esperando a que el bot de la comunidad o un administrador apruebe…',
waitingApprovalManual: 'Modo manual. Pulsa «Buscar invitación» cuando se apruebe.',
noInvite: 'Aún no hay invitación. El bot suele aprobar en un minuto; pulsa «Buscar invitación» después.',
pendingRequests: '{{count}} solicitud(es) de entrada esperando',
pendingRequestsDesc: 'El bot suele aprobar automáticamente. Si se ha quedado atascado, abre la sala y pulsa Buscar solicitudes.',
botMissing: 'El bot de la comunidad no está en esta sala ahora mismo; un administrador revisará las solicitudes.',
secondDeviceHint: '¿Ya eres miembro en otro dispositivo? Usa «Vincular dispositivo»; el bot no puede añadir una segunda clave a la misma cuenta.',
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.',

View file

@ -16,6 +16,7 @@ const cordnUi = {
noGroupMember: 'You’re not in this crew’s private chat yet.',
create: 'Create the private chat',
requestJoin: 'Ask to join',
requestAgain: 'Request again',
checkInvite: 'Check for invite',
requested: 'Request sent — a crew admin will approve it.',
noInvite: 'No invite waiting yet. Ask an admin to approve your request.',
@ -118,6 +119,7 @@ const cordnUi = {
noGroupMember: 'Aún no estás en el chat privado de esta peña.',
create: 'Crear el chat privado',
requestJoin: 'Pedir entrar',
requestAgain: 'Volver a pedir',
checkInvite: 'Buscar invitación',
requested: 'Solicitud enviada: un administrador la aprobará.',
noInvite: 'Aún no hay invitación. Pide a un administrador que apruebe tu solicitud.',

View file

@ -307,6 +307,7 @@ const drift = {
"noGroupMember": "Je zit nog niet in de privéchat van deze crew.",
"create": "De privéchat aanmaken",
"requestJoin": "Vragen om deel te nemen",
"requestAgain": "Request again",
"checkInvite": "Op uitnodiging controleren",
"requested": "Verzoek verzonden — een crew-admin keurt het goed.",
"noInvite": "Nog geen uitnodiging. Vraag een admin je verzoek goed te keuren.",
@ -398,6 +399,13 @@ const drift = {
"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.",
"waitingApproval": "Wachten op goedkeuring door de communitybot of een beheerder…",
"waitingApprovalManual": "Handmatige modus. Tik op ‘Op uitnodiging controleren’ zodra het is goedgekeurd.",
"noInvite": "Nog geen uitnodiging. De bot keurt meestal binnen een minuut goed — tik daarna op ‘Op uitnodiging controleren’.",
"pendingRequests": "{{count}} openstaande verzoeken om deel te nemen",
"pendingRequestsDesc": "De bot keurt meestal automatisch goed. Als het vastloopt, open dan hieronder de ruimte en tik op Verzoeken controleren.",
"botMissing": "The community bot isn’t in this room right now, so an admin will review join requests.",
"secondDeviceHint": "Already a member on another device? Use “Link device” instead — the bot can’t add a second key to the same account.",
"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.",

View file

@ -307,6 +307,7 @@ const drift = {
"noGroupMember": "Nie należysz jeszcze do prywatnego czatu tego crew.",
"create": "Utwórz prywatny czat",
"requestJoin": "Poproś o dołączenie",
"requestAgain": "Request again",
"checkInvite": "Sprawdź zaproszenie",
"requested": "Prośba wysłana — administrator crew ją zatwierdzi.",
"noInvite": "Brak oczekującego zaproszenia. Poproś administratora o zatwierdzenie prośby.",
@ -398,6 +399,13 @@ const drift = {
"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.",
"waitingApproval": "Czekamy na zatwierdzenie przez bota społeczności lub administratora…",
"waitingApprovalManual": "Tryb ręczny. Gdy zostanie zatwierdzone, dotknij „Sprawdź zaproszenie”.",
"noInvite": "Brak zaproszenia. Bot zwykle zatwierdza w ciągu minuty — potem dotknij „Sprawdź zaproszenie”.",
"pendingRequests": "{{count}} oczekujących próśb o dołączenie",
"pendingRequestsDesc": "Bot zwykle zatwierdza automatycznie. Jeśli utknął, otwórz pokój poniżej i dotknij Sprawdź prośby.",
"botMissing": "The community bot isn’t in this room right now, so an admin will review join requests.",
"secondDeviceHint": "Already a member on another device? Use “Link device” instead — the bot can’t add a second key to the same account.",
"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.",

View file

@ -309,6 +309,7 @@ const drift = {
noGroupMember: "Você ainda não está no chat privado desta crew.",
create: "Criar o chat privado",
requestJoin: "Pedir para entrar",
requestAgain: "Request again",
checkInvite: "Verificar convite",
requested: "Solicitação enviada — um admin da crew vai aprovar.",
noInvite: "Nenhum convite aguardando. Peça a um admin para aprovar sua solicitação.",
@ -400,6 +401,13 @@ const drift = {
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.",
waitingApproval: "Aguardando aprovação do bot da comunidade ou de um admin…",
waitingApprovalManual: "Modo manual. Toque em “Verificar convite” quando for aprovado.",
noInvite: "Nenhum convite ainda. O bot costuma aprovar em até um minuto — depois toque em “Verificar convite”.",
pendingRequests: "{{count}} pedido(s) de entrada aguardando",
pendingRequestsDesc: "O bot costuma aprovar automaticamente. Se travou, abra a sala abaixo e toque em Verificar pedidos.",
botMissing: "The community bot isn’t in this room right now, so an admin will review join requests.",
secondDeviceHint: "Already a member on another device? Use “Link device” instead — the bot can’t add a second key to the same account.",
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.",

View file

@ -307,6 +307,7 @@ const drift = {
"noGroupMember": "Nu ești încă în chatul privat al acestui crew.",
"create": "Creează chatul privat",
"requestJoin": "Cere să te alături",
"requestAgain": "Request again",
"checkInvite": "Verifică invitația",
"requested": "Cerere trimisă — un admin de crew o va aproba.",
"noInvite": "Nicio invitație în așteptare încă. Cere unui admin să aprobe cererea ta.",
@ -398,6 +399,13 @@ const drift = {
"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.",
"waitingApproval": "Se așteaptă aprobarea de la botul comunității sau de la un administrator…",
"waitingApprovalManual": "Mod manual. Apasă „Verifică invitația” după ce este aprobat.",
"noInvite": "Încă nicio invitație. Botul aprobă de obicei într-un minut — apoi apasă „Verifică invitația”.",
"pendingRequests": "{{count}} cereri de alăturare în așteptare",
"pendingRequestsDesc": "Botul aprobă de obicei automat. Dacă s-a blocat, deschide camera de mai jos și apasă Verifică cererile.",
"botMissing": "The community bot isn’t in this room right now, so an admin will review join requests.",
"secondDeviceHint": "Already a member on another device? Use “Link device” instead — the bot can’t add a second key to the same account.",
"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.",

View file

@ -311,6 +311,7 @@ const drift = {
noGroupMember: "Вы ещё не в приватном чате этого Crew.",
create: "Создать приватный чат",
requestJoin: "Попросить присоединиться",
requestAgain: "Request again",
checkInvite: "Проверить приглашение",
requested: "Запрос отправлен — администратор Crew одобрит его.",
noInvite: "Приглашение пока не ждёт. Попросите администратора одобрить ваш запрос.",
@ -402,6 +403,13 @@ const drift = {
notLoggedIn: "Войди, чтобы присоединиться к чату сообщества.",
noGroupAdmin: "Комната сообщества ещё не создана.",
noGroupMember: "Ты ещё не в комнате сообщества.",
waitingApproval: "Ждём одобрения от бота сообщества или администратора…",
waitingApprovalManual: "Ручной режим. Когда одобрят, нажми «Проверить приглашение».",
noInvite: "Приглашения пока нет. Бот обычно одобряет в течение минуты — после этого нажми «Проверить приглашение».",
pendingRequests: "{{count}} запросов на вступление в ожидании",
pendingRequestsDesc: "Бот обычно одобряет автоматически. Если застряло, открой комнату ниже и нажми «Проверить запросы».",
botMissing: "The community bot isn’t in this room right now, so an admin will review join requests.",
secondDeviceHint: "Already a member on another device? Use “Link device” instead — the bot can’t add a second key to the same account.",
create: "Создать комнату сообщества",
setupDeviceBody: "Это даёт устройству ключ от комнаты сообщества. Администратор или бот подтверждает его один раз.",
onboardingFounder1: "Создай комнату сообщества.",

View file

@ -311,6 +311,7 @@ const drift = {
noGroupMember: "Du är inte med i det här crewets privata chatt än.",
create: "Skapa den privata chatten",
requestJoin: "Be om att gå med",
requestAgain: "Request again",
checkInvite: "Kontrollera inbjudan",
requested: "Begäran skickad — en crewadmin godkänner den.",
noInvite: "Ingen inbjudan väntar än. Be en admin godkänna din begäran.",
@ -402,6 +403,13 @@ const drift = {
notLoggedIn: "Logga in för att gå med i communitychatten.",
noGroupAdmin: "Communityrummet har inte skapats än.",
noGroupMember: "Du är inte i communityrummet än.",
waitingApproval: "Väntar på godkännande från communityboten eller en admin…",
waitingApprovalManual: "Manuellt läge. Tryck på ”Kontrollera inbjudan” när det är godkänt.",
noInvite: "Ingen inbjudan än. Boten godkänner oftast inom en minut — tryck på ”Kontrollera inbjudan” efter det.",
pendingRequests: "{{count}} ansökningar om att gå med väntar",
pendingRequestsDesc: "Boten godkänner oftast automatiskt. Om det har fastnat, öppna rummet nedan och tryck på Kontrollera begäranden.",
botMissing: "The community bot isn’t in this room right now, so an admin will review join requests.",
secondDeviceHint: "Already a member on another device? Use “Link device” instead — the bot can’t add a second key to the same account.",
create: "Skapa communityrummet",
setupDeviceBody: "Detta ger enheten en nyckel till communityrummet. En admin eller boten godkänner den en gång.",
onboardingFounder1: "Skapa communityrummet.",

View file

@ -303,6 +303,7 @@ const driftStrings = {
noGroupMember: 'คุณยังไม่ได้อยู่ในแชทส่วนตัวของ crew นี้',
create: 'สร้างแชทส่วนตัว',
requestJoin: 'ขอเข้าร่วม',
requestAgain: "Request again",
checkInvite: 'ตรวจสอบคำเชิญ',
requested: 'ส่งคำขอแล้ว — แอดมิน crew จะอนุมัติ',
noInvite: 'ยังไม่มีคำเชิญที่รออยู่ ขอให้แอดมินอนุมัติคำขอของคุณ',
@ -394,6 +395,13 @@ const driftStrings = {
notLoggedIn: "เข้าสู่ระบบเพื่อเข้าร่วมแชทชุมชน",
noGroupAdmin: "ยังไม่ได้สร้างห้องชุมชน",
noGroupMember: "คุณยังไม่ได้อยู่ในห้องชุมชน",
waitingApproval: "กำลังรอการอนุมัติจากบอทชุมชนหรือผู้ดูแล…",
waitingApprovalManual: "โหมดกำหนดเอง แตะ “ตรวจสอบคำเชิญ” เมื่อได้รับการอนุมัติแล้ว",
noInvite: "ยังไม่มีคำเชิญ บอทมักอนุมัติภายในหนึ่งนาที — หลังจากนั้นแตะ “ตรวจสอบคำเชิญ”",
pendingRequests: "มีคำขอเข้าร่วม {{count}} รายการรออยู่",
pendingRequestsDesc: "บอทมักอนุมัติโดยอัตโนมัติ หากค้างอยู่ ให้เปิดห้องด้านล่างแล้วแตะ ตรวจสอบคำขอ",
botMissing: "The community bot isn’t in this room right now, so an admin will review join requests.",
secondDeviceHint: "Already a member on another device? Use “Link device” instead — the bot can’t add a second key to the same account.",
create: "สร้างห้องชุมชน",
setupDeviceBody: "สิ่งนี้มอบกุญแจสำหรับห้องชุมชนให้อุปกรณ์นี้ ผู้ดูแลหรือบอทจะอนุมัติหนึ่งครั้ง",
onboardingFounder1: "สร้างห้องชุมชน",

View file

@ -303,6 +303,7 @@ const driftStrings = {
noGroupMember: "Henüz bu crew'in özel sohbetinde değilsin.",
create: 'Özel sohbeti oluştur',
requestJoin: 'Katılmayı iste',
requestAgain: "Request again",
checkInvite: 'Davetiye kontrol et',
requested: 'İstek gönderildi — bir crew yöneticisi onaylayacak.',
noInvite: 'Henüz bekleyen davetiye yok. Bir yöneticiden isteğini onaylamasını iste.',
@ -394,6 +395,13 @@ const driftStrings = {
notLoggedIn: "Topluluk sohbetine katılmak için giriş yap.",
noGroupAdmin: "Topluluk odası henüz oluşturulmadı.",
noGroupMember: "Henüz topluluk odasında değilsin.",
waitingApproval: "Topluluk botunun veya bir yöneticinin onaylaması bekleniyor…",
waitingApprovalManual: "Manuel mod. Onaylandığında “Davetiye kontrol et”e dokun.",
noInvite: "Henüz davetiye yok. Bot genellikle bir dakika içinde onaylar — ardından “Davetiye kontrol et”e dokun.",
pendingRequests: "{{count}} katılma isteği bekliyor",
pendingRequestsDesc: "Bot genellikle otomatik onaylar. Takıldıysa aşağıdaki odayı aç ve İstekleri kontrol et’e dokun.",
botMissing: "The community bot isn’t in this room right now, so an admin will review join requests.",
secondDeviceHint: "Already a member on another device? Use “Link device” instead — the bot can’t add a second key to the same account.",
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.",

View file

@ -311,6 +311,7 @@ const drift = {
noGroupMember: "Ви ще не в приватному чаті цієї команди.",
create: "Створити приватний чат",
requestJoin: "Попросити приєднатися",
requestAgain: "Request again",
checkInvite: "Перевірити запрошення",
requested: "Запит надіслано — адміністратор команди його схвалить.",
noInvite: "Запрошення ще не чекає. Попросіть адміністратора схвалити ваш запит.",
@ -402,6 +403,13 @@ const drift = {
notLoggedIn: "Увійди, щоб приєднатися до чату спільноти.",
noGroupAdmin: "Кімнату спільноти ще не створено.",
noGroupMember: "Ти ще не в кімнаті спільноти.",
waitingApproval: "Чекаємо на схвалення від бота спільноти або адміністратора…",
waitingApprovalManual: "Ручний режим. Коли схвалять, натисни «Перевірити запрошення».",
noInvite: "Запрошення ще немає. Бот зазвичай схвалює протягом хвилини — після цього натисни «Перевірити запрошення».",
pendingRequests: "{{count}} запитів на приєднання в очікуванні",
pendingRequestsDesc: "Бот зазвичай схвалює автоматично. Якщо застрягло, відкрий кімнату нижче й натисни «Перевірити запити».",
botMissing: "The community bot isn’t in this room right now, so an admin will review join requests.",
secondDeviceHint: "Already a member on another device? Use “Link device” instead — the bot can’t add a second key to the same account.",
create: "Створити кімнату спільноти",
setupDeviceBody: "Це дає цьому пристрою ключ до кімнати спільноти. Адміністратор або бот підтверджує його один раз.",
onboardingFounder1: "Створи кімнату спільноти.",

View file

@ -303,6 +303,7 @@ const driftStrings = {
noGroupMember: 'Bạn chưa ở trong chat riêng tư của crew này.',
create: 'Tạo chat riêng tư',
requestJoin: 'Xin tham gia',
requestAgain: "Request again",
checkInvite: 'Kiểm tra lời mời',
requested: 'Đã gửi yêu cầu — quản trị viên crew sẽ duyệt.',
noInvite: 'Chưa có lời mời nào. Hãy nhờ quản trị viên duyệt yêu cầu của bạn.',
@ -394,6 +395,13 @@ const driftStrings = {
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.",
waitingApproval: "Đang chờ bot cộng đồng hoặc quản trị viên phê duyệt…",
waitingApprovalManual: "Chế độ thủ công. Nhấn “Kiểm tra lời mời” sau khi được phê duyệt.",
noInvite: "Chưa có lời mời. Bot thường phê duyệt trong vòng một phút — sau đó nhấn “Kiểm tra lời mời”.",
pendingRequests: "{{count}} yêu cầu tham gia đang chờ",
pendingRequestsDesc: "Bot thường phê duyệt tự động. Nếu bị kẹt, mở phòng bên dưới và nhấn Kiểm tra yêu cầu.",
botMissing: "The community bot isn’t in this room right now, so an admin will review join requests.",
secondDeviceHint: "Already a member on another device? Use “Link device” instead — the bot can’t add a second key to the same account.",
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.",

View file

@ -319,6 +319,7 @@ const drift: Dict = {
noGroupMember: '你还没有加入此 Crew 的私密聊天。',
create: '创建私密聊天',
requestJoin: '请求加入',
requestAgain: "Request again",
checkInvite: '检查邀请',
requested: '请求已发送——Crew 管理员会批准。',
noInvite: '还没有等待中的邀请。请管理员批准你的请求。',
@ -410,6 +411,13 @@ const drift: Dict = {
notLoggedIn: "登录后即可加入社区聊天。",
noGroupAdmin: "社区房间尚未创建。",
noGroupMember: "你还没有加入社区房间。",
waitingApproval: "正在等待社区机器人或管理员批准…",
waitingApprovalManual: "手动模式。批准后请点按“检查邀请”。",
noInvite: "还没有邀请。机器人通常在一分钟内批准——之后请点按“检查邀请”。",
pendingRequests: "{{count}} 个加入请求正在等待",
pendingRequestsDesc: "机器人通常会自动批准。如果卡住了,请打开下面的房间并点按“检查请求”。",
botMissing: "The community bot isn’t in this room right now, so an admin will review join requests.",
secondDeviceHint: "Already a member on another device? Use “Link device” instead — the bot can’t add a second key to the same account.",
create: "创建社区房间",
setupDeviceBody: "这会为此设备提供社区房间的密钥,管理员或机器人会批准一次。",
onboardingFounder1: "创建社区房间。",

View file

@ -319,6 +319,7 @@ const drift: Dict = {
noGroupMember: '你還沒有加入此 Crew 的私密聊天。',
create: '建立私密聊天',
requestJoin: '要求加入',
requestAgain: "Request again",
checkInvite: '檢查邀請',
requested: '要求已送出——Crew 管理員會批准。',
noInvite: '還沒有等待中的邀請。請管理員批准你的要求。',
@ -410,6 +411,13 @@ const drift: Dict = {
notLoggedIn: "登入後即可加入社群聊天。",
noGroupAdmin: "社群房間尚未建立。",
noGroupMember: "你還沒有加入社群房間。",
waitingApproval: "正在等待社群機器人或管理員核准…",
waitingApprovalManual: "手動模式。核准後請點按「檢查邀請」。",
noInvite: "還沒有邀請。機器人通常在一分鐘內核准——之後請點按「檢查邀請」。",
pendingRequests: "{{count}} 個加入要求正在等待",
pendingRequestsDesc: "機器人通常會自動核准。如果卡住了,請開啟下面的房間並點按「檢查要求」。",
botMissing: "The community bot isn’t in this room right now, so an admin will review join requests.",
secondDeviceHint: "Already a member on another device? Use “Link device” instead — the bot can’t add a second key to the same account.",
create: "建立社群房間",
setupDeviceBody: "這會為此裝置提供社群房間的金鑰,管理員或機器人會核准一次。",
onboardingFounder1: "建立社群房間。",

View file

@ -457,9 +457,23 @@ export class CordnSession {
return keyPackage;
}
/** Pending join requests for a group (for an admin to approve). */
async fetchJoinRequests(gid: string): Promise<JoinRequest[]> {
const result = await this.getClient().fetchManyPendingJoinRequests({ groups: [{ gid }] });
/**
* Pending join requests for a group.
*
* Pass `consumed` after handling a request so the coordinator can retire it;
* otherwise an already-approved request can sit in the queue forever and be
* retried on every poll (MLS then rejects it as "already in the group").
*/
async fetchJoinRequests(
gid: string,
consumed: Array<{ pk: string; at: number }> = [],
): Promise<JoinRequest[]> {
const result = await this.getClient().fetchManyPendingJoinRequests({
groups: [{ gid }],
...(consumed.length > 0
? { consumed: consumed.map((request) => ({ ...request, gid })) }
: {}),
});
return result.requests;
}

View file

@ -1,7 +1,7 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import { Link } from 'react-router-dom';
import { ArrowLeft, HeartHandshake, Lock, MessagesSquare } from 'lucide-react';
import { ArrowLeft, HeartHandshake, Lock, MessagesSquare, UserPlus } from 'lucide-react';
import { CordnNativePanel } from '@/components/crew/CordnNativePanel';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
@ -20,6 +20,7 @@ export default function CommunityPage() {
const { t } = useTranslation();
const { user } = useCurrentUser();
const canManage = isAdminPubkey(user?.pubkey);
const [pendingRequests, setPendingRequests] = useState(0);
const inviteUrl = useMemo(() => {
const origin = isNativePlatform() ? 'https://skate-spots.org' : window.location.origin;
@ -59,6 +60,29 @@ export default function CommunityPage() {
</CardContent>
</Card>
{canManage && pendingRequests > 0 && (
<Card className="mb-4 border-amber-500/30 bg-amber-500/[0.06]" aria-live="polite">
<CardContent className="flex items-start gap-3 py-3">
<UserPlus className="mt-0.5 h-4 w-4 shrink-0 text-amber-300" />
<div className="min-w-0 flex-1">
<p className="text-sm font-bold text-amber-200">
{t('communityUi.pendingRequests', { count: pendingRequests })}
</p>
<p className="mt-0.5 text-xs text-amber-200/70">
{t('communityUi.pendingRequestsDesc')}
</p>
<button
type="button"
onClick={() => document.getElementById('community-requests')?.scrollIntoView({ behavior: 'smooth', block: 'center' })}
className="mt-1.5 text-xs font-bold text-amber-200 underline underline-offset-2 hover:text-amber-100"
>
{t('cordnUi.checkRequests')}
</button>
</div>
</CardContent>
</Card>
)}
<CordnNativePanel
crew={COMMUNITY_ROOM}
canManage={canManage}
@ -67,6 +91,7 @@ export default function CommunityPage() {
scope="community"
inviteUrl={inviteUrl}
botPubkey={COMMUNITY_BOT_PUBKEY || undefined}
onRequestsChange={setPendingRequests}
/>
<div className="mt-5 flex items-center justify-center gap-2 text-xs text-gray-500">