Collectives P1: participation landing (ES/EN), group tree, room provisioning APIs
- 030: groups.parent_id tree + kind discriminator + group_rooms mapping - /collectives module: create/tree/join APIs, MatrixProvisioner (open room public+federated, closed room E2EE-at-creation, onboarding room), NullProvisioner fail-soft when ARCHIVE_PROVISIONER_* unset - web root becomes the public participation manual (ES-first + EN toggle, three paths: join by invite, start collective, solo account) - participation manual content in core
This commit is contained in:
parent
c31762b82d
commit
4a7e78b28d
23 changed files with 2892 additions and 8 deletions
|
|
@ -1,9 +1,9 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
async redirects() {
|
||||
return [{ source: "/", destination: "/streams", permanent: false }];
|
||||
},
|
||||
// The app root ("/") is the public participation-manual landing page
|
||||
// (collectives P1): no redirect — see src/app/page.tsx. Signed-in visitors
|
||||
// are sent to /streams client-side so the manual stays reachable.
|
||||
async rewrites() {
|
||||
const api = (process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000").replace(/\/$/, "");
|
||||
return [{ source: "/media-control/preview/:path*", destination: `${api}/media-control/preview/:path*` }];
|
||||
|
|
|
|||
|
|
@ -6,10 +6,12 @@
|
|||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:manual": "node --require ./scripts/control-room-register.cjs --test src/lib/participation-manual.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tactical-media-broadcast-hub/connector-sdk": "workspace:^",
|
||||
"@tactical-media-broadcast-hub/core": "workspace:^",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@tanstack/react-virtual": "^3.14.13",
|
||||
"@tiptap/extension-character-count": "^3.31.4",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Badge, Button, Card, Input } from "../../../components/ui/primitives";
|
|||
import { LoadingPanel } from "../../../components/brand/loading";
|
||||
import { apiFetch, ApiError, ApiUnreachableError, errorMessage } from "../../../lib/api-client";
|
||||
import type { InviteInfo } from "../../../lib/api-types";
|
||||
import type { ManualLanguage } from "@tactical-media-broadcast-hub/core";
|
||||
import { useAuth } from "../../../lib/auth-store";
|
||||
|
||||
const MIN_PASSWORD = 10;
|
||||
|
|
@ -29,6 +30,16 @@ function deadReasonFrom(err: unknown): DeadReason {
|
|||
|
||||
const isAlreadyMember = (err: unknown): boolean => err instanceof ApiError && err.status === 409;
|
||||
|
||||
/**
|
||||
* One-line collective labelling for the claim card ("Collectives: X · Y").
|
||||
* Null when unknown or empty — the card must read fine without it.
|
||||
*/
|
||||
function collectiveLabel(collectives: Array<{ id: string; name: string }> | null, lang: ManualLanguage): string | null {
|
||||
if (collectives === null || collectives.length === 0) return null;
|
||||
const names = collectives.map((c) => c.name).join(" · ");
|
||||
return lang === "es" ? `Colectivos: ${names}` : `Collectives: ${names}`;
|
||||
}
|
||||
|
||||
function usesLeftLine(info: InviteInfo): string {
|
||||
const left = info.maxUses === null ? "unlimited uses" : `${Math.max(info.maxUses - info.usedCount, 0)} use${info.maxUses - info.usedCount === 1 ? "" : "s"} left`;
|
||||
return `${left} · expires ${new Date(info.expiresAt).toLocaleDateString()}`;
|
||||
|
|
@ -183,6 +194,9 @@ export default function JoinWorkspacePage({ params }: { params: { token: string
|
|||
const [claimError, setClaimError] = useState<string | null>(null);
|
||||
const [alreadyMember, setAlreadyMember] = useState(false);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
// Collective names for the claim card (names-only public read). Purely
|
||||
// cosmetic labelling: a failure renders the card without it, never blocks.
|
||||
const [collectives, setCollectives] = useState<Array<{ id: string; name: string }> | null>(null);
|
||||
|
||||
// The preview is public: fetch it immediately rather than waiting for the session to settle.
|
||||
useEffect(() => {
|
||||
|
|
@ -193,6 +207,14 @@ export default function JoinWorkspacePage({ params }: { params: { token: string
|
|||
if (cancelled) return;
|
||||
setInfo(preview);
|
||||
setPhase("preview");
|
||||
// Best-effort collective labelling: the token is already proven valid.
|
||||
apiFetch<{ collectives: Array<{ id: string; name: string }> }>(`/collectives-public/for-invite/${encodeURIComponent(params.token)}`, {
|
||||
redirectOn401: false,
|
||||
})
|
||||
.then((result) => {
|
||||
if (!cancelled) setCollectives(result.collectives);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
|
|
@ -288,6 +310,9 @@ export default function JoinWorkspacePage({ params }: { params: { token: string
|
|||
<Badge tone="accent">{info.role}</Badge>.
|
||||
</p>
|
||||
<p className="mt-2 text-2xs text-text-faint">{usesLeftLine(info)}</p>
|
||||
{collectiveLabel(collectives, "en") !== null && (
|
||||
<p className="mt-1 text-2xs text-text-faint">{collectiveLabel(collectives, "en")}</p>
|
||||
)}
|
||||
{claimError !== null && <p className="mt-2 text-xs text-danger">{claimError}</p>}
|
||||
{memberCta}
|
||||
</Card>
|
||||
|
|
@ -309,6 +334,9 @@ export default function JoinWorkspacePage({ params }: { params: { token: string
|
|||
<p className="mt-1 text-2xs text-text-faint">
|
||||
You've been invited as <Badge tone="accent">{info.role}</Badge> · {usesLeftLine(info)}
|
||||
</p>
|
||||
{collectiveLabel(collectives, "en") !== null && (
|
||||
<p className="mt-0.5 text-2xs text-text-faint">{collectiveLabel(collectives, "en")}</p>
|
||||
)}
|
||||
</div>
|
||||
{mode === "signin" ? (
|
||||
<JoinSignInForm token={params.token} onSwitch={() => setMode("register")} onAlreadyMember={() => setAlreadyMember(true)} />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,190 @@
|
|||
import { redirect } from "next/navigation";
|
||||
"use client";
|
||||
|
||||
export default function RootPage(): never {
|
||||
redirect("/streams");
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
MANUAL_INTRO,
|
||||
MANUAL_PATHS_HEADING,
|
||||
MANUAL_TITLE,
|
||||
PARTICIPATION_MANUAL,
|
||||
PARTICIPATION_PATHS,
|
||||
isManualLanguage,
|
||||
type ManualLanguage,
|
||||
} from "@tactical-media-broadcast-hub/core";
|
||||
import { Logo } from "../components/brand/logo";
|
||||
import { AuthProvider, useAuth } from "../lib/auth-store";
|
||||
|
||||
/**
|
||||
* Public participation-manual landing page (the app root).
|
||||
*
|
||||
* Design (docs/design/2026-10-12-collectives-architecture.md §"Participation
|
||||
* manual"): the root explains the platform in plain language and offers the
|
||||
* three participation paths — join a collective, start one, work on your own —
|
||||
* before any auth wall. The copy is the SINGLE source in
|
||||
* packages/core/src/participation-manual.ts: the provisioner posts the
|
||||
* byte-identical text into onboarding rooms, so this page and the bot guide can
|
||||
* never drift apart.
|
||||
*
|
||||
* Spanish is the primary language: the page opens in ES and the EN toggle is a
|
||||
* one-tap switch, mirroring the bot's ES-first posting order.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = "tmbh.manual.lang";
|
||||
|
||||
function readStoredLanguage(): ManualLanguage {
|
||||
if (typeof window === "undefined") return "es";
|
||||
try {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
return isManualLanguage(stored) ? stored : "es";
|
||||
} catch {
|
||||
return "es";
|
||||
}
|
||||
}
|
||||
|
||||
/** Renders manual body text: blank-line paragraphs, "- " bullets. */
|
||||
function ManualBody({ text }: { text: string }): React.ReactElement {
|
||||
const blocks = text.split("\n\n");
|
||||
return (
|
||||
<>
|
||||
{blocks.map((block, index) => {
|
||||
const lines = block.split("\n");
|
||||
return lines.every((line) => line.startsWith("- ")) ? (
|
||||
<ul key={index} className="mb-3 ml-1 list-disc space-y-1.5 pl-4">
|
||||
{lines.map((line, i) => (
|
||||
<li key={i}>{line.slice(2)}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p key={index} className="mb-3 leading-relaxed">{block}</p>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The root sits OUTSIDE the (main) route group, so its AuthProvider must be
|
||||
* local: the (main) layout's provider does not wrap this page, and useAuth
|
||||
* without a provider throws during prerender.
|
||||
*/
|
||||
export default function ManualLandingPage(): React.ReactElement {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<ManualLanding />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ManualLanding(): React.ReactElement {
|
||||
const { status, session } = useAuth();
|
||||
const router = useRouter();
|
||||
const [lang, setLang] = useState<ManualLanguage>("es");
|
||||
|
||||
useEffect(() => setLang(readStoredLanguage()), []);
|
||||
|
||||
function switchLanguage(next: ManualLanguage): void {
|
||||
setLang(next);
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, next);
|
||||
} catch {
|
||||
// Private-browsing refusal: the choice still holds for this view.
|
||||
}
|
||||
}
|
||||
|
||||
// Signed-in visitors go straight to the app; the manual stays reachable at /wiki.
|
||||
useEffect(() => {
|
||||
if (status === "authenticated") router.replace("/streams");
|
||||
}, [status, router]);
|
||||
|
||||
const t = <T extends { es: string; en: string }>(text: T): string => text[lang];
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-bg text-text">
|
||||
<header className="border-b border-border-subtle bg-surface">
|
||||
<div className="mx-auto flex max-w-2xl items-center gap-3 px-4 py-4">
|
||||
<Logo size={26} />
|
||||
<span className="flex-1 truncate text-xs font-bold tracking-tight text-accent">Tactical Media Broadcast Hub</span>
|
||||
<div className="flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5" role="group" aria-label={t({ es: "Idioma", en: "Language" })}>
|
||||
{(["es", "en"] as const).map((code) => (
|
||||
<button
|
||||
key={code}
|
||||
type="button"
|
||||
onClick={() => switchLanguage(code)}
|
||||
aria-pressed={lang === code}
|
||||
className={`min-h-8 rounded px-2.5 text-2xs font-bold uppercase transition-colors duration-150 ${
|
||||
lang === code ? "bg-accent text-white" : "text-text-dim hover:text-text"
|
||||
}`}
|
||||
>
|
||||
{code}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Link
|
||||
href={status === "authenticated" ? "/streams" : "/onboarding"}
|
||||
className="shrink-0 rounded-md bg-accent px-3 py-1.5 text-2xs font-medium text-white hover:bg-accent-hover"
|
||||
>
|
||||
{status === "authenticated"
|
||||
? t({ es: "Abrir la app", en: "Open app" })
|
||||
: t({ es: "Entrar", en: "Sign in" })}
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-2xl px-4 pb-20 pt-8">
|
||||
<h1 className="mb-2 text-2xl font-bold">{t(MANUAL_TITLE)}</h1>
|
||||
<p className="mb-8 text-sm leading-relaxed text-text-dim">{t(MANUAL_INTRO)}</p>
|
||||
|
||||
{/* The three participation paths — the core of the page. */}
|
||||
<section aria-labelledby="paths-heading" className="mb-10">
|
||||
<h2 id="paths-heading" className="mb-3 text-xs font-bold uppercase tracking-wider text-text-faint">
|
||||
{t(MANUAL_PATHS_HEADING)}
|
||||
</h2>
|
||||
<ul className="grid gap-3">
|
||||
{PARTICIPATION_PATHS.map((path) => {
|
||||
// All three land on /onboarding: that page holds the sign-in and
|
||||
// create-account forms. A join link itself arrives from outside
|
||||
// (the only /join route is /join/[token]); starting/solo need the
|
||||
// same forms, so one destination is honest for all three.
|
||||
const href = "/onboarding";
|
||||
return (
|
||||
<li key={path.id}>
|
||||
<Link
|
||||
href={href}
|
||||
className="flex min-h-24 flex-col justify-center gap-1.5 rounded-lg border border-border-subtle bg-surface p-4 transition-colors duration-150 hover:border-accent"
|
||||
>
|
||||
<span className="text-sm font-semibold">{t(path.title)}</span>
|
||||
<span className="text-xs leading-relaxed text-text-faint">{t(path.summary)}</span>
|
||||
<span className="mt-1 text-2xs font-bold uppercase tracking-wider text-accent">{t(path.action)} ›</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* The manual itself. */}
|
||||
<div className="flex flex-col gap-8">
|
||||
{PARTICIPATION_MANUAL.map((section, index) => (
|
||||
<section key={section.id} aria-labelledby={`section-${section.id}`}>
|
||||
<h2 id={`section-${section.id}`} className="mb-2 flex items-baseline gap-2.5 text-base font-bold">
|
||||
<span className="font-mono text-xs font-bold text-accent">{String(index + 1).padStart(2, "0")}</span>
|
||||
{t(section.heading)}
|
||||
</h2>
|
||||
<div className="text-sm text-text-dim">
|
||||
<ManualBody text={section.body[lang]} />
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<footer className="mt-12 border-t border-border-subtle pt-6 text-2xs leading-relaxed text-text-faint">
|
||||
{t({
|
||||
es: "Sin cuenta no se puede entrar al área de trabajo, pero esta página es pública por diseño: compártela con quien quiera entender cómo funciona un colectivo antes de pedir su invitación.",
|
||||
en: "The workspace itself needs an account, but this page is public by design: share it with anyone who wants to understand how a collective works before asking for their invite.",
|
||||
})}
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,32 @@ export interface InviteInfo {
|
|||
/** Claiming installs a fresh workspace session — same envelope as POST /auth/login. */
|
||||
export type InviteClaimResult = AuthResult;
|
||||
|
||||
/** GET /collectives-public/for-invite/:token — names-only, pre-claim labelling. */
|
||||
export interface CollectiveNameList {
|
||||
collectives: Array<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
/** A collective node in the workspace tree (GET /collectives/tree). */
|
||||
export interface CollectiveRoomSummary {
|
||||
roomType: "open" | "closed" | "onboarding";
|
||||
roomId: string;
|
||||
}
|
||||
|
||||
export type GroupKind = "collective" | "subcollective" | "plain";
|
||||
|
||||
export interface CollectiveNode {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: GroupKind;
|
||||
language: "es" | "en";
|
||||
parentId: string | null;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
memberCount: number;
|
||||
rooms: CollectiveRoomSummary[];
|
||||
children: CollectiveNode[];
|
||||
}
|
||||
|
||||
/** Anonymous claimant credentials; ignored when the request carries a valid session. */
|
||||
export interface InviteClaimInput {
|
||||
username: string;
|
||||
|
|
|
|||
136
apps/web/src/lib/participation-manual.test.ts
Normal file
136
apps/web/src/lib/participation-manual.test.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
MANUAL_LANGUAGES,
|
||||
MANUAL_TITLE,
|
||||
MANUAL_INTRO,
|
||||
MANUAL_PATHS_HEADING,
|
||||
PARTICIPATION_MANUAL,
|
||||
PARTICIPATION_PATHS,
|
||||
DEFAULT_MANUAL_LANGUAGE,
|
||||
MANUAL_LANGUAGE_ORDER,
|
||||
isManualLanguage,
|
||||
onboardingHeader,
|
||||
onboardingMessages,
|
||||
onboardingLanguageOrder,
|
||||
type ManualLanguage,
|
||||
} from "@tactical-media-broadcast-hub/core";
|
||||
|
||||
/**
|
||||
* Landing-page content tests (node:test, the control-room.test.ts runner).
|
||||
* The copy module is the single source shared by the public root page and the
|
||||
* Matrix onboarding bot, so these assertions protect BOTH surfaces at once.
|
||||
*/
|
||||
|
||||
test("the manual covers the four sections the design mandates", () => {
|
||||
assert.deepEqual(
|
||||
PARTICIPATION_MANUAL.map((section) => section.id),
|
||||
["what-this-is", "rooms", "safety", "help"],
|
||||
);
|
||||
for (const section of PARTICIPATION_MANUAL) {
|
||||
for (const lang of MANUAL_LANGUAGES) {
|
||||
assert.ok(section.heading[lang].length > 0, `${section.id}.${lang} heading`);
|
||||
assert.ok(section.body[lang].length > 0, `${section.id}.${lang} body`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("the three participation paths are exactly join, start and solo", () => {
|
||||
assert.deepEqual(
|
||||
PARTICIPATION_PATHS.map((path) => path.id),
|
||||
["join", "start", "solo"],
|
||||
);
|
||||
for (const path of PARTICIPATION_PATHS) {
|
||||
assert.ok(path.title.es && path.title.en);
|
||||
assert.ok(path.summary.es && path.summary.en);
|
||||
assert.ok(path.action.es && path.action.en);
|
||||
}
|
||||
});
|
||||
|
||||
test("the safety section states the non-negotiables in both languages", () => {
|
||||
const safety = PARTICIPATION_MANUAL.find((section) => section.id === "safety")!;
|
||||
for (const [lang, needles] of [
|
||||
["es", ["cifrado de extremo a extremo", "borradones", "invitación", "cifrado"]],
|
||||
["en", ["end-to-end encryption", "redaction", "invitation", "ciphertext"]],
|
||||
] as const) {
|
||||
for (const needle of needles) assert.ok(safety.body[lang].toLowerCase().includes(needle), `${lang}: ${needle}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("the rooms section names all three room types and their privacy stance", () => {
|
||||
const rooms = PARTICIPATION_MANUAL.find((section) => section.id === "rooms")!;
|
||||
for (const [lang, needles] of [
|
||||
["es", ["abierta", "cerrada", "acogida", "nunca está cifrada", "nace cifrada"]],
|
||||
["en", ["open room", "closed room", "onboarding room", "never end-to-end encrypted", "born encrypted"]],
|
||||
] as const) {
|
||||
for (const needle of needles) assert.ok(rooms.body[lang].toLowerCase().includes(needle), `${lang}: ${needle}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("Spanish is the default and first language everywhere", () => {
|
||||
assert.equal(DEFAULT_MANUAL_LANGUAGE, "es");
|
||||
assert.deepEqual(MANUAL_LANGUAGE_ORDER, ["es", "en"]);
|
||||
assert.equal(onboardingLanguageOrder("es")[0], "es");
|
||||
assert.equal(onboardingLanguageOrder("en")[0], "en");
|
||||
// Title/intro exist for both languages (the page renders whichever is active).
|
||||
assert.ok(MANUAL_TITLE.es && MANUAL_TITLE.en);
|
||||
assert.ok(MANUAL_INTRO.es && MANUAL_INTRO.en);
|
||||
assert.ok(MANUAL_PATHS_HEADING.es && MANUAL_PATHS_HEADING.en);
|
||||
});
|
||||
|
||||
test("isManualLanguage accepts exactly es and en", () => {
|
||||
assert.equal(isManualLanguage("es"), true);
|
||||
assert.equal(isManualLanguage("en"), true);
|
||||
for (const bad of ["fr", "ES", "", null, undefined, 1]) {
|
||||
assert.equal(isManualLanguage(bad), false);
|
||||
}
|
||||
});
|
||||
|
||||
test("onboardingMessages is one header plus one message per section, in section order", () => {
|
||||
for (const lang of MANUAL_LANGUAGES) {
|
||||
const messages = onboardingMessages(lang);
|
||||
assert.equal(messages.length, PARTICIPATION_MANUAL.length + 1);
|
||||
assert.ok(messages[0]!.startsWith(onboardingHeader(lang)));
|
||||
for (const [index, section] of PARTICIPATION_MANUAL.entries()) {
|
||||
const message = messages[index + 1];
|
||||
assert.ok(message !== undefined, `${lang} section ${index} exists`);
|
||||
assert.ok(message!.startsWith(section.heading[lang]), `${lang} section ${index} starts with its heading`);
|
||||
assert.ok(message!.includes(section.body[lang].split("\n\n")[0]!), `${lang} section ${index} carries its body`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("the onboarding header cross-references the other language", () => {
|
||||
assert.ok(onboardingHeader("es").includes("inglés"));
|
||||
assert.ok(onboardingHeader("en").includes("Spanish"));
|
||||
});
|
||||
|
||||
test("every body block renders with the two known conventions only (paragraphs and '- ' bullets)", () => {
|
||||
for (const section of PARTICIPATION_MANUAL) {
|
||||
for (const lang of MANUAL_LANGUAGES) {
|
||||
for (const block of section.body[lang].split("\n\n")) {
|
||||
const lines = block.split("\n");
|
||||
const allBullets = lines.every((line) => line.startsWith("- "));
|
||||
const noBullets = lines.every((line) => !line.startsWith("- "));
|
||||
assert.ok(allBullets || noBullets, `${section.id}.${lang}: mixed bullet/paragraph block`);
|
||||
// No markdown the renderer does not understand.
|
||||
for (const line of lines) assert.ok(!/^#{1,6} /.test(line), `${section.id}.${lang}: stray heading`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("language type is closed: assigning an unknown value is a compile-time error, runtime is guarded", () => {
|
||||
const langs: ManualLanguage[] = ["es", "en"];
|
||||
assert.deepEqual(langs.sort(), ["en", "es"]);
|
||||
});
|
||||
|
||||
test("manual copy stays plain-language: no dev jargon in user-facing sections", () => {
|
||||
const jargon = ["api", "http", "rls", "endpoint", "json", "sdk", "federate=true", "megolm"];
|
||||
for (const section of PARTICIPATION_MANUAL) {
|
||||
for (const lang of MANUAL_LANGUAGES) {
|
||||
const body = section.body[lang].toLowerCase();
|
||||
for (const word of jargon) assert.ok(!body.includes(word), `${section.id}.${lang}: jargon "${word}"`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -83,6 +83,45 @@ export const WIKI_PAGES: readonly WikiPage[] = [
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "collectives",
|
||||
title: { en: "Collectives and rooms", es: "Colectivos y salas" },
|
||||
blurb: {
|
||||
en: "How the collective model works: the open, closed and onboarding rooms, who gets in, and what stays private.",
|
||||
es: "Cómo funciona el modelo de colectivos: las salas abierta, cerrada y de acogida, quién entra y qué se mantiene privado.",
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
heading: { en: "What a collective is", es: "Qué es un colectivo" },
|
||||
body: {
|
||||
en: "A collective is your group inside the workspace: it has its own name, its own language (Spanish or English), its own files folder and its own set of Matrix rooms. A workspace can hold several collectives, and a collective can hold subcollectives — a tree, with the people who found it at the root.\n\nEverything else you already know still applies: drafts, approvals, scheduling and the archive are shared through the workspace, while the collective decides who talks with whom and where.",
|
||||
es: "Un colectivo es tu grupo dentro del espacio de trabajo: tiene su propio nombre, su propio idioma (español o inglés), su propia carpeta de archivos y su propio conjunto de salas de Matrix. Un espacio puede contener varios colectivos, y un colectivo puede tener subcolectivos — un árbol, con quienes lo fundan en la raíz.\n\nTodo lo demás que ya conoces sigue igual: borradores, revisiones, programación y archivo se comparten por el espacio de trabajo, mientras el colectivo decide quién habla con quién y dónde.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "The three rooms", es: "Las tres salas" },
|
||||
body: {
|
||||
en: "Creating a collective provisions three rooms automatically.\n\n- **Open room** — public, listed and federated: anyone from any Matrix server can join and talk to you without an account from us. It is never end-to-end encrypted, because the archive bot must be able to read it to index what is shared. Everything written there is public — treat it that way.\n- **Closed room** (production) — invite-only and limited to this server. It is end-to-end encrypted from the moment it is created: there is no switch to flip and no unencrypted version of its content anywhere on the server. Internal coordination, people's data and decisions belong here.\n- **Onboarding room** — invite-only and disposable. The guide bot posts the participation manual in Spanish and in English, so every new person reads the same orientation.",
|
||||
|
||||
es: "Crear un colectivo provisiona tres salas automáticamente.\n\n- **Sala abierta** — pública, listada y federada: cualquiera desde cualquier servidor de Matrix puede entrar y hablarte sin pedirte cuenta. Nunca está cifrada de extremo a extremo, porque el bot de archivo debe poder leerla para indexar lo compartido. Todo lo que se escriba ahí es público — trátalo así.\n- **Sala cerrada** (producción) — solo por invitación y limitada a este servidor. Está cifrada de extremo a extremo desde el momento en que se crea: no hay interruptor que activar ni versión sin cifrar de su contenido en ninguna parte del servidor. La coordinación interna, los datos de personas y las decisiones van aquí.\n- **Sala de acogida** — solo por invitación y desechable. El bot guía publica el manual de participación en español y en inglés, así que cada persona nueva lee la misma orientación.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Who can do what", es: "Quién puede hacer qué" },
|
||||
body: {
|
||||
en: "The person who creates a collective is its **founder**: they hold the highest permission level in the open and closed rooms and they are the one who invites. Everyone else starts at the normal level — they can speak, but not reshape the rooms.\n\nJoining happens by invite link, exactly as it always has: open the link, create your account if you need one, and you are in. If the link has expired or run out of uses, ask for a fresh one — the expiry is a safety measure, not a fault.",
|
||||
es: "Quien crea un colectivo es su **fundador**: tiene el nivel máximo de permisos en las salas abierta y cerrada, y es quien invita. Las demás personas empiezan en el nivel normal — pueden hablar, pero no reconfigurar las salas.\n\nEntrar se hace por enlace de invitación, igual que siempre: abre el enlace, crea tu cuenta si la necesitas, y ya estás dentro. Si el enlace caducó o agotó sus usos, pide uno nuevo: la caducidad es una medida de seguridad, no un fallo.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "What stays private", es: "Qué se mantiene privado" },
|
||||
body: {
|
||||
en: "If the server is seized, what leaves the closed rooms is ciphertext without the keys to read it — the material itself lives in the clients of the room's members. A redaction in Matrix is honoured in the index and in derived links. Open-room content was public by definition and is archived as public; closed-room material is never archived to the public index.\n\nThe public participation manual at the site root explains this same model for people who have no account at all — share that link freely.",
|
||||
es: "Si el servidor se incauta, lo que sale de las salas cerradas es texto cifrado sin las llaves para leerlo — el material mismo vive en los clientes de quienes son miembros de la sala. Un borradón en Matrix se respeta en el índice y en los enlaces derivados. El contenido de la sala abierta era público por definición y se archiva como público; el material de las salas cerradas nunca llega al índice público.\n\nEl manual de participación público, en la raíz del sitio, explica este mismo modelo para quien no tiene cuenta ninguna — comparte ese enlace con libertad.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "composing-posts",
|
||||
title: { en: "Composing posts", es: "Redactar publicaciones" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue