diff --git a/netlify.toml b/netlify.toml index 337f131..9ea4b31 100644 --- a/netlify.toml +++ b/netlify.toml @@ -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 diff --git a/scripts/community-bot.ts b/scripts/community-bot.ts index 0357f23..d921f6d 100644 --- a/scripts/community-bot.ts +++ b/scripts/community-bot.ts @@ -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 { return false; } -const approved = new Set(); +// 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(); const lastAttempt = new Map(); let lastJoinRequestAt = 0; let lastError = ''; +let lastPending = 0; +let capLogged = false; +const deviceCapLogged = new Set(); async function cycle(): Promise { try { @@ -149,23 +166,104 @@ async function cycle(): Promise { 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 { 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(); diff --git a/src/components/crew/CordnNativePanel.tsx b/src/components/crew/CordnNativePanel.tsx index 5ba9029..cbb0508 100644 --- a/src/components/crew/CordnNativePanel.tsx +++ b/src/components/crew/CordnNativePanel.tsx @@ -112,6 +112,12 @@ export function CordnNativePanel({ const queryClient = useQueryClient(); const [roleBusy, setRoleBusy] = useState(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({ + {isCommunity && native.group && botPubkey && !native.group.members.some((member) => member.pubkey === botPubkey) && ( +
+ {t('communityUi.botMissing')} +
+ )} + {native.error && (
@@ -726,16 +738,33 @@ export function CordnNativePanel({ ) : native.interactiveHintVisible ? ( <> - {t('cordnUi.waitingApprovalManual')} + {t(waitingApprovalManualKey)} ) : ( <> - {t('cordnUi.waitingApproval')} + {t(waitingApprovalKey)} )}

)} + {awaiting && !deviceLinkPending && ( + + )} + + {isCommunity && ( +

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

+ )} + {notice &&

{notice}

} {canManage && ( -
+
{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} /> diff --git a/src/hooks/useCordnNative.ts b/src/hooks/useCordnNative.ts index fcbe1da..675f9d0 100644 --- a/src/hooks/useCordnNative.ts +++ b/src/hooks/useCordnNative.ts @@ -83,8 +83,12 @@ export interface UseCordnNativeResult { loadRequests: () => Promise; /** Background check that never toggles `busy` (for a tab/request badge). */ loadRequestsQuiet: () => Promise; - /** Founder/admin: add a requester (by pubkey) and deliver a welcome. */ - approve: (identifier: string) => Promise; + /** Founder/admin: add a requester by key-package ref, falling back to pubkey. */ + approve: ( + identifier: string, + fallbackIdentifier?: string, + handled?: { pk: string; at: number }, + ) => Promise; /** Founder/admin: remove a member leaf (device) from the group. */ removeMember: (leafIndex: number) => Promise; /** 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; }); diff --git a/src/i18n/ca/batch1.ts b/src/i18n/ca/batch1.ts index 9519c74..2829bf0 100644 --- a/src/i18n/ca/batch1.ts +++ b/src/i18n/ca/batch1.ts @@ -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.", diff --git a/src/i18n/cs/drift.ts b/src/i18n/cs/drift.ts index 51b048d..798fa40 100644 --- a/src/i18n/cs/drift.ts +++ b/src/i18n/cs/drift.ts @@ -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.", diff --git a/src/i18n/de/drift.ts b/src/i18n/de/drift.ts index 72c6309..621d326 100644 --- a/src/i18n/de/drift.ts +++ b/src/i18n/de/drift.ts @@ -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.", diff --git a/src/i18n/el/drift.ts b/src/i18n/el/drift.ts index 935291f..1db6e1e 100644 --- a/src/i18n/el/drift.ts +++ b/src/i18n/el/drift.ts @@ -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: "Δημιούργησε το δωμάτιο της κοινότητας.", diff --git a/src/i18n/eu/batch1.ts b/src/i18n/eu/batch1.ts index 869c5f9..7a0b570 100644 --- a/src/i18n/eu/batch1.ts +++ b/src/i18n/eu/batch1.ts @@ -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.", diff --git a/src/i18n/fr/drift.ts b/src/i18n/fr/drift.ts index 3b4d790..085d582 100644 --- a/src/i18n/fr/drift.ts +++ b/src/i18n/fr/drift.ts @@ -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é.", diff --git a/src/i18n/gl/batch1.ts b/src/i18n/gl/batch1.ts index 0c924d3..d8000c7 100644 --- a/src/i18n/gl/batch1.ts +++ b/src/i18n/gl/batch1.ts @@ -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.", diff --git a/src/i18n/hi/batch1.ts b/src/i18n/hi/batch1.ts index 78d50fc..a54e6b3 100644 --- a/src/i18n/hi/batch1.ts +++ b/src/i18n/hi/batch1.ts @@ -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": "कम्युनिटी रूम बनाएँ।", diff --git a/src/i18n/id/drift.ts b/src/i18n/id/drift.ts index ec0bb54..b3bdb99 100644 --- a/src/i18n/id/drift.ts +++ b/src/i18n/id/drift.ts @@ -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.", diff --git a/src/i18n/it/drift.ts b/src/i18n/it/drift.ts index 7edc581..5fa206a 100644 --- a/src/i18n/it/drift.ts +++ b/src/i18n/it/drift.ts @@ -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.", diff --git a/src/i18n/ja/drift.ts b/src/i18n/ja/drift.ts index 7228e51..50035cf 100644 --- a/src/i18n/ja/drift.ts +++ b/src/i18n/ja/drift.ts @@ -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: "コミュニティルームを作成します。", diff --git a/src/i18n/ko/drift.ts b/src/i18n/ko/drift.ts index e21bfa8..0deddda 100644 --- a/src/i18n/ko/drift.ts +++ b/src/i18n/ko/drift.ts @@ -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: "커뮤니티 방을 만드세요.", diff --git a/src/i18n/legacy/communityUi.ts b/src/i18n/legacy/communityUi.ts index bc31efe..adffba7 100644 --- a/src/i18n/legacy/communityUi.ts +++ b/src/i18n/legacy/communityUi.ts @@ -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.', diff --git a/src/i18n/legacy/cordnUi.ts b/src/i18n/legacy/cordnUi.ts index 2b4f67a..f5cd5b4 100644 --- a/src/i18n/legacy/cordnUi.ts +++ b/src/i18n/legacy/cordnUi.ts @@ -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.', diff --git a/src/i18n/nl/drift.ts b/src/i18n/nl/drift.ts index 2638ded..b085514 100644 --- a/src/i18n/nl/drift.ts +++ b/src/i18n/nl/drift.ts @@ -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.", diff --git a/src/i18n/pl/drift.ts b/src/i18n/pl/drift.ts index 67bf152..b0f781f 100644 --- a/src/i18n/pl/drift.ts +++ b/src/i18n/pl/drift.ts @@ -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.", diff --git a/src/i18n/pt/drift.ts b/src/i18n/pt/drift.ts index f095125..de2bcdb 100644 --- a/src/i18n/pt/drift.ts +++ b/src/i18n/pt/drift.ts @@ -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.", diff --git a/src/i18n/ro/drift.ts b/src/i18n/ro/drift.ts index 7049b68..b9592a1 100644 --- a/src/i18n/ro/drift.ts +++ b/src/i18n/ro/drift.ts @@ -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.", diff --git a/src/i18n/ru/drift.ts b/src/i18n/ru/drift.ts index db5c503..bfc61da 100644 --- a/src/i18n/ru/drift.ts +++ b/src/i18n/ru/drift.ts @@ -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: "Создай комнату сообщества.", diff --git a/src/i18n/sv/drift.ts b/src/i18n/sv/drift.ts index a95da1c..682ec32 100644 --- a/src/i18n/sv/drift.ts +++ b/src/i18n/sv/drift.ts @@ -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.", diff --git a/src/i18n/th/drift.ts b/src/i18n/th/drift.ts index bc836d1..63578c1 100644 --- a/src/i18n/th/drift.ts +++ b/src/i18n/th/drift.ts @@ -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: "สร้างห้องชุมชน", diff --git a/src/i18n/tr/drift.ts b/src/i18n/tr/drift.ts index fcdf45d..eb1fd52 100644 --- a/src/i18n/tr/drift.ts +++ b/src/i18n/tr/drift.ts @@ -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.", diff --git a/src/i18n/uk/drift.ts b/src/i18n/uk/drift.ts index c18ba0a..1762f28 100644 --- a/src/i18n/uk/drift.ts +++ b/src/i18n/uk/drift.ts @@ -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: "Створи кімнату спільноти.", diff --git a/src/i18n/vi/drift.ts b/src/i18n/vi/drift.ts index cecef33..6dc4292 100644 --- a/src/i18n/vi/drift.ts +++ b/src/i18n/vi/drift.ts @@ -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.", diff --git a/src/i18n/zh-CN/drift.ts b/src/i18n/zh-CN/drift.ts index 18380ac..f798ef4 100644 --- a/src/i18n/zh-CN/drift.ts +++ b/src/i18n/zh-CN/drift.ts @@ -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: "创建社区房间。", diff --git a/src/i18n/zh-TW/drift.ts b/src/i18n/zh-TW/drift.ts index d30786e..183d14d 100644 --- a/src/i18n/zh-TW/drift.ts +++ b/src/i18n/zh-TW/drift.ts @@ -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: "建立社群房間。", diff --git a/src/lib/cordn/native/session.ts b/src/lib/cordn/native/session.ts index 9b92f92..99d1be5 100644 --- a/src/lib/cordn/native/session.ts +++ b/src/lib/cordn/native/session.ts @@ -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 { - 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 { + const result = await this.getClient().fetchManyPendingJoinRequests({ + groups: [{ gid }], + ...(consumed.length > 0 + ? { consumed: consumed.map((request) => ({ ...request, gid })) } + : {}), + }); return result.requests; } diff --git a/src/pages/CommunityPage.tsx b/src/pages/CommunityPage.tsx index 8e0baca..d5b8b3a 100644 --- a/src/pages/CommunityPage.tsx +++ b/src/pages/CommunityPage.tsx @@ -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() { + {canManage && pendingRequests > 0 && ( + + + +
+

+ {t('communityUi.pendingRequests', { count: pendingRequests })} +

+

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

+ +
+
+
+ )} +