Manual: full participation guide (media/accounts/decisions/safety) + Enter-to-door + provisioner env passthrough
This commit is contained in:
parent
07d2df8c53
commit
6161ac4740
5 changed files with 251 additions and 42 deletions
|
|
@ -1,6 +1,4 @@
|
||||||
import {
|
import {
|
||||||
PARTICIPATION_MANUAL,
|
|
||||||
onboardingHeader,
|
|
||||||
onboardingLanguageOrder,
|
onboardingLanguageOrder,
|
||||||
onboardingMessages,
|
onboardingMessages,
|
||||||
type ManualLanguage,
|
type ManualLanguage,
|
||||||
|
|
@ -9,18 +7,17 @@ import {
|
||||||
/**
|
/**
|
||||||
* Onboarding content adapter.
|
* Onboarding content adapter.
|
||||||
*
|
*
|
||||||
* The bilingual manual copy lives in packages/core/src/participation-manual.ts
|
* The bilingual manual copy — including the table of contents the guide bot
|
||||||
* so there is ONE source: the public landing page renders it and the
|
* posts at the top of each language block — lives in
|
||||||
* provisioner posts the byte-identical text into Matrix onboarding rooms. This
|
* packages/core/src/participation-manual.ts so there is ONE source: the public
|
||||||
* module only derives the message sequence and its language order.
|
* landing page renders it and the provisioner posts the byte-identical text
|
||||||
|
* into Matrix onboarding rooms. This module only derives the message sequence
|
||||||
|
* and its language order.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** The manual as the guide bot posts it: one m.text message per section. */
|
/** The manual as the guide bot posts it: contents first, then one m.text message per section. */
|
||||||
export function manualMessages(lang: ManualLanguage): string[] {
|
export function manualMessages(lang: ManualLanguage): string[] {
|
||||||
return [
|
return onboardingMessages(lang);
|
||||||
onboardingHeader(lang),
|
|
||||||
...PARTICIPATION_MANUAL.map((section) => `${section.heading[lang]}\n\n${section.body[lang]}`),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
MANUAL_INTRO,
|
MANUAL_INTRO,
|
||||||
MANUAL_PATHS_HEADING,
|
MANUAL_PATHS_HEADING,
|
||||||
MANUAL_TITLE,
|
MANUAL_TITLE,
|
||||||
|
MANUAL_TOC_HEADING,
|
||||||
PARTICIPATION_MANUAL,
|
PARTICIPATION_MANUAL,
|
||||||
PARTICIPATION_PATHS,
|
PARTICIPATION_PATHS,
|
||||||
isManualLanguage,
|
isManualLanguage,
|
||||||
|
|
@ -83,6 +84,17 @@ function ManualLanding(): React.ReactElement {
|
||||||
|
|
||||||
useEffect(() => setLang(readStoredLanguage()), []);
|
useEffect(() => setLang(readStoredLanguage()), []);
|
||||||
|
|
||||||
|
// Enter jumps to the door only when body has focus; interactive elements keep their own Enter.
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent): void => {
|
||||||
|
if (e.key === "Enter" && e.target === document.body && !e.shiftKey) {
|
||||||
|
router.push(status === "authenticated" ? "/streams" : "/onboarding");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [status, router]);
|
||||||
|
|
||||||
function switchLanguage(next: ManualLanguage): void {
|
function switchLanguage(next: ManualLanguage): void {
|
||||||
setLang(next);
|
setLang(next);
|
||||||
try {
|
try {
|
||||||
|
|
@ -101,7 +113,7 @@ function ManualLanding(): React.ReactElement {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-dvh bg-bg text-text">
|
<div className="min-h-dvh bg-bg text-text">
|
||||||
<header className="border-b border-border-subtle bg-surface">
|
<header className="sticky top-0 z-10 border-b border-border-subtle bg-surface">
|
||||||
<div className="mx-auto flex max-w-2xl items-center gap-3 px-4 py-4">
|
<div className="mx-auto flex max-w-2xl items-center gap-3 px-4 py-4">
|
||||||
<Logo size={26} />
|
<Logo size={26} />
|
||||||
<span className="flex-1 truncate text-xs font-bold tracking-tight text-accent">Tactical Media Broadcast Hub</span>
|
<span className="flex-1 truncate text-xs font-bold tracking-tight text-accent">Tactical Media Broadcast Hub</span>
|
||||||
|
|
@ -163,10 +175,31 @@ function ManualLanding(): React.ReactElement {
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Compact table of contents, one line per section, anchor links. */}
|
||||||
|
<nav aria-labelledby="toc-heading" className="mb-10 rounded-lg border border-border-subtle bg-surface p-4">
|
||||||
|
<h2 id="toc-heading" className="mb-2 text-xs font-bold uppercase tracking-wider text-text-faint">
|
||||||
|
{t(MANUAL_TOC_HEADING)}
|
||||||
|
</h2>
|
||||||
|
<ol className="grid gap-1.5">
|
||||||
|
{PARTICIPATION_MANUAL.map((section, index) => (
|
||||||
|
<li key={section.id} className="flex items-baseline gap-2.5 text-sm">
|
||||||
|
<span className="font-mono text-xs font-bold text-text-faint">{String(index + 1).padStart(2, "0")}</span>
|
||||||
|
<a
|
||||||
|
href={`#section-${section.id}`}
|
||||||
|
className="text-accent underline-offset-2 hover:underline"
|
||||||
|
onClick={jumpToSection}
|
||||||
|
>
|
||||||
|
{t(section.heading)}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
{/* The manual itself. */}
|
{/* The manual itself. */}
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-8">
|
||||||
{PARTICIPATION_MANUAL.map((section, index) => (
|
{PARTICIPATION_MANUAL.map((section, index) => (
|
||||||
<section key={section.id} aria-labelledby={`section-${section.id}`}>
|
<section key={section.id} aria-labelledby={`section-${section.id}`} className="scroll-mt-20">
|
||||||
<h2 id={`section-${section.id}`} className="mb-2 flex items-baseline gap-2.5 text-base font-bold">
|
<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>
|
<span className="font-mono text-xs font-bold text-accent">{String(index + 1).padStart(2, "0")}</span>
|
||||||
{t(section.heading)}
|
{t(section.heading)}
|
||||||
|
|
@ -188,3 +221,19 @@ function ManualLanding(): React.ReactElement {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smooth-scroll to the target section. The anchor itself works without
|
||||||
|
* JavaScript; this only makes the jump gentle and keeps the sticky header from
|
||||||
|
* covering the heading (paired with scroll-mt-20 above).
|
||||||
|
*/
|
||||||
|
function jumpToSection(event: React.MouseEvent<HTMLAnchorElement>): void {
|
||||||
|
const href = event.currentTarget.getAttribute("href");
|
||||||
|
if (href === null || !href.startsWith("#")) return;
|
||||||
|
const target = document.getElementById(href.slice(1));
|
||||||
|
if (target === null) return;
|
||||||
|
event.preventDefault();
|
||||||
|
target.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
// Reflect the jump in the URL so the link is shareable.
|
||||||
|
window.history.replaceState(null, "", href);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
MANUAL_TITLE,
|
MANUAL_TITLE,
|
||||||
MANUAL_INTRO,
|
MANUAL_INTRO,
|
||||||
MANUAL_PATHS_HEADING,
|
MANUAL_PATHS_HEADING,
|
||||||
|
MANUAL_TOC_HEADING,
|
||||||
PARTICIPATION_MANUAL,
|
PARTICIPATION_MANUAL,
|
||||||
PARTICIPATION_PATHS,
|
PARTICIPATION_PATHS,
|
||||||
DEFAULT_MANUAL_LANGUAGE,
|
DEFAULT_MANUAL_LANGUAGE,
|
||||||
|
|
@ -13,6 +14,7 @@ import {
|
||||||
onboardingHeader,
|
onboardingHeader,
|
||||||
onboardingMessages,
|
onboardingMessages,
|
||||||
onboardingLanguageOrder,
|
onboardingLanguageOrder,
|
||||||
|
manualToc,
|
||||||
type ManualLanguage,
|
type ManualLanguage,
|
||||||
} from "@tactical-media-broadcast-hub/core";
|
} from "@tactical-media-broadcast-hub/core";
|
||||||
|
|
||||||
|
|
@ -22,10 +24,24 @@ import {
|
||||||
* Matrix onboarding bot, so these assertions protect BOTH surfaces at once.
|
* Matrix onboarding bot, so these assertions protect BOTH surfaces at once.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
test("the manual covers the four sections the design mandates", () => {
|
/** The manual outline, in reading order: what a collective gets → how it decides → solo → safety → help. */
|
||||||
|
const SECTION_IDS = [
|
||||||
|
"what-this-is",
|
||||||
|
"library",
|
||||||
|
"rooms",
|
||||||
|
"accounts",
|
||||||
|
"live",
|
||||||
|
"decisions",
|
||||||
|
"diffusion",
|
||||||
|
"solo",
|
||||||
|
"safety",
|
||||||
|
"help",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
test("the manual covers the sections the design mandates, in reading order", () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
PARTICIPATION_MANUAL.map((section) => section.id),
|
PARTICIPATION_MANUAL.map((section) => section.id),
|
||||||
["what-this-is", "rooms", "safety", "help"],
|
[...SECTION_IDS],
|
||||||
);
|
);
|
||||||
for (const section of PARTICIPATION_MANUAL) {
|
for (const section of PARTICIPATION_MANUAL) {
|
||||||
for (const lang of MANUAL_LANGUAGES) {
|
for (const lang of MANUAL_LANGUAGES) {
|
||||||
|
|
@ -50,23 +66,82 @@ test("the three participation paths are exactly join, start and solo", () => {
|
||||||
test("the safety section states the non-negotiables in both languages", () => {
|
test("the safety section states the non-negotiables in both languages", () => {
|
||||||
const safety = PARTICIPATION_MANUAL.find((section) => section.id === "safety")!;
|
const safety = PARTICIPATION_MANUAL.find((section) => section.id === "safety")!;
|
||||||
for (const [lang, needles] of [
|
for (const [lang, needles] of [
|
||||||
["es", ["cifrado de extremo a extremo", "borradones", "invitación", "cifrado"]],
|
["es", ["cifrado de extremo a extremo", "borradones", "invitación", "cifrado", "contribuciones"]],
|
||||||
["en", ["end-to-end encryption", "redaction", "invitation", "ciphertext"]],
|
["en", ["end-to-end encryption", "redaction", "invitation", "ciphertext", "contributions"]],
|
||||||
] as const) {
|
] as const) {
|
||||||
for (const needle of needles) assert.ok(safety.body[lang].toLowerCase().includes(needle), `${lang}: ${needle}`);
|
for (const needle of needles) assert.ok(safety.body[lang].toLowerCase().includes(needle), `${lang}: ${needle}`);
|
||||||
}
|
}
|
||||||
|
// The default contribution policy is stated plainly, not buried.
|
||||||
|
assert.ok(safety.body.es.includes("borrar tu cuenta no borra tus contribuciones"));
|
||||||
|
assert.ok(safety.body.en.includes("deleting your account does not delete your contributions"));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the rooms section names all three room types and their privacy stance", () => {
|
test("the rooms section names all three room types and their privacy stance", () => {
|
||||||
const rooms = PARTICIPATION_MANUAL.find((section) => section.id === "rooms")!;
|
const rooms = PARTICIPATION_MANUAL.find((section) => section.id === "rooms")!;
|
||||||
for (const [lang, needles] of [
|
for (const [lang, needles] of [
|
||||||
["es", ["abierta", "cerrada", "acogida", "nunca está cifrada", "nace cifrada"]],
|
["es", ["abierta", "cerrada", "acogida", "nunca está cifrada", "nace cifrada", "telegram"]],
|
||||||
["en", ["open room", "closed room", "onboarding room", "never end-to-end encrypted", "born encrypted"]],
|
["en", ["open room", "closed room", "onboarding room", "never end-to-end encrypted", "born encrypted", "telegram"]],
|
||||||
] as const) {
|
] as const) {
|
||||||
for (const needle of needles) assert.ok(rooms.body[lang].toLowerCase().includes(needle), `${lang}: ${needle}`);
|
for (const needle of needles) assert.ok(rooms.body[lang].toLowerCase().includes(needle), `${lang}: ${needle}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the library section explains folders, auto-archiving and reorganising without dead links", () => {
|
||||||
|
const library = PARTICIPATION_MANUAL.find((section) => section.id === "library")!;
|
||||||
|
for (const [lang, needles] of [
|
||||||
|
["es", ["carpeta", "archivados", "renombrar", "sin romper enlaces"]],
|
||||||
|
["en", ["folder", "archived", "renamed", "without breaking links"]],
|
||||||
|
] as const) {
|
||||||
|
for (const needle of needles) assert.ok(library.body[lang].toLowerCase().includes(needle), `${lang}: ${needle}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the accounts section names the networks and the approval-before-publish path", () => {
|
||||||
|
const accounts = PARTICIPATION_MANUAL.find((section) => section.id === "accounts")!;
|
||||||
|
for (const [lang, needles] of [
|
||||||
|
["es", ["Mastodon", "Bluesky", "Telegram", "PeerTube", "Facebook", "Instagram", "Twitch", "Discord", "aprobación"]],
|
||||||
|
["en", ["Mastodon", "Bluesky", "Telegram", "PeerTube", "Facebook", "Instagram", "Twitch", "Discord", "approval"]],
|
||||||
|
] as const) {
|
||||||
|
for (const needle of needles) assert.ok(accounts.body[lang].includes(needle), `${lang}: ${needle}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the live section covers switching, overlays, auto-director and distributed transcoding", () => {
|
||||||
|
const live = PARTICIPATION_MANUAL.find((section) => section.id === "live")!;
|
||||||
|
for (const [lang, needles] of [
|
||||||
|
["es", ["fuentes", "rótulos", "prioridades", "nodos de trabajo"]],
|
||||||
|
["en", ["sources", "overlays", "priorities", "render workers"]],
|
||||||
|
] as const) {
|
||||||
|
for (const needle of needles) assert.ok(live.body[lang].toLowerCase().includes(needle), `${lang}: ${needle}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the decisions section states the gate, the reset-on-edit rule and the verifiable record", () => {
|
||||||
|
const decisions = PARTICIPATION_MANUAL.find((section) => section.id === "decisions")!;
|
||||||
|
for (const [lang, needles] of [
|
||||||
|
["es", ["aprobación", "votación", "encadenado"]],
|
||||||
|
["en", ["approvals", "vote", "chained"]],
|
||||||
|
] as const) {
|
||||||
|
for (const needle of needles) assert.ok(decisions.body[lang].toLowerCase().includes(needle), `${lang}: ${needle}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the diffusion section says subcollectives push UP and the parent releases", () => {
|
||||||
|
const diffusion = PARTICIPATION_MANUAL.find((section) => section.id === "diffusion")!;
|
||||||
|
assert.ok(diffusion.body.es.toLowerCase().includes("hacia arriba"));
|
||||||
|
assert.ok(diffusion.body.en.toLowerCase().includes("upward"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the solo section covers personal storage, video access and joining later by invite", () => {
|
||||||
|
const solo = PARTICIPATION_MANUAL.find((section) => section.id === "solo")!;
|
||||||
|
for (const [lang, needles] of [
|
||||||
|
["es", ["archivos", "vídeo", "invitación"]],
|
||||||
|
["en", ["files", "video", "invite"]],
|
||||||
|
] as const) {
|
||||||
|
for (const needle of needles) assert.ok(solo.body[lang].toLowerCase().includes(needle), `${lang}: ${needle}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("Spanish is the default and first language everywhere", () => {
|
test("Spanish is the default and first language everywhere", () => {
|
||||||
assert.equal(DEFAULT_MANUAL_LANGUAGE, "es");
|
assert.equal(DEFAULT_MANUAL_LANGUAGE, "es");
|
||||||
assert.deepEqual(MANUAL_LANGUAGE_ORDER, ["es", "en"]);
|
assert.deepEqual(MANUAL_LANGUAGE_ORDER, ["es", "en"]);
|
||||||
|
|
@ -76,6 +151,7 @@ test("Spanish is the default and first language everywhere", () => {
|
||||||
assert.ok(MANUAL_TITLE.es && MANUAL_TITLE.en);
|
assert.ok(MANUAL_TITLE.es && MANUAL_TITLE.en);
|
||||||
assert.ok(MANUAL_INTRO.es && MANUAL_INTRO.en);
|
assert.ok(MANUAL_INTRO.es && MANUAL_INTRO.en);
|
||||||
assert.ok(MANUAL_PATHS_HEADING.es && MANUAL_PATHS_HEADING.en);
|
assert.ok(MANUAL_PATHS_HEADING.es && MANUAL_PATHS_HEADING.en);
|
||||||
|
assert.ok(MANUAL_TOC_HEADING.es && MANUAL_TOC_HEADING.en);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("isManualLanguage accepts exactly es and en", () => {
|
test("isManualLanguage accepts exactly es and en", () => {
|
||||||
|
|
@ -86,11 +162,26 @@ test("isManualLanguage accepts exactly es and en", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("onboardingMessages is one header plus one message per section, in section order", () => {
|
test("manualToc is one numbered line per section, in section order", () => {
|
||||||
|
for (const lang of MANUAL_LANGUAGES) {
|
||||||
|
const lines = manualToc(lang).split("\n");
|
||||||
|
assert.equal(lines.length, PARTICIPATION_MANUAL.length);
|
||||||
|
for (const [index, section] of PARTICIPATION_MANUAL.entries()) {
|
||||||
|
assert.ok(lines[index]!.startsWith(`${index + 1}. `), `${lang} line ${index} numbered`);
|
||||||
|
assert.ok(lines[index]!.includes(section.heading[lang]), `${lang} line ${index} carries the heading`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("onboardingMessages is header+contents first, then one message per section, in section order", () => {
|
||||||
for (const lang of MANUAL_LANGUAGES) {
|
for (const lang of MANUAL_LANGUAGES) {
|
||||||
const messages = onboardingMessages(lang);
|
const messages = onboardingMessages(lang);
|
||||||
assert.equal(messages.length, PARTICIPATION_MANUAL.length + 1);
|
assert.equal(messages.length, PARTICIPATION_MANUAL.length + 1);
|
||||||
assert.ok(messages[0]!.startsWith(onboardingHeader(lang)));
|
assert.ok(messages[0]!.startsWith(onboardingHeader(lang)), `${lang} header first`);
|
||||||
|
// The contents list rides in the first message so a long manual stays navigable.
|
||||||
|
for (const section of PARTICIPATION_MANUAL) {
|
||||||
|
assert.ok(messages[0]!.includes(section.heading[lang]), `${lang} contents lists ${section.id}`);
|
||||||
|
}
|
||||||
for (const [index, section] of PARTICIPATION_MANUAL.entries()) {
|
for (const [index, section] of PARTICIPATION_MANUAL.entries()) {
|
||||||
const message = messages[index + 1];
|
const message = messages[index + 1];
|
||||||
assert.ok(message !== undefined, `${lang} section ${index} exists`);
|
assert.ok(message !== undefined, `${lang} section ${index} exists`);
|
||||||
|
|
@ -126,7 +217,7 @@ test("language type is closed: assigning an unknown value is a compile-time erro
|
||||||
});
|
});
|
||||||
|
|
||||||
test("manual copy stays plain-language: no dev jargon in user-facing sections", () => {
|
test("manual copy stays plain-language: no dev jargon in user-facing sections", () => {
|
||||||
const jargon = ["api", "http", "rls", "endpoint", "json", "sdk", "federate=true", "megolm"];
|
const jargon = ["api", "http", "rls", "endpoint", "json", "sdk", "federate=true", "megolm", "ocis", "webdav", "r2"];
|
||||||
for (const section of PARTICIPATION_MANUAL) {
|
for (const section of PARTICIPATION_MANUAL) {
|
||||||
for (const lang of MANUAL_LANGUAGES) {
|
for (const lang of MANUAL_LANGUAGES) {
|
||||||
const body = section.body[lang].toLowerCase();
|
const body = section.body[lang].toLowerCase();
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,9 @@ services:
|
||||||
FILES_OCID_USER: ${FILES_OCID_USER:?}
|
FILES_OCID_USER: ${FILES_OCID_USER:?}
|
||||||
FILES_OCID_PASSWORD: ${FILES_OCID_PASSWORD:?}
|
FILES_OCID_PASSWORD: ${FILES_OCID_PASSWORD:?}
|
||||||
FILES_PUBLIC_BASE: ${FILES_PUBLIC_BASE:-https://files.occupymedia.cc}
|
FILES_PUBLIC_BASE: ${FILES_PUBLIC_BASE:-https://files.occupymedia.cc}
|
||||||
|
ARCHIVE_PROVISIONER_HOMESERVER_URL: ${ARCHIVE_PROVISIONER_HOMESERVER_URL:-}
|
||||||
|
ARCHIVE_PROVISIONER_USER: ${ARCHIVE_PROVISIONER_USER:-}
|
||||||
|
ARCHIVE_PROVISIONER_PASSWORD: ${ARCHIVE_PROVISIONER_PASSWORD:-}
|
||||||
ports: ["3000:3000"]
|
ports: ["3000:3000"]
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres: { condition: service_healthy }
|
postgres: { condition: service_healthy }
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,10 @@
|
||||||
* renders it and the provisioner bot posts the same text into onboarding rooms,
|
* renders it and the provisioner bot posts the same text into onboarding rooms,
|
||||||
* so the manual a founder reads in the browser is byte-identical to the one the
|
* so the manual a founder reads in the browser is byte-identical to the one the
|
||||||
* guide bot posts in Matrix.
|
* guide bot posts in Matrix.
|
||||||
|
*
|
||||||
|
* Section order mirrors how the platform is used: what a collective gets
|
||||||
|
* (library → rooms → accounts → live), how it decides (gates → diffusion), the
|
||||||
|
* solo path, then safety and help.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Manual languages. `es` is the default everywhere (Spanish-first product). */
|
/** Manual languages. `es` is the default everywhere (Spanish-first product). */
|
||||||
|
|
@ -55,8 +59,14 @@ export const MANUAL_TITLE: LocalizedManualText = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MANUAL_INTRO: LocalizedManualText = {
|
export const MANUAL_INTRO: LocalizedManualText = {
|
||||||
es: "Este es el punto de entrada público de un colectivo de medios. Antes de entrar, lee esto: explica qué es la plataforma, los tres caminos para participar, cómo funcionan las salas y qué protege el modelo de seguridad. No hace falta cuenta para leer esta página.",
|
es: "Este es el punto de entrada público de un colectivo de medios. Antes de entrar, lee esto: explica qué es la plataforma, los tres caminos para participar, qué recibe un colectivo (biblioteca de archivos, salas, cuentas conectadas, directo), cómo se decide en común y qué protege el modelo de seguridad. No hace falta cuenta para leer esta página.",
|
||||||
en: "This is the public entry point of a media collective. Read this first: it explains what the platform is, the three ways to take part, how the rooms work and what the safety model protects. No account is needed to read this page.",
|
en: "This is the public entry point of a media collective. Read this first: it explains what the platform is, the three ways to take part, what a collective gets (a files library, rooms, connected accounts, live broadcasting), how decisions are made together, and what the safety model protects. No account is needed to read this page.",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Heading of the compact table of contents (landing page + bot-posted guide). */
|
||||||
|
export const MANUAL_TOC_HEADING: LocalizedManualText = {
|
||||||
|
es: "Contenido",
|
||||||
|
en: "Contents",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PARTICIPATION_MANUAL: readonly ManualSection[] = [
|
export const PARTICIPATION_MANUAL: readonly ManualSection[] = [
|
||||||
|
|
@ -64,32 +74,80 @@ export const PARTICIPATION_MANUAL: readonly ManualSection[] = [
|
||||||
id: "what-this-is",
|
id: "what-this-is",
|
||||||
heading: { es: "Qué es esta plataforma", en: "What this platform is" },
|
heading: { es: "Qué es esta plataforma", en: "What this platform is" },
|
||||||
body: {
|
body: {
|
||||||
es: "Un centro de operaciones para colectivos que publican: redactar, revisar, programar y publicar en varias redes a la vez, mantener un archivo que sobrevive a los borradones y coordinar salas en vivo.\n\nCada colectivo tiene su propio árbol de grupos (colectivo y subcolectivos), su carpeta de archivos y su conjunto de salas. Todo funciona en el navegador; no hay nada que instalar.",
|
es: "Un centro de operaciones para colectivos que publican: redactar, revisar, programar y sacar contenido en varias redes a la vez, mantener una biblioteca de archivos que sobrevive a los borradones y coordinar emisiones en directo.\n\nCada colectivo tiene su propio árbol de grupos —el colectivo y sus subcolectivos—, su propia biblioteca de archivos y su propio conjunto de salas. Todo funciona en el navegador; no hay nada que instalar.\n\nEste manual sigue el orden en que se usa la plataforma: primero lo que recibe un colectivo (biblioteca, salas, cuentas, directo), después cómo se decide en común y cómo el contenido sube de los subcolectivos al colectivo, después el camino de quien va solo y, al final, el modelo de seguridad y dónde pedir ayuda.",
|
||||||
en: "An operations room for collectives that publish: write, review, schedule and post to several networks at once, keep an archive that survives takedowns, and coordinate live rooms.\n\nEvery collective has its own group tree (the collective and its subcollectives), its own files folder and its own room set. Everything runs in the browser; there is nothing to install.",
|
en: "An operations room for collectives that publish: write, review, schedule and post to several networks at once, keep a files library that survives takedowns, and coordinate live broadcasts.\n\nEvery collective has its own group tree — the collective and its subcollectives —, its own files library and its own room set. Everything runs in the browser; there is nothing to install.\n\nThis manual follows the order the platform is used in: first what a collective gets (library, rooms, accounts, live), then how decisions are made together and how content moves up from subcollectives to the collective, then the path of someone working alone, and at the end the safety model and where to get help.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "library",
|
||||||
|
heading: { es: "La biblioteca de archivos del colectivo", en: "The collective's files library" },
|
||||||
|
body: {
|
||||||
|
es: "Cada grupo —el colectivo y cada subcolectivo— tiene su carpeta propia en la biblioteca compartida. La biblioteca es del colectivo, no de cada persona: quien entra en un grupo encuentra el material de ese grupo en su sitio, y el material no desaparece cuando alguien se va.\n\n- Archivado automático: los archivos que se mandan por las salas —una foto en la sala abierta, un clip en una sala de trabajo— quedan archivados en la carpeta del grupo, con quién lo mandó, cuándo, de qué tipo es y su tamaño comprobado. El mismo vídeo compartido cinco veces se guarda una sola vez.\n- Organizar más tarde: los archivos se pueden mover de carpeta, renombrar y reordenar cuando se quiera sin romper enlaces. Los enlaces apuntan al archivo, no a la carpeta en la que está en ese momento, así que reorganizar la biblioteca no deja enlaces muertos por detrás.\n- Compartir bajo control: un enlace de biblioteca pide ser miembro del grupo. Si un archivo tiene que salir fuera, se crea un enlace público con caducidad, y se puede revocar; revocar un enlace no borra el archivo.\n- El límite del cifrado: en una sala cifrada de extremo a extremo el servidor no puede leer lo que se envía, así que un archivo de ahí entra en la biblioteca cuando alguien del colectivo lo guarda. A partir de ese momento se organiza como cualquier otro archivo.",
|
||||||
|
en: "Every group — the collective and each subcollective — has its own folder in the shared library. The library belongs to the collective, not to each person: whoever joins a group finds that group's material in its place, and material does not disappear when someone leaves.\n\n- Automatic archiving: files sent through the rooms — a photo in the open room, a clip in a working room — are archived into the group's folder, with who sent it, when, what type it is and its verified size. The same video shared five times is stored once.\n- Organising later: files can be moved between folders, renamed and reordered whenever needed without breaking links. Links point to the file, not to the folder it happens to sit in, so reorganising the library leaves no dead links behind.\n- Sharing under control: a library link requires membership of the group. If a file has to go outside, a public link is created with an expiry, and it can be revoked; revoking a link does not delete the file.\n- The limit of encryption: in an end-to-end encrypted room the server cannot read what is sent, so a file from there enters the library when a member of the collective saves it. From that moment on it is organised like any other file.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "rooms",
|
id: "rooms",
|
||||||
heading: { es: "Las salas: abierta, cerrada y de acogida", en: "The rooms: open, closed and onboarding" },
|
heading: { es: "Las salas: abierta, cerrada y de acogida", en: "The rooms: open, closed and onboarding" },
|
||||||
body: {
|
body: {
|
||||||
es: "- Sala abierta: pública, listada y federada. Cualquier persona desde cualquier servidor Matrix puede entrar a hablar con el colectivo sin pedirnos cuenta. Nunca está cifrada de extremo a extremo: el bot de archivo debe poder leerla para indexar lo que se comparte, así que todo lo que escribas ahí es público.\n- Sala cerrada (producción): solo por invitación y solo en este servidor. Cifrarla de extremo a extremo no es una opción que se activa: la sala nace cifrada y el servidor guarda texto cifrado. La coordinación interna va aquí.\n- Sala de acogida (onboarding): solo por invitación y desechable. El bot guía publica este manual en español y en inglés.\n\nEtiqueta mínima: en la sala abierta habla en nombre propio; la coordinación interna, los datos de personas y las decisiones van en la sala cerrada.",
|
es: "- Sala abierta: pública, listada y federada. Cualquier persona desde cualquier servidor de Matrix puede entrar a hablar con el colectivo sin pedirnos cuenta, y el colectivo puede conectarla además a un grupo de Telegram para que también se participe desde allí sin cuenta de Matrix. Nunca está cifrada de extremo a extremo: el bot de archivo tiene que poder leerla para indexar lo que se comparte, así que todo lo que se escriba ahí es público.\n- Sala cerrada (producción): solo por invitación y solo en este servidor. Cifrarla no es una opción que se active: la sala nace cifrada y el servidor guarda texto cifrado. Aquí va la coordinación interna —decisiones, datos de personas, borradores— y el trabajo del día a día.\n- Sala de acogida (onboarding): solo por invitación y desechable. El bot guía publica este manual en español y en inglés; es la primera sala a la que entras y donde está la ayuda básica.\n\nEtiqueta mínima: en la sala abierta se habla en nombre propio y se presupone que es público. La coordinación interna, los datos de personas y las decisiones van en la sala cerrada. Si algo tiene que llegar a gente de fuera, se publica en condiciones; no se copia a la sala abierta.",
|
||||||
en: "- Open room: public, listed and federated. Anyone from any Matrix server can join and talk to the collective without asking us for an account. It is never end-to-end encrypted: the archive bot has to be able to read it to index what is shared, so everything you write there is public.\n- Closed room (production): invite-only and on this server only. End-to-end encryption is not a switch that gets flipped: the room is born encrypted and the server stores ciphertext. Internal coordination belongs here.\n- Onboarding room: invite-only and disposable. The guide bot posts this manual in Spanish and in English.\n\nMinimum etiquette: in the open room speak in your own name; internal coordination, people's data and decisions belong in the closed room.",
|
en: "- Open room: public, listed and federated. Anyone from any Matrix server can join and talk to the collective without asking us for an account, and the collective can also bridge it to a Telegram group so people can take part from there with no Matrix account at all. It is never end-to-end encrypted: the archive bot has to be able to read it to index what is shared, so everything written there is public.\n- Closed room (production): invite-only and on this server only. Encryption is not a switch that gets flipped: the room is born encrypted and the server stores ciphertext. Internal coordination belongs here — decisions, people's data, drafts — and the day-to-day work.\n- Onboarding room: invite-only and disposable. The guide bot posts this manual in Spanish and in English; it is the first room you enter and where the basic help lives.\n\nMinimum etiquette: in the open room speak in your own name and assume it is public. Internal coordination, people's data and decisions belong in the closed room. If something has to reach people outside, it gets published properly; it does not get copied into the open room.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "accounts",
|
||||||
|
heading: { es: "Cuentas y redes conectadas", en: "Accounts and networks" },
|
||||||
|
body: {
|
||||||
|
es: "El colectivo conecta sus propias cuentas. Las credenciales son del colectivo, no de una persona, y se guardan cifradas: la base de datos nunca guarda una clave en claro, y quien se va del colectivo no se lleva el acceso.\n\nSe pueden conectar cuentas de texto, de vídeo y de comunidad: Mastodon, Bluesky, Telegram, PeerTube, Facebook, Instagram, Threads, LinkedIn, Reddit, Twitch, Discord, Slack y otras alternativas a X. Cada cuenta se conecta una vez y el colectivo publica a través de ella; nadie tiene que entregar su contraseña personal a nadie.\n\nNada se publica por el mero hecho de escribirlo. El recorrido es siempre el mismo: alguien redacta un borrador, el borrador pasa por la aprobación del colectivo y solo entonces se publica o se programa. Una misma pieza puede ir a varias redes a la vez, cada una con su formato, y lo que se aprobó es lo que sale. Los borradores se pueden programar con antelación y el calendario aguanta los cambios de hora.",
|
||||||
|
en: "The collective connects its own accounts. The credentials belong to the collective, not to a person, and they are stored encrypted: the database never holds a key in the clear, and someone leaving the collective does not take the access with them.\n\nText, video and community accounts can be connected: Mastodon, Bluesky, Telegram, PeerTube, Facebook, Instagram, Threads, LinkedIn, Reddit, Twitch, Discord, Slack and other alternatives to X. Each account is connected once and the collective publishes through it; nobody has to hand their personal password to anyone.\n\nNothing is published just because it was written. The path is always the same: someone drafts it, the draft goes through the collective's approval, and only then it is published or scheduled. One piece can go to several networks at once, each with its own format, and what was approved is what goes out. Drafts can be scheduled ahead, and the calendar survives clock changes.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "live",
|
||||||
|
heading: { es: "El directo: sala de control y transcodificación", en: "Live: control room and transcoding" },
|
||||||
|
body: {
|
||||||
|
es: "La sala de control funciona en el navegador y sirve para mezclar varias fuentes en directo: una persona transmitiendo desde la calle con el móvil, una cámara fija, una captura de pantalla, una invitada remota.\n\n- Conmutación de fuentes: todas las fuentes se ven a la vez en una cuadrícula; se elige qué sale en antena y qué queda preparado, y el cambio puede ser un corte seco o una transición. El audio se mezcla por separado: se puede llevar el sonido de una fuente con la imagen de otra.\n- Rótulos: nombres y cargos, rótulos en movimiento, logotipo y reloj se editan en directo sin parar la emisión.\n- Director automático: se fijan prioridades y el programa conmuta solo: si una fuente se cae, entra la siguiente; si no queda ninguna, entra una imagen de respaldo o la lista programada, de modo que el canal no se queda negro.\n- Salida: el programa sale al canal de vídeo del colectivo (PeerTube), y cuando nadie está en directo el canal sigue emitiendo la lista programada.\n- Transcodificación distribuida: convertir vídeo a todos los formatos es lo más caro de todo el proceso. Los portátiles y las tarjetas gráficas del propio colectivo se unen como nodos de trabajo por una red privada, y se unen más cuando hay más carga. No hace falta un centro de datos: el trabajo lo hacen las máquinas del propio colectivo.",
|
||||||
|
en: "The control room runs in the browser and mixes several live sources: someone streaming from the street on a phone, a fixed camera, a screen capture, a remote guest.\n\n- Switching sources: every source shows at once in a grid; you pick what is on air and what is on standby, and the change can be a hard cut or a transition. Audio is mixed separately: one source's sound can go out with another source's picture.\n- Overlays: names and roles, scrolling captions, the logo and a clock are edited live without stopping the broadcast.\n- Auto-director: priorities are set once and the programme switches by itself: if a source drops, the next one comes in; if none is left, a fallback slate or the scheduled playlist comes in, so the channel never goes dark.\n- Output: the programme goes out to the collective's video channel (PeerTube), and when nobody is live the channel keeps playing the scheduled playlist.\n- Distributed transcoding: converting video to every format is the most expensive part of the whole process. The collective's own laptops and graphics cards join as render workers over a private network, and more join as load rises. No data centre is needed: the work is done by the collective's own machines.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "decisions",
|
||||||
|
heading: { es: "Decisiones del colectivo", en: "Collective decisions" },
|
||||||
|
body: {
|
||||||
|
es: "Nada sale sin el visto bueno del colectivo. Esa es la regla estructural, y no es una costumbre: es una puerta que está en el camino de toda publicación.\n\n- Puertas de aprobación: cada borrador necesita las aprobaciones que el colectivo haya fijado. Si el texto se toca después de enviado, las aprobaciones anteriores dejan de valer y se empieza de nuevo, porque lo que se aprueba es una versión concreta, no «más o menos eso». Con umbral de dos o más, nadie aprueba su propio texto.\n- Votaciones y encuestas: se puede abrir una votación en el propio chat —«¿publicamos esto?»— con las reglas fijadas al abrirla: cuántos sí hacen falta, entre quiénes, con qué plazo, con veto o sin él. El recuento va en vivo y las reglas no cambian a mitad de la votación. Las papeletas que no cuentan se guardan pero no se cuentan; un veto tumba la votación.\n- Registro verificable: cada decisión —quién aprobó qué, cuándo y sobre qué texto— queda en un registro encadenado en el que cualquier alteración posterior se nota. No es una promesa de transparencia: es una propiedad del registro.\n- Lo que sale es lo que se aprobó: el sistema publica exactamente la versión que recibió las aprobaciones, no una versión posterior.",
|
||||||
|
en: "Nothing goes out without the collective's sign-off. That is the structural rule, and it is not a custom: it is a gate standing in the path of every publication.\n\n- Approval gates: each draft needs the number of approvals the collective has set. If the text is touched after submission, earlier approvals stop counting and the count starts again, because what is approved is a concrete version, not \"roughly that\". With a threshold of two or more, nobody approves their own text.\n- Polls and votes: a vote can be opened in the chat itself — \"do we publish this?\" — with the rules fixed when it opens: how many yes votes are needed, from whom, on what deadline, with or without veto. The tally runs live and the rules do not change mid-vote. Ineligible ballots are stored but never counted; a veto brings the vote down.\n- A verifiable record: every decision — who approved what, when, and on which text — lands in a chained log where any later alteration shows. That is not a promise of transparency: it is a property of the log.\n- What goes out is what was approved: the system publishes exactly the version that received the approvals, not a later one.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "diffusion",
|
||||||
|
heading: { es: "Subcolectivos y difusión hacia arriba", en: "Subcollectives and upward diffusion" },
|
||||||
|
body: {
|
||||||
|
es: "Un colectivo puede tener subcolectivos: la comisión de prensa, el equipo de streaming, el grupo de barrio. Cada uno tiene su carpeta, sus salas y sus propias reglas de publicación, y ninguno publica en nombre del colectivo entero por su cuenta.\n\nLa difusión va hacia arriba. Un subcolectivo entrega una pieza a la cola del colectivo padre; quien la recibe ve de dónde viene, y la publican las personas que aprueban en el padre. Es la misma puerta de aprobación de siempre, con la etiqueta de origen a la vista.\n\nLos árboles se componen: un subcolectivo puede tener a su vez subcolectivos y entregar hacia arriba lo suyo y también lo que le llegó de abajo. Quien entrega no pierde la autoría: la pieza llega con su origen puesto.",
|
||||||
|
en: "A collective can have subcollectives: the press crew, the streaming team, the neighbourhood group. Each has its folder, its rooms and its own publishing rules, and none of them publishes on behalf of the whole collective on its own.\n\nDiffusion goes upward. A subcollective hands a piece to the parent collective's queue; whoever receives it sees where it came from, and the people who approve at the parent level are the ones who publish it. It is the same approval gate as always, with the origin label in plain sight.\n\nTrees compose: a subcollective can in turn have subcollectives, and push up its own material as well as what reached it from below. Whoever hands something up keeps the credit: the piece arrives with its origin on it.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "solo",
|
||||||
|
heading: { es: "Trabajar sin colectivo", en: "Working without a collective" },
|
||||||
|
body: {
|
||||||
|
es: "No hace falta un colectivo para tener cuenta. Una cuenta sin colectivo tiene su propio espacio de archivos —el mismo almacenamiento que usan los colectivos— y acceso al vídeo: subir, gestionar y emitir en un canal propio.\n\nEs un punto de partida, no un destino: puedes entrar más adelante en un colectivo con una invitación, o crear el tuyo cuando lo tengas claro. Mientras tanto, lo que publicas y lo que decides lo firmas tú: las puertas de aprobación están explicadas más arriba, y con una sola persona el umbral se ajusta a esa realidad.\n\nSi te invitan a un colectivo, su sala de acogida trae este manual y el bot guía; si lo creas tú, las salas se crean solas y quedas como fundador.",
|
||||||
|
en: "A collective is not required to have an account. An account without a collective gets its own files space — the same storage the collectives use — and access to video: upload, manage and broadcast on a channel of your own.\n\nIt is a starting point, not a destination: you can join a collective later with an invite, or start your own when it is clear. In the meantime, what you publish and what you decide you sign yourself: the approval gates are explained above, and with a single person the threshold is set to match that reality.\n\nIf a collective invites you in, its onboarding room carries this manual and the guide bot; if you start one, the rooms are provisioned for you and you are its founder.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "safety",
|
id: "safety",
|
||||||
heading: { es: "Modelo de seguridad", en: "Safety model" },
|
heading: { es: "Modelo de seguridad", en: "Safety model" },
|
||||||
body: {
|
body: {
|
||||||
es: "- Cifrado de extremo a extremo por defecto en producción: cada sala cerrada se crea ya cifrada; no existe una versión sin cifrar de su contenido en el servidor.\n- Cuentas solo con invitación: el registro está cerrado por token. Quien participa desde fuera lo hace federado, desde su propio servidor, y nunca recibe credenciales nuestras.\n- Borradones: una redacción en Matrix se respeta en el índice y en los enlaces derivados; el material de una sala cerrada vive en los clientes de quienes son miembros. El contenido de una sala abierta era público por definición y se archiva como público.\n- Si el servidor se incauta, lo que se lleva de las salas cerradas es texto cifrado sin claves de lectura.",
|
es: "- Cifrado de extremo a extremo por defecto en producción: cada sala cerrada se crea ya cifrada; no existe una versión sin cifrar de su contenido en el servidor.\n- Cuentas solo con invitación: el registro está cerrado por token. Quien participa desde fuera lo hace federado, desde su propio servidor, y nunca recibe credenciales nuestras.\n- Política de contribuciones, dicha al entrar: la regla por defecto se dice sin rodeos — borrar tu cuenta no borra tus contribuciones. Lo que ya entregaste a un colectivo se queda en su biblioteca, con tu autoría, bajo las reglas de retención de ese colectivo. Cada colectivo puede fijar una política distinta (retirar la autoría al cabo de un tiempo, quitar lo que se pida) y esa política se muestra al unirse y en los ajustes.\n- Borradones: una redacción en Matrix se respeta en el índice y en los enlaces derivados, y el archivo que llegó con ese mensaje deja de servirse desde la biblioteca; si ese mismo archivo sostiene otro registro, no se borra por debajo. El material de una sala cerrada vive en los clientes de quienes son miembros. El contenido de una sala abierta era público por definición y se archiva como público.\n- Si el servidor se incauta, lo que se lleva de las salas cerradas es texto cifrado sin claves de lectura.",
|
||||||
en: "- End-to-end encryption by default in production: every closed room is created already encrypted; no unencrypted copy of its content exists on the server.\n- Accounts only by invitation: registration is token-gated. Outsiders take part federated, from their own server, and never receive credentials of ours.\n- Redaction: a Matrix redaction is honoured in the index and in any derived links; closed-room material lives in the clients of its members. Open-room content was public by definition and is archived as public.\n- If the server is seized, what leaves the closed rooms is ciphertext without reading keys.",
|
en: "- End-to-end encryption by default in production: every closed room is created already encrypted; no unencrypted copy of its content exists on the server.\n- Accounts only by invitation: registration is token-gated. Outsiders take part federated, from their own server, and never receive credentials of ours.\n- Contribution policy, stated at join: the default rule is said plainly — deleting your account does not delete your contributions. What you already handed to a collective stays in its library, attributed to you, under that collective's retention rules. A collective can set a different policy (drop attribution after a time, remove what is asked) and that policy is shown at join and in its settings.\n- Redaction: a Matrix redaction is honoured in the index and in any derived links, and the file that arrived with that message stops being served from the library; if that same file backs another record, it is not deleted underneath. Closed-room material lives in the clients of its members. Open-room content was public by definition and is archived as public.\n- If the server is seized, what leaves the closed rooms is ciphertext without reading keys.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "help",
|
id: "help",
|
||||||
heading: { es: "Cómo pedir ayuda", en: "How to get help" },
|
heading: { es: "Cómo pedir ayuda", en: "How to get help" },
|
||||||
body: {
|
body: {
|
||||||
es: "Tu sala de acogida es el primer sitio: ahí está este manual en español y en inglés, y el bot guía responde con lo básico.\n\nSi tu enlace de invitación caducó o se agotó, pide uno nuevo a quien lo creó; la caducidad es una medida de seguridad, no un fallo. Si algo urgente pasa en una sala cerrada, escribe en la sala: quien administra el colectivo ve los avisos.",
|
es: "Tu sala de acogida es el primer sitio: ahí está este manual en español y en inglés, y el bot guía responde con lo básico.\n\nSi tu enlace de invitación caducó o se agotó, pide uno nuevo a quien lo creó; la caducidad es una medida de seguridad, no un fallo. Si algo urgente pasa en una sala cerrada, escribe en la sala: quien administra el colectivo ve los avisos.\n\nPara lo demás —la política de contribuciones de tu colectivo, sus umbrales de aprobación, sus carpetas— mira los ajustes del colectivo: lo que está ahí es lo que se aplica.",
|
||||||
en: "Your onboarding room is the first place to look: this manual is there in Spanish and in English, and the guide bot answers with the basics.\n\nIf your invite link expired or ran out of uses, ask whoever created it for a fresh one; the expiry is a safety measure, not a fault. If something urgent happens in a closed room, write in the room: whoever administers the collective sees the notices.",
|
en: "Your onboarding room is the first place to look: this manual is there in Spanish and in English, and the guide bot answers with the basics.\n\nIf your invite link expired or ran out of uses, ask whoever created it for a fresh one; the expiry is a safety measure, not a fault. If something urgent happens in a closed room, write in the room: whoever administers the collective sees the notices.\n\nFor everything else — your collective's contribution policy, its approval thresholds, its folders — look at the collective's settings: what is there is what gets applied.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -100,8 +158,8 @@ export const PARTICIPATION_PATHS: readonly ParticipationPath[] = [
|
||||||
id: "join",
|
id: "join",
|
||||||
title: { es: "Únete a un colectivo", en: "Join a collective" },
|
title: { es: "Únete a un colectivo", en: "Join a collective" },
|
||||||
summary: {
|
summary: {
|
||||||
es: "Te han pasado un enlace de invitación. Ábrelo, crea tu cuenta si aún no tienes y entras directamente al colectivo.",
|
es: "Te han pasado un enlace de invitación. Ábrelo, crea tu cuenta si aún no tienes y entras directamente al colectivo: su biblioteca, sus salas y su sala de acogida con este manual.",
|
||||||
en: "Someone gave you an invite link. Open it, create your account if you do not have one yet, and you land inside the collective.",
|
en: "Someone gave you an invite link. Open it, create your account if you do not have one yet, and you land inside the collective: its library, its rooms and its onboarding room with this manual.",
|
||||||
},
|
},
|
||||||
action: { es: "Abrir mi invitación", en: "Open my invite" },
|
action: { es: "Abrir mi invitación", en: "Open my invite" },
|
||||||
},
|
},
|
||||||
|
|
@ -109,8 +167,8 @@ export const PARTICIPATION_PATHS: readonly ParticipationPath[] = [
|
||||||
id: "start",
|
id: "start",
|
||||||
title: { es: "Empieza un colectivo", en: "Start a collective" },
|
title: { es: "Empieza un colectivo", en: "Start a collective" },
|
||||||
summary: {
|
summary: {
|
||||||
es: "Con cuenta activa, creas el colectivo con su nombre y su idioma; las salas (abierta, cerrada y de acogida) se crean solas y quedas como fundador.",
|
es: "Con cuenta activa, creas el colectivo con su nombre y su idioma; las salas (abierta, cerrada y de acogida) se crean solas, la biblioteca queda lista y eres el fundador.",
|
||||||
en: "With an active account you create the collective with its name and language; the room set (open, closed and onboarding) is provisioned for you and you are its founder.",
|
en: "With an active account you create the collective with its name and language; the room set (open, closed and onboarding) is provisioned for you, the library is ready, and you are its founder.",
|
||||||
},
|
},
|
||||||
action: { es: "Crear un colectivo", en: "Create a collective" },
|
action: { es: "Crear un colectivo", en: "Create a collective" },
|
||||||
},
|
},
|
||||||
|
|
@ -118,8 +176,8 @@ export const PARTICIPATION_PATHS: readonly ParticipationPath[] = [
|
||||||
id: "solo",
|
id: "solo",
|
||||||
title: { es: "Trabaja por tu cuenta", en: "Work on your own" },
|
title: { es: "Trabaja por tu cuenta", en: "Work on your own" },
|
||||||
summary: {
|
summary: {
|
||||||
es: "Todavía no hay colectivo: entra o crea tu cuenta y empieza en tu propio espacio. Cuando te unas o crees un colectivo, su sala de acogida trae este manual y el bot guía.",
|
es: "Todavía no hay colectivo: entra o crea tu cuenta y empieza en tu propio espacio, con tus archivos y tu canal de vídeo. Cuando te inviten a un colectivo, entrarás con este manual en su sala de acogida.",
|
||||||
en: "No collective yet: sign in or create your account and start in your own workspace. When you join or start a collective, its onboarding room carries this manual and the guide bot.",
|
en: "No collective yet: sign in or create your account and start in your own workspace, with your files and your video channel. When a collective invites you, you join with this manual waiting in its onboarding room.",
|
||||||
},
|
},
|
||||||
action: { es: "Entrar o crear cuenta", en: "Sign in or create account" },
|
action: { es: "Entrar o crear cuenta", en: "Sign in or create account" },
|
||||||
},
|
},
|
||||||
|
|
@ -131,6 +189,16 @@ export const MANUAL_PATHS_HEADING: LocalizedManualText = {
|
||||||
en: "The three paths",
|
en: "The three paths",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact table of contents, one numbered line per section. The landing page
|
||||||
|
* renders its own anchor links from PARTICIPATION_MANUAL; this plain-text form
|
||||||
|
* is what the guide bot posts at the top of the onboarding manual, so the room
|
||||||
|
* copy of a long manual stays navigable too.
|
||||||
|
*/
|
||||||
|
export function manualToc(lang: ManualLanguage): string {
|
||||||
|
return PARTICIPATION_MANUAL.map((section, index) => `${index + 1}. ${section.heading[lang]}`).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
/** Short header the guide bot posts before each language's manual sequence. */
|
/** Short header the guide bot posts before each language's manual sequence. */
|
||||||
export function onboardingHeader(lang: ManualLanguage): string {
|
export function onboardingHeader(lang: ManualLanguage): string {
|
||||||
return lang === "es"
|
return lang === "es"
|
||||||
|
|
@ -139,13 +207,14 @@ export function onboardingHeader(lang: ManualLanguage): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The manual as the guide bot posts it into an onboarding room: one message per
|
* The manual as the guide bot posts it into an onboarding room: the language
|
||||||
* section, plain text (no HTML), headings inline. The provisioner posts the
|
* header and the table of contents first, then one message per section, plain
|
||||||
* primary language's sequence first and the other language second.
|
* text (no HTML), headings inline. The provisioner posts the primary
|
||||||
|
* language's sequence first and the other language second.
|
||||||
*/
|
*/
|
||||||
export function onboardingMessages(lang: ManualLanguage): string[] {
|
export function onboardingMessages(lang: ManualLanguage): string[] {
|
||||||
return [
|
return [
|
||||||
onboardingHeader(lang),
|
`${onboardingHeader(lang)}\n\n${MANUAL_TOC_HEADING[lang]}:\n${manualToc(lang)}`,
|
||||||
...PARTICIPATION_MANUAL.map((section) => `${section.heading[lang]}\n\n${section.body[lang]}`),
|
...PARTICIPATION_MANUAL.map((section) => `${section.heading[lang]}\n\n${section.body[lang]}`),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue