From 83f8ca09810292772b4fe2cc4b9c41fbdd110e9f Mon Sep 17 00:00:00 2001 From: Yvv Date: Sat, 31 Oct 2026 04:21:20 +0100 Subject: [PATCH] =?UTF-8?q?v3=20:=20interface=20des=20objections,=20des=20?= =?UTF-8?q?coquilles=20valid=C3=A9es=20et=20de=20la=20messagerie=20;=20con?= =?UTF-8?q?tr=C3=B4les=20C28=E2=80=93C31?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une version retenue par une objection le dit sous l'article, à chacun selon son rôle : la personne qui vote voit qu'elle ne se vote plus (les votes faits restent) ; l'auteur, quoi faire (« Modifier l'article visé » pour une redite, « Reformuler » pour une contradiction) ; la personne qui objecte, lever ou réitérer avant l'échéance. Le panier la montre sans choix et ne l'envoie jamais. « Détails » : faire une objection (redit / contredit), les objections et où elles en sont, les coquilles proposées par d'autres avec leur diff resserré, valider / refuser / revenir, l'historique dit qui a validé. Une coquille se propose sur toute version ouverte ou en vigueur. Boîte « Messages » dans le menu, avec le nombre de non-lus (pastille sur le bouton du menu en mobile) : objet, texte, gestes, échéance ; un message traité n'a plus de geste. Répondre à une objection lit son message. Trilingue (fr, en, es). C6 suit la règle : la coquille d'un administrateur sur un texte du seed passe tout de suite ; C28–C31 couvrent le reste. Co-Authored-By: Claude Opus 5.5 --- crates/ld-server/src/objections.rs | 19 +- crates/ld-server/src/proposals.rs | 6 +- crates/ld-server/tests/objections.rs | 2 + web/e2e/helpers.ts | 43 +++ web/e2e/objections.spec.ts | 223 +++++++++++ web/e2e/reader.spec.ts | 6 +- web/src/lib/basket.ts | 16 +- web/src/lib/components/BallotPanel.svelte | 29 +- web/src/lib/components/DeepList.svelte | 2 +- web/src/lib/components/DocPanel.svelte | 18 +- web/src/lib/components/Icon.svelte | 4 +- web/src/lib/components/ProposeView.svelte | 23 +- web/src/lib/components/Sidebar.svelte | 42 ++ web/src/lib/components/VersionDetails.svelte | 358 ++++++++++++++---- web/src/lib/components/VoteFold.svelte | 104 ++++- web/src/lib/diff.ts | 31 ++ web/src/lib/doc.svelte.ts | 24 ++ web/src/lib/i18n/en.ts | 127 ++++++- web/src/lib/i18n/es.ts | 127 ++++++- web/src/lib/i18n/fr.ts | 127 ++++++- web/src/lib/objections.ts | 28 ++ web/src/lib/session.svelte.ts | 28 ++ web/src/lib/types.ts | 86 ++++- web/src/lib/versions.ts | 3 +- web/src/routes/+layout.svelte | 21 +- .../routes/g/[group]/docs/[doc]/+page.svelte | 37 +- web/src/routes/messages/+page.svelte | 285 ++++++++++++++ web/tests/unit/basket.test.ts | 26 +- web/tests/unit/diff.test.ts | 45 +++ web/tests/unit/objections.test.ts | 37 ++ web/tests/unit/versions.test.ts | 2 +- 31 files changed, 1732 insertions(+), 197 deletions(-) create mode 100644 web/e2e/objections.spec.ts create mode 100644 web/src/lib/diff.ts create mode 100644 web/src/lib/objections.ts create mode 100644 web/src/routes/messages/+page.svelte create mode 100644 web/tests/unit/diff.test.ts create mode 100644 web/tests/unit/objections.test.ts diff --git a/crates/ld-server/src/objections.rs b/crates/ld-server/src/objections.rs index 4be4f5c..9ae259f 100644 --- a/crates/ld-server/src/objections.rs +++ b/crates/ld-server/src/objections.rs @@ -405,8 +405,9 @@ pub async fn reiterate( // What answers an objection // --------------------------------------------------------------------------- -/// A version changed: the objections around it whose author it answers move on. -pub async fn on_change(state: &AppState, proposal_id: &str, substantive: bool) -> AppResult<()> { +/// A version changed (by `by`): the objections around it whose author it +/// answers move on; the messages of the one who answered are read. +pub async fn on_change(state: &AppState, proposal_id: &str, substantive: bool, by: &str) -> AppResult<()> { for o in standing_around(state, proposal_id).await? { let Some(side) = o.touches(proposal_id) else { continue }; let change = if substantive { Change::Revision(side) } else { Change::Typo(side) }; @@ -416,7 +417,9 @@ pub async fn on_change(state: &AppState, proposal_id: &str, substantive: bool) - let due = state.clock.in_days(objection::ANSWER_DAYS); if let Some(step) = objection::step(&o.phase(), &Event::Answered { due: &due }).map_err(refused)? { let answer = if side == Touched::Target { "revision" } else { "other" }; - store(state, &o, &step, proposal_id, Some((answer, proposal_id))).await?; + if store(state, &o, &step, proposal_id, Some((answer, proposal_id))).await? { + messages::read_subject(state, by, Topic::Objection, &o.id).await?; + } } } Ok(()) @@ -443,15 +446,17 @@ pub async fn check_link(state: &AppState, user: &IdentityRow, objection_id: &str Ok(()) } -/// A proposal linked to an objection was submitted: it answers the objection. -pub async fn on_linked(state: &AppState, objection_id: &str, proposal_id: &str) -> AppResult<()> { +/// A proposal linked to an objection was submitted (by `by`): it answers the objection. +pub async fn on_linked(state: &AppState, objection_id: &str, proposal_id: &str, by: &str) -> AppResult<()> { let o = load(state, objection_id).await?; if !objection::answers(Change::LinkedProposal) { return Ok(()); } let due = state.clock.in_days(objection::ANSWER_DAYS); - if let Some(step) = objection::step(&o.phase(), &Event::Answered { due: &due }).map_err(refused)? { - store(state, &o, &step, proposal_id, Some(("proposal", proposal_id))).await?; + if let Some(step) = objection::step(&o.phase(), &Event::Answered { due: &due }).map_err(refused)? + && store(state, &o, &step, proposal_id, Some(("proposal", proposal_id))).await? + { + messages::read_subject(state, by, Topic::Objection, &o.id).await?; } Ok(()) } diff --git a/crates/ld-server/src/proposals.rs b/crates/ld-server/src/proposals.rs index d246ead..e6c1961 100644 --- a/crates/ld-server/src/proposals.rs +++ b/crates/ld-server/src/proposals.rs @@ -298,7 +298,7 @@ pub async fn create( .execute(&state.db) .await?; if b.submit { - objections::on_linked(&state, oid, &id).await?; + objections::on_linked(&state, oid, &id, &user.id).await?; } } Ok(Json(json!({ "id": id, "unitId": unit_id }))) @@ -389,7 +389,7 @@ pub async fn edit( record_revision(&state, &id, revision, "substantive", title, body, Some(&user.id)).await?; // It answers the objections around it (the objector judges again), and // the typo fixes waiting on the former text no longer fit. - objections::on_change(&state, &id, true).await?; + objections::on_change(&state, &id, true, &user.id).await?; typos::on_text_changed(&state, &id).await?; Ok(Json(json!({ "edit": "substantive", "revision": revision }))) } @@ -431,7 +431,7 @@ pub async fn submit( let linked: Option = sqlx::query_scalar("SELECT objection_id FROM proposals WHERE id = ?").bind(&id).fetch_one(&state.db).await?; if let Some(oid) = linked { - objections::on_linked(&state, &oid, &id).await?; + objections::on_linked(&state, &oid, &id, &user.id).await?; } let entered = adoption::run(&state, &st.doc.id).await?; Ok(Json(json!({ "status": "open", "entered": entered }))) diff --git a/crates/ld-server/tests/objections.rs b/crates/ld-server/tests/objections.rs index 3068bf7..79af219 100644 --- a/crates/ld-server/tests/objections.rs +++ b/crates/ld-server/tests/objections.rs @@ -216,6 +216,7 @@ async fn a_redit_holds_the_latest_back_until_its_author_answers_and_the_objector let m = chiara.messages().await; assert_eq!(gestures(&m["messages"][0]), Vec::<&str>::new()); assert_eq!(m["messages"][0]["outcome"], "answered"); + assert_eq!(m["unread"], 0, "answering reads one's message"); // Yvv lifts from his message: the proposal is votable again, the message offers nothing more. yvv.ok("POST", &format!("/api/objections/{oid}/lift"), None).await; @@ -263,6 +264,7 @@ async fn a_contredit_answered_by_a_rewording_is_lifted_alone_at_the_deadline() { "Je m'engage à ne certifier que des personnes qui respectent ces engagements, après les avoir rencontrées."; let r = chiara.ok("PATCH", &format!("/api/proposals/{b}"), Some(json!({ "title": title, "body": reworded }))).await; assert_eq!(r["edit"], "substantive"); + assert_eq!(chiara.ok("GET", "/api/messages/unread", None).await["unread"], 0, "rewording reads one's message"); let det = yvv.detail(&b).await; assert_eq!(phase(&det), (Some("objector".into()), Some(1))); assert_eq!(det["objections"][0]["answer"], "revision"); diff --git a/web/e2e/helpers.ts b/web/e2e/helpers.ts index b9c95a8..c4d7c8b 100644 --- a/web/e2e/helpers.ts +++ b/web/e2e/helpers.ts @@ -69,6 +69,49 @@ export async function shot(page: Page, name: string) { if (dir) await page.screenshot({ path: `${dir}/${name}.png` }); } +const CSRF = { 'x-requested-with': 'libredecision' }; + +/** The document of the test group, as the server gives it. */ +export async function testDoc(page: Page): Promise<{ + id: string; + units: { id: string; code: string; inForce: { id: string; title: string } | null; proposals: { id: string; title: string }[] }[]; +}> { + const g = await (await page.request.get('/api/groups/essai')).json(); + const d = await (await page.request.get(`/api/docs/${g.docs[0].id}`)).json(); + return { id: d.doc.id, units: d.units }; +} + +/** A new article proposed by the person of `page`, submitted to the vote; returns its id. */ +export async function propose(page: Page, title: string, body: string): Promise { + const doc = await testDoc(page); + const r = await page.request.post(`/api/docs/${doc.id}/proposals`, { + data: { kind: 'text', unitId: null, title, body, submit: true }, + headers: CSRF, + }); + if (!r.ok()) throw new Error(`propose ${r.status()} ${await r.text()}`); + return (await r.json()).id; +} + +/** Moves the server's clock forward (dev login only) and passes the deadlines at once. */ +export async function advanceClock(page: Page, seconds: number): Promise<{ lifted: number; applied: number; stale: number }> { + const r = await page.request.post('/api/dev/clock', { data: { seconds }, headers: CSRF }); + if (!r.ok()) throw new Error(`clock ${r.status()}`); + return r.json(); +} + +/** Screenshots on a desktop and on a phone, when `LD_SHOTS` names a directory (never in the loop). */ +export async function shots(page: Page, name: string) { + if (!process.env.LD_SHOTS) return; + const size = page.viewportSize(); + await page.waitForTimeout(400); + await shot(page, `${name}-bureau`); + await page.setViewportSize({ width: 412, height: 915 }); + await page.waitForTimeout(200); + await shot(page, `${name}-mobile`); + if (size) await page.setViewportSize(size); + await page.waitForTimeout(200); +} + /** The control door of the simulated chain, found where the page finds its node. */ export async function chain(page: Page, docPath = DOC) { const [, , group] = docPath.split('/'); diff --git a/web/e2e/objections.spec.ts b/web/e2e/objections.spec.ts new file mode 100644 index 0000000..b940ae7 --- /dev/null +++ b/web/e2e/objections.spec.ts @@ -0,0 +1,223 @@ +import { expect, test, type Page } from '@playwright/test'; +import { advanceClock, person, propose, shots, TEST_DOC, testDoc } from './helpers'; + +// Lot F (Yvv, 2026-10-30, ADR 0010): an objection « redit » or « contredit » +// holds back the later of two versions until it is lifted; the author answers, +// the objector lifts or reiterates from the box « Messages », and with no answer +// within 3 days it is lifted. A typo fix to someone else's text waits for its +// author (an administrator for a seed text) or for 3 days. Each control has its +// own people and its own proposals, in the test group. + +const DAY = 86_400; + +/** The menu's « Messages », with its number of unread messages. */ +async function openBox(page: Page, unread: number) { + const nav = page.getByRole('navigation', { name: 'Boîte à outils' }); + const link = nav.getByRole('link', { name: `Messages, ${unread} ${unread > 1 ? 'non lus' : 'non lu'}` }); + await expect(link).toBeVisible(); + await expect(link.locator('.unread')).toHaveText(String(unread)); + await link.click(); + await expect(page.getByRole('heading', { level: 1, name: 'Messages' })).toBeVisible(); +} + +/** The details of a version, in the panel. */ +async function details(page: Page, id: string) { + await page.goto(`${TEST_DOC}?onglet=details&version=${id}`); + const panel = page.locator('aside.panel'); + await expect(panel.getByRole('tab', { name: 'Détails' })).toHaveAttribute('aria-selected', 'true'); + return panel; +} + +/** From the details of a version: an objection against an article in force. */ +async function raise(page: Page, id: string, kind: 'Redit' | 'Contredit', target: string, reason: string) { + const panel = await details(page, id); + await panel.getByText('Faire une objection').click(); + await panel.locator(`.kinds label:has(input[value="${kind === 'Redit' ? 'redit' : 'contredit'}"])`).click(); + await expect(panel.getByRole('radio', { name: new RegExp(`^${kind}`) })).toBeChecked(); + await panel.getByLabel('Quel article ?').selectOption({ label: `En vigueur — ${target}` }); + await panel.getByLabel(kind === 'Redit' ? 'En quoi ces deux textes disent la même chose' : 'En quoi ces deux textes se contredisent').fill(reason); + await panel.getByRole('button', { name: "Envoyer l'objection" }).click(); + await expect(page.getByText("Objection enregistrée : la dernière arrivée des deux versions ne se vote plus jusqu'à sa levée.")).toBeVisible(); + return panel; +} + +test('C28 — « redit » : la dernière arrivée ne se vote plus, l’auteur modifie l’article visé, la personne qui objecte lève', async ({ browser }) => { + const aude = await person(browser, 'Aude'); + const oscar = await person(browser, 'Oscar'); + const vera = await person(browser, 'Vera'); + const title = 'Une seule clé par personne'; + const id = await propose(aude, title, "Je m'engage à n'avoir qu'un seul compte membre : une seule clé certifiante par être humain."); + + // Oscar: it repeats « Unicité du compte ». + const panel = await raise(oscar, id, 'Redit', 'Unicité du compte', 'Unicité du compte dit déjà exactement cela.'); + await expect(panel.locator('.objection')).toContainText(`« ${title} » redit « Unicité du compte »`); + await expect(panel.getByText('cette version ne se vote plus')).toBeVisible(); + await expect(panel.getByRole('button', { name: "Lever l'objection" })).toBeVisible(); + await shots(oscar, 'f1-objection-vue-par-qui-objecte'); + + // Vera, who votes: held back, said so, nothing to press; the list marks it. + await vera.goto(`${TEST_DOC}?vote=${id}`); + const fold = vera.getByRole('region', { name: `Le vote de « ${title} »` }); + await expect(fold.getByText("Une objection retient cette version : elle ne se vote plus jusqu'à sa levée. Les votes déjà faits restent.")).toBeVisible(); + await expect(fold.getByText('Elle redit « Unicité du compte ».')).toBeVisible(); + await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0); + await expect(vera.locator('ul.deep button.row', { hasText: title })).toContainText('non votable : objection'); + await fold.scrollIntoViewIfNeeded(); + await shots(vera, 'f2-objection-vue-par-qui-vote'); + + // Aude: a message, and the way to answer — modify the article repeated. + await aude.goto(TEST_DOC); + await openBox(aude, 1); + const raised = aude.locator('li.message', { hasText: 'Oscar fait une objection' }); + await expect(raised).toContainText(`ta version « ${title} » redit « Unicité du compte »`); + await expect(raised).toContainText("Pour répondre, modifie l'article visé."); + await shots(aude, 'f3-messages-auteur'); + await raised.getByRole('button', { name: "Modifier l'article visé" }).click(); + await expect(aude).toHaveURL(/onglet=proposer/); + await expect(aude).toHaveURL(/objection=/); + const sheet = aude.locator('aside.panel'); + await expect(sheet.getByText("Cette proposition répond à une objection : une fois soumise, la personne qui l'a faite la lève ou la réitère.")).toBeVisible(); + const body = sheet.getByLabel('Texte', { exact: true }); + await expect(body).toHaveValue(/Je m.engage sur l.honneur/); + await body.fill(`${await body.inputValue()}\n\nUne seule clé certifiante par être humain, et un seul compte membre.`); + await shots(aude, 'f4-reponse-auteur'); + await sheet.getByRole('button', { name: 'Soumettre au vote' }).click(); + await expect(aude.getByText('Proposition soumise au vote.')).toBeVisible(); + + // Oscar: the answer, to lift or reiterate, with its deadline. + await oscar.goto(TEST_DOC); + await openBox(oscar, 1); + const answered = oscar.locator('li.message', { hasText: 'a reçu une réponse' }); + await expect(answered).toContainText(`Ton objection sur « ${title} » a reçu une réponse.`); + await expect(answered.getByText(/^échéance : /)).toBeVisible(); + await expect(answered.getByRole('button', { name: "Réitérer l'objection" })).toBeVisible(); + await shots(oscar, 'f5-messages-qui-objecte'); + await answered.getByRole('button', { name: "Lever l'objection" }).click(); + await expect(oscar.getByText('Objection levée.')).toBeVisible(); + // Dealt with: no gesture any more, and read. + await expect(answered.getByText('objection levée', { exact: true })).toBeVisible(); + await expect(answered.locator('.btn')).toHaveCount(0); + await expect(oscar.getByRole('navigation', { name: 'Boîte à outils' }).getByRole('link', { name: 'Messages', exact: true })).toBeVisible(); + + // Vera: it is voted on again. + await vera.reload(); + await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toBeVisible(); + await expect(fold.getByText('Une objection retient cette version', { exact: false })).toHaveCount(0); +}); + +test('C29 — « contredit » : l’auteur reformule, sans réponse de la personne qui objecte l’objection est levée à l’échéance', async ({ browser }) => { + const aude = await person(browser, 'Aude'); + const oscar = await person(browser, 'Oscar'); + const vera = await person(browser, 'Vera'); + const title = 'Certification à sens unique'; + const id = await propose(aude, title, "Je certifie une personne sans attendre qu'elle me certifie à son tour."); + await raise(oscar, id, 'Contredit', 'Réciprocité', 'Réciprocité demande que la certification aille dans les deux sens.'); + + // Aude, from the vote under the article: reword it. + await aude.goto(`${TEST_DOC}?vote=${id}`); + const fold = aude.getByRole('region', { name: `Le vote de « ${title} »` }); + await expect(fold.getByText('Elle contredit « Réciprocité ».')).toBeVisible(); + await expect(fold.getByText("Pour qu'elle se vote à nouveau, reformule-la.", { exact: false })).toBeVisible(); + await fold.scrollIntoViewIfNeeded(); + await shots(aude, 'f8-objection-vue-par-l-auteur'); + await fold.getByRole('button', { name: 'Reformuler' }).click(); + await expect(aude).toHaveURL(/modifier=1/); + const panel = aude.locator('aside.panel'); + const body = panel.getByLabel('Texte', { exact: true }); + await expect(body).toBeVisible(); + await body.fill("Je certifie une personne et j'attends qu'elle me certifie à son tour, comme le veut la réciprocité."); + await panel.getByRole('button', { name: 'Enregistrer la correction' }).click(); + await expect(aude.getByText('Nouvelle version enregistrée : les votants devront reconfirmer.')).toBeVisible(); + await expect(fold.getByText(/^Une réponse est arrivée : Oscar lève ou réitère l'objection d'ici le /)).toBeVisible(); + + // Oscar says nothing; three days pass on the server's clock. + const swept = await advanceClock(oscar, 3 * DAY + 60); + expect(swept.lifted).toBeGreaterThanOrEqual(1); + + // Vera: votable again; the objection says why it was lifted. + const panelV = await details(vera, id); + await expect(panelV.locator('.objection')).toContainText('levée : 3 jours sans suite'); + await vera.goto(`${TEST_DOC}?vote=${id}`); + await expect(vera.getByRole('region', { name: `Le vote de « ${title} »` }).getByRole('button', { name: 'Pour', exact: true })).toBeVisible(); + + // Oscar's message: dealt with, by the deadline. + await oscar.goto('/messages'); + const answered = oscar.locator('li.message', { hasText: `Ton objection sur « ${title} »` }); + await expect(answered.getByText('objection levée : 3 jours sans suite')).toBeVisible(); + await expect(answered.locator('.btn')).toHaveCount(0); +}); + +test('C30 — la coquille d’un autre sur une proposition : en attente, puis validée par l’auteur depuis ses messages', async ({ browser }) => { + const aude = await person(browser, 'Aude'); + const vera = await person(browser, 'Vera'); + const title = 'Clé unique et vivante'; + const id = await propose(aude, title, "Je m'engage à n'utiliser qu'une seule clé certifiante, tenue par moi seul."); + + // Vera fixes the apostrophes: it waits for Aude, the text stays as it was. + const panel = await details(vera, id); + await panel.getByText('Corriger une coquille').click(); + const body = panel.getByLabel('Texte', { exact: true }); + await body.fill((await body.inputValue()).replaceAll("'", '’')); + await panel.getByRole('button', { name: 'Enregistrer la correction' }).click(); + await expect(vera.getByText(/^Ta correction attend l'auteur du texte : elle s'applique quand il la valide, ou d'elle-même le /)).toBeVisible(); + const fix = panel.locator('li.fix'); + await expect(fix).toContainText('proposée par Vera'); + await expect(fix).toContainText("en attente : s'applique d'elle-même le"); + await expect(fix.locator('ins')).toHaveCount(3); + await expect(panel.locator('.prose').first()).toContainText("Je m'engage à n'utiliser"); + await shots(vera, 'f6-coquille-en-attente'); + + // Aude validates it from her box. + await aude.goto(TEST_DOC); + await openBox(aude, 1); + const msg = aude.locator('li.message', { hasText: `Vera propose une correction de coquille dans ta version « ${title} ».` }); + await expect(msg.locator('ins')).toHaveCount(3); + await expect(msg.getByText(/^échéance : /)).toBeVisible(); + await msg.getByRole('button', { name: 'Valider' }).click(); + await expect(aude.getByText('Correction appliquée.')).toBeVisible(); + await expect(msg.getByText('correction appliquée', { exact: true })).toBeVisible(); + await expect(msg.locator('.btn')).toHaveCount(0); + + // The text carries the fix; its history says who wrote it and who let it in. + const after = await details(vera, id); + await expect(after.locator('.prose').first()).toContainText('Je m’engage à n’utiliser'); + await expect(after.locator('.history')).toContainText('coquille corrigée'); + await expect(after.locator('.history')).toContainText('par Vera'); + await expect(after.locator('.history')).toContainText('validée par Aude'); + await expect(after.locator('li.fix')).toContainText('appliquée · par Aude'); +}); + +test('C31 — une coquille sur un texte en vigueur attend sa validation, puis s’applique', async ({ browser }) => { + // An administrator opens the group first: the message goes to them (a seed text has no author). + const ada = await person(browser, 'Ada', true); + await ada.goto(TEST_DOC); + const vera = await person(browser, 'Vera'); + await vera.goto(TEST_DOC); + const purpose = vera.locator('section.text article.unit', { hasText: "Raison d'être" }); + await purpose.hover(); + await purpose.getByRole('button', { name: 'Le vote' }).click(); + await purpose.getByRole('button', { name: 'Coquilles, traductions, objections, historique' }).click(); + const panel = vera.locator('aside.panel'); + await panel.getByText('Corriger une coquille').click(); + const body = panel.getByLabel('Texte', { exact: true }); + await body.fill((await body.inputValue()).replace("C'est le seul", 'C’est le seul')); + await panel.getByRole('button', { name: 'Enregistrer la correction' }).click(); + await expect(vera.getByText(/^Ta correction attend un administrateur, ce texte venant du seed/)).toBeVisible(); + // In force, only a validated fix changes the text. + await expect(purpose).toContainText("C'est le seul et unique propos"); + await expect(panel.locator('li.fix')).toContainText("en attente : s'applique d'elle-même le"); + + await ada.reload(); + await openBox(ada, 1); + const msg = ada.locator('li.message', { hasText: "Vera propose une correction de coquille dans « Raison d'être », un texte du seed" }); + await shots(ada, 'f7-messages-administrateur'); + await msg.getByRole('button', { name: 'Valider' }).click(); + await expect(ada.getByText('Correction appliquée.')).toBeVisible(); + + await vera.reload(); + await expect(purpose).toContainText('C’est le seul et unique propos'); + const doc = await testDoc(vera); + const unit = doc.units.find((u) => u.inForce?.title === "Raison d'être"); + const after = await details(vera, unit?.inForce?.id ?? ''); + await expect(after.locator('.history')).toContainText('validée par Ada'); +}); diff --git a/web/e2e/reader.spec.ts b/web/e2e/reader.spec.ts index b105d87..8a45ad1 100644 --- a/web/e2e/reader.spec.ts +++ b/web/e2e/reader.spec.ts @@ -90,13 +90,15 @@ test('C5 — une proposition remonte en vigueur quand elle atteint son seuil (gr }); test('C6 — une coquille se corrige sans vote', async ({ page }) => { - await signIn(page, 'Chiara'); + // A seed text has no author: an administrator's fix applies at once (someone + // else's waits for an administrator, or for 3 days: C31). + await signIn(page, 'Yvv', true); await page.goto(TEST_DOC); const unicity = page.locator('section.text article.unit', { hasText: 'Unicité du compte' }); await unicity.hover(); await unicity.getByRole('button', { name: 'Le vote' }).click(); // What is not the vote lives in the panel, tab « Détails ». - await unicity.getByRole('button', { name: 'Coquilles, traductions, signalements, historique' }).click(); + await unicity.getByRole('button', { name: 'Coquilles, traductions, objections, historique' }).click(); const panel = page.locator('aside.panel'); await expect(panel.getByRole('tab', { name: 'Détails' })).toHaveAttribute('aria-selected', 'true'); await panel.getByText('Corriger une coquille').click(); diff --git a/web/src/lib/basket.ts b/web/src/lib/basket.ts index cb37a23..1b937ee 100644 --- a/web/src/lib/basket.ts +++ b/web/src/lib/basket.ts @@ -17,6 +17,8 @@ export interface Slot { versions: Proposal[]; /** Open changes of its inertia: another question, never filled by a grouped gesture. */ inertia: Proposal[]; + /** Versions held back by an objection: shown, not votable until it is lifted (ADR 0010). */ + held: Proposal[]; } export interface Section { @@ -32,12 +34,13 @@ export function sections(units: Unit[]): Section[] { const open = unit.proposals.filter((p) => p.status === 'open'); const versions = [ ...(unit.inForce?.status === 'in_force' ? [unit.inForce] : []), - ...open.filter((p) => p.kind !== 'inertia'), + ...open.filter((p) => p.kind !== 'inertia' && p.votable !== false), ]; const inertia = open.filter((p) => p.kind === 'inertia'); - if (!versions.length && !inertia.length) continue; + const held = open.filter((p) => p.kind !== 'inertia' && p.votable === false); + if (!versions.length && !inertia.length && !held.length) continue; const last = out.at(-1); - const slot = { unit, versions, inertia }; + const slot = { unit, versions, inertia, held }; if (last && last.key === unit.section) last.slots.push(slot); else out.push({ key: unit.section, slots: [slot] }); } @@ -59,9 +62,14 @@ export function waiting(slots: Slot[], picks: Picks): Slot[] { return slots.filter((s) => s.versions.length > 1 && !s.versions.some((p) => p.id in picks)); } +/** The versions one may vote now: those a choice of the basket is sent for. */ +export function votableIds(all: Section[]): Set { + return new Set(all.flatMap((s) => s.slots.flatMap((x) => [...x.versions, ...x.inertia].map((p) => p.id)))); +} + /** Empties the basket of these articles only. */ export function clearScope(slots: Slot[], picks: Picks): Picks { - const ids = new Set(slots.flatMap((s) => [...s.versions, ...s.inertia].map((p) => p.id))); + const ids = new Set(slots.flatMap((s) => [...s.versions, ...s.inertia, ...s.held].map((p) => p.id))); return Object.fromEntries(Object.entries(picks).filter(([id]) => !ids.has(id))); } diff --git a/web/src/lib/components/BallotPanel.svelte b/web/src/lib/components/BallotPanel.svelte index ed10023..b39daed 100644 --- a/web/src/lib/components/BallotPanel.svelte +++ b/web/src/lib/components/BallotPanel.svelte @@ -3,8 +3,9 @@ // section entière, ou un article avec son titre, ou séparément »): choices // article by article, « tout pour » / « tout contre » for a section or the whole // document — never for an article whose versions compete —, then one gesture. + // A version held back by an objection is shown, with nothing to choose. import { onMount } from 'svelte'; - import { clearScope, counts, fill, lines, sections, waiting, type Section, type Slot } from '$lib/basket'; + import { clearScope, counts, fill, lines, sections, votableIds, waiting, type Section, type Slot } from '$lib/basket'; import type { DocState } from '$lib/doc.svelte'; import { i18n, t, type Key } from '$lib/i18n/index.svelte'; import { relationOf, titleOf } from '$lib/labels'; @@ -37,7 +38,7 @@ const access = $derived(view?.vote); const all = $derived(sections(view?.units ?? [])); const slots = $derived(all.flatMap((s) => s.slots)); - const known = $derived(new Set(slots.flatMap((s) => [...s.versions, ...s.inertia].map((p) => p.id)))); + const known = $derived(votableIds(all)); const toSend = $derived(lines(basket.picks, known)); const tally = $derived(counts(Object.fromEntries(Object.entries(basket.picks).filter(([id]) => known.has(id))))); const left = $derived(waiting(slots, basket.picks)); @@ -51,7 +52,7 @@ }); function headline(s: Slot): string { - const text = s.versions.find((p) => p.kind === 'text'); + const text = [...s.versions, ...s.held].find((p) => p.kind === 'text'); return text?.title ?? (s.versions[0] ? titleOf(s.versions[0], s.unit) : s.unit.code); } @@ -123,7 +124,7 @@ {#each s.slots as slot (slot.unit.id)} {@const title = headline(slot)} {@const several = slot.versions.length > 1} - {@const first = slot.versions[0] ?? slot.inertia[0]} + {@const first = slot.versions[0] ?? slot.inertia[0] ?? slot.held[0]}
  • {#if several}

    {t('basket.versions')}

    {/if} @@ -145,6 +146,15 @@ /> {/each} + {#each slot.held as v (v.id)} +
    + + {labelOf(v, slot)} + {t('tally.short', { for: v.tally.for, against: v.tally.against })} + + {t('basket.held')} +
    + {/each}
  • {/each} @@ -279,6 +289,17 @@ .small { font-size: 0.8rem; } + .version.held .label { + color: var(--ink-soft); + } + .held-note { + padding: 0.25rem 0.7rem; + border-radius: var(--r-pill); + background: var(--ochre-wash); + color: var(--ochre); + font-size: 0.8rem; + font-weight: 750; + } .send { position: sticky; bottom: -1.6rem; diff --git a/web/src/lib/components/DeepList.svelte b/web/src/lib/components/DeepList.svelte index ac68677..2edd612 100644 --- a/web/src/lib/components/DeepList.svelte +++ b/web/src/lib/components/DeepList.svelte @@ -62,7 +62,7 @@ {#snippet marks(p: Proposal)} {#if p.evaluation?.wellRated}{t('deep.wellrated')}{/if} - {#if p.blocked}{t('deep.blocked')}{/if} + {#if p.blocked}{t('deep.held')}{/if} {#if p.myVoteStale}{t('deep.stale')}{/if} {#if p.mine}{t('deep.mine')}{/if} {/snippet} diff --git a/web/src/lib/components/DocPanel.svelte b/web/src/lib/components/DocPanel.svelte index 5dc1ccf..3a7fa38 100644 --- a/web/src/lib/components/DocPanel.svelte +++ b/web/src/lib/components/DocPanel.svelte @@ -5,7 +5,9 @@ import type { DocState, PanelState } from '$lib/doc.svelte'; import { t } from '$lib/i18n/index.svelte'; import { relationOf, titleOf } from '$lib/labels'; + import { answerPanel } from '$lib/objections'; import type { BasketState } from '$lib/picks.svelte'; + import { session } from '$lib/session.svelte'; import type { Lang } from '$lib/types'; import type { Line } from '$lib/vote'; import BallotPanel from './BallotPanel.svelte'; @@ -77,7 +79,7 @@
    {#each tabs as [key, label] (key)} - + {/each}
    @@ -91,8 +93,13 @@ {doc} proposalId={voteId} {lang} + edit={!!panel.edit} onvote={(id) => onvote(id, true)} - onpropose={(prefill) => onchange({ ...panel, tab: 'propose', prefill, draftId: null })} + onpropose={(prefill) => onchange({ ...panel, tab: 'propose', prefill, draftId: null, edit: false })} + onanswer={(o) => { + const next = answerPanel(o, session.me?.id ?? null); + if (next) onchange(next); + }} /> {:else if panel.tab === 'proposals' && unit}
      @@ -103,7 +110,7 @@ {v.status === 'in_force' ? t('panel.inforce') : t('panel.proposal')} {titleOf(v, unit)} - {v.status === 'in_force' ? (v.seedMinimal ? t('doc.seed') : '') : relationOf(v, unit)} · {t('tally.short', { for: v.tally.for, against: v.tally.against })}{#if v.myVote && !v.myVoteStale} · {t('basket.mine', { choice: t(`vote.choice.${v.myVote}`) })}{/if} + {v.status === 'in_force' ? (v.seedMinimal ? t('doc.seed') : '') : relationOf(v, unit)} · {t('tally.short', { for: v.tally.for, against: v.tally.against })}{#if v.myVote && !v.myVoteStale} · {t('basket.mine', { choice: t(`vote.choice.${v.myVote}`) })}{/if}{#if v.blocked} · {t('deep.held')}{/if} @@ -116,6 +123,8 @@ unitId={panel.unitId} draftId={panel.draftId ?? null} prefill={panel.prefill ?? null} + from={panel.objectionId ? (panel.proposalId ?? null) : null} + objectionId={panel.objectionId ?? null} ondone={(r) => r.proposalId && r.submitted ? onvote(r.proposalId, true) @@ -223,6 +232,9 @@ .small { font-size: 0.82rem; } + .held { + color: var(--ochre); + } @keyframes enter { from { transform: translateX(16px); diff --git a/web/src/lib/components/Icon.svelte b/web/src/lib/components/Icon.svelte index 527ecd4..35446b1 100644 --- a/web/src/lib/components/Icon.svelte +++ b/web/src/lib/components/Icon.svelte @@ -1,6 +1,6 @@ diff --git a/web/src/lib/components/ProposeView.svelte b/web/src/lib/components/ProposeView.svelte index a90b1a4..ce1bff2 100644 --- a/web/src/lib/components/ProposeView.svelte +++ b/web/src/lib/components/ProposeView.svelte @@ -16,12 +16,18 @@ unitId, draftId = null, prefill = null, + from = null, + objectionId = null, ondone, }: { doc: DocState; unitId: string | null; draftId?: string | null; prefill?: { title: string; body: string } | null; + /** The version whose text the new one starts from (an answer to an objection). */ + from?: string | null; + /** The objection this proposal answers (ADR 0010): linked to it when created. */ + objectionId?: string | null; ondone: (r: { proposalId: string | null; submitted: boolean }) => void; } = $props(); @@ -42,16 +48,18 @@ // (Re)initialise only when the panel is pointed elsewhere — never on a reload, // which would wipe what the person is typing. $effect(() => { - const [u0, d0, pf] = [unitId, draftId, prefill]; + const [u0, d0, pf, f0] = [unitId, draftId, prefill, from]; + void objectionId; untrack(() => { const u = doc.unit(u0); const d = doc.proposal(d0); + const f = doc.proposal(f0); target = u0; savedId = d0; mode = u0 ? 'replace' : 'new'; after = doc.inForce.at(-1)?.id ?? ''; - title = pf?.title ?? d?.title ?? u?.inForce?.title ?? u?.proposals[0]?.title ?? ''; - body = pf?.body ?? d?.body ?? u?.inForce?.body ?? ''; + title = pf?.title ?? d?.title ?? f?.title ?? u?.inForce?.title ?? u?.proposals[0]?.title ?? ''; + body = pf?.body ?? d?.body ?? f?.body ?? u?.inForce?.body ?? ''; error = ''; }); }); @@ -93,6 +101,7 @@ title: mode === 'remove' ? (unit?.inForce?.title ?? title) : title, body: mode === 'remove' ? '' : body, submit: false, + ...(objectionId && mode !== 'new' ? { objectionId } : {}), }); savedId = r.id; return r.id; @@ -152,6 +161,7 @@
      + {#if objectionId && mode !== 'new' && !draft}

      {t('objection.linked')}

      {/if} {#if !draft}
      {#if unit} @@ -249,6 +259,13 @@ .assist .btn { justify-self: start; } + .linked { + padding: 0.7rem 0.9rem; + border-radius: var(--r-input); + background: var(--ochre-wash); + font-weight: 650; + font-size: 0.9rem; + } .small { font-size: 0.84rem; } diff --git a/web/src/lib/components/Sidebar.svelte b/web/src/lib/components/Sidebar.svelte index d59f75f..7e2323b 100644 --- a/web/src/lib/components/Sidebar.svelte +++ b/web/src/lib/components/Sidebar.svelte @@ -57,6 +57,17 @@ onnavigate?.()}> {t('nav.groups')} + onnavigate?.()} + > + {t('nav.messages')} + {#if session.unread}{/if} + {/if}
        @@ -146,6 +157,37 @@ .groups.current .eyebrow { color: var(--ink); } + .messages { + display: flex; + align-items: center; + gap: 0.55rem; + min-height: 2.5rem; + margin-top: -0.9rem; + padding: 0.5rem 0.7rem; + border-radius: var(--r-input); + color: var(--ink); + font-weight: 700; + text-decoration: none; + transition: background-color 0.15s ease; + } + .messages:hover, + .messages.current { + background: var(--water-wash); + } + .messages.current { + color: var(--water); + } + .unread { + margin-left: auto; + min-width: 1.6rem; + padding: 0.05rem 0.45rem; + border-radius: var(--r-pill); + background: var(--ochre); + color: var(--on-water); + font-size: 0.78rem; + font-weight: 800; + text-align: center; + } .tools { list-style: none; margin: 0; diff --git a/web/src/lib/components/VersionDetails.svelte b/web/src/lib/components/VersionDetails.svelte index 0f31741..7a75a8a 100644 --- a/web/src/lib/components/VersionDetails.svelte +++ b/web/src/lib/components/VersionDetails.svelte @@ -1,14 +1,17 @@ @@ -184,6 +240,7 @@ {#if p.seedMinimal}{t('doc.seed')} {:else if p.confirmedAt}{t('doc.confirmed')} {:else if p.enteredAt && p.status === 'in_force'}{t('doc.adopted', { date: i18n.date(p.enteredAt) })}{/if} + {#if p.blocked}{t('deep.held')}{/if} {#if p.author}{t('panel.by', { name: p.author })}{#if p.authorInvited} · {t('author.invited')}{/if}{:else if p.origin === 'seed'}{t('panel.seed.origin')}{/if}

        @@ -202,14 +259,14 @@ {/if} - {#if votable} + {#if live}

        {t('tally.short', { for: p.tally.for, against: p.tally.against })} ·

        {/if} - {#if p.kind === 'text' && (p.status === 'in_force' || p.origin === 'seed' || p.mine) && votable} + {#if p.kind === 'text' && live}
        {p.mine && p.status === 'open' ? t('propose.edit') : t('typo.title')} @@ -227,6 +284,30 @@
        {/if} + {#if detail.typos.length} +
        +

        {t('typo.list')}

        +
          + {#each detail.typos as fix (fix.id)} +
        • + +

          + {t('typo.by', { name: fix.writer ?? '' })}{#if fix.writerInvited} · {t('author.invited')}{/if} · {i18n.date(fix.at)} +

          +

          {typoState(fix)}

          + {#if fix.gestures.length} +
          + {#each fix.gestures as g (g)} + + {/each} +
          + {/if} +
        • + {/each} +
        +
        + {/if} + {#if lang !== source && p.kind === 'text'}
        @@ -247,7 +328,10 @@
          {#each detail.history.slice(1) as h (h.at + h.revision)}
        1. -

          {t(`history.${h.kind}`)} · {i18n.date(h.at)}{#if h.author} · {t('history.by', { name: h.author })}{#if h.authorInvited} · {t('author.invited')}{/if}{/if}

          +

          + {t(`history.${h.kind}`)} · {i18n.date(h.at)}{#if h.author} · {t('history.by', { name: h.author })}{#if h.authorInvited} · {t('author.invited')}{/if}{/if} + {#if h.typo?.deadline} · {t('history.typo.deadline')}{:else if h.typo?.answeredBy} · {t('history.typo.validated', { name: h.typo.answeredBy })}{/if} +

          {#if h.diff}{/if}
        2. {/each} @@ -255,33 +339,69 @@
        {/if} - {#if p.kind === 'text' && votable} -
        -

        {t('signal.title')}

        - {#each detail.signals as s (s.id)} -
        -

        {s.other?.title ?? ''} — {s.reason}

        -

        - {t('signal.by', { name: s.author })}{#if s.authorInvited} · {t('author.invited')}{/if} · {i18n.date(s.at)} - {#if s.liftedAt} · {s.liftedBy === 'revision' ? t('signal.lifted.revision') : t('signal.lifted')}{/if} + {#if p.kind === 'text' && (live || detail.objections.length)} +

        +

        {t('objection.title')}

        + {#each detail.objections as o (o.id)} + {@const standing = o.phase !== 'lifted'} +
        +

        + {t(`objection.kind.${o.kind}`)} + {t(`objection.says.${o.kind}`, { held: o.heldVersion?.title ?? '', other: o.other.title ?? '' })}

        - {#if s.mine && !s.liftedAt}{/if} +

        {o.reason}

        +

        + {t('objection.by', { name: o.objector ?? '' })}{#if o.objectorInvited} · {t('author.invited')}{/if} · {i18n.date(o.at)} + · {stateOf(o)}{#if standing && o.answer} · {t(`objection.answer.${o.answer}`)}{/if} +

        + {#if standing && o.held}

        {t('objection.held.here')}

        {/if} + {#if standing} +
        + {#if o.iAnswer && !o.mine && o.phase === 'author'} + + {/if} + {#if o.canLift} + + {/if} + {#if o.canReiterate} + + {/if} +
        + {/if}
        {:else} -

        {t('signal.none')}

        +

        {t('objection.none')}

        {/each} -
        - {t('signal.add')} -
        -

        {t('signal.note')}

        - - - -
        -
        + {#if live && objectionTargets.length} +
        + {t('objection.add')} +
        +

        {t('objection.note')}

        +
        + {#each ['redit', 'contredit'] as const as k (k)} + + {/each} +
        + + + +
        +
        + {/if}
        {/if} @@ -375,18 +495,88 @@ display: grid; gap: 0.6rem; } - .signal { + .fixes { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.6rem; + } + .fix, + .objection { padding: 0.6rem 0.8rem; border-radius: var(--r-input); - background: var(--ochre-wash); display: grid; - gap: 0.2rem; + gap: 0.25rem; } - .signal.lifted { + .fix { + background: var(--water-wash); + } + .fix.pending .state { + font-weight: 750; + color: var(--water); + } + .fix.refused, + .fix.stale { + background: var(--surface-2); + opacity: 0.8; + } + .objection { + background: var(--ochre-wash); + } + .objection.lifted { background: var(--surface-2); opacity: 0.75; } - .signal .btn { - justify-self: start; + .reason { + font-family: var(--font-text); + font-style: italic; + color: var(--ink-soft); + font-size: 0.92rem; + } + .held { + font-weight: 750; + color: var(--ochre); + } + .gestures { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-top: 0.2rem; + } + .kinds { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); + gap: 0.4rem; + } + .kinds label { + display: grid; + gap: 0.1rem; + padding: 0.55rem 0.8rem; + border-radius: var(--r-input); + background: var(--surface-2); + cursor: pointer; + font-size: 0.9rem; + } + .kinds label.on { + background: var(--ochre); + color: var(--on-water); + } + .kinds label.on .hint { + color: inherit; + opacity: 0.85; + } + .kinds input { + position: absolute; + opacity: 0; + pointer-events: none; + } + .kinds label:has(input:focus-visible) { + outline: 2px solid var(--water); + outline-offset: 2px; + } + .hint { + font-size: 0.8rem; + color: var(--ink-soft); } diff --git a/web/src/lib/components/VoteFold.svelte b/web/src/lib/components/VoteFold.svelte index 4dc4e33..796e5ee 100644 --- a/web/src/lib/components/VoteFold.svelte +++ b/web/src/lib/components/VoteFold.svelte @@ -3,16 +3,20 @@ // vote et les autres. À combien il se rapproche du passage en vigueur. Façon // toggle, pas une fenêtre qui s'ouvre »): the level of the water, the totals — // never who voted what —, how far the threshold is, your vote and where it - // stands, then the inertia and the formula. The rest of a version (typos, - // translations, flags, history) lives in the panel, tab « Détails ». + // stands, then the inertia and the formula. A version held back by an + // objection says so, and tells each one what is theirs to do (ADR 0010). The + // rest of a version (typos, translations, objections, history) lives in the + // panel, tab « Détails ». import { onMount } from 'svelte'; import { api } from '$lib/api'; import type { DocState } from '$lib/doc.svelte'; import { i18n, t } from '$lib/i18n/index.svelte'; import { inertiaBadge, titleOf } from '$lib/labels'; + import { holding } from '$lib/objections'; + import { session } from '$lib/session.svelte'; import { toasts } from '$lib/toast.svelte'; - import type { LevelInfo, Proposal, Unit } from '$lib/types'; - import { errorText, g1Amount, voteDirect, type Line } from '$lib/vote'; + import type { Entered, LevelInfo, Objection, Proposal, Unit } from '$lib/types'; + import { announce, errorText, g1Amount, voteDirect, type Line } from '$lib/vote'; import Formula from './Formula.svelte'; import InertiaPicker from './InertiaPicker.svelte'; import WaterLevel from './WaterLevel.svelte'; @@ -24,6 +28,7 @@ onopen, onsettings, ondetails, + onanswer, onsign, }: { doc: DocState; @@ -33,6 +38,8 @@ onopen: (proposalId: string) => void; onsettings: (proposalId: string) => void; ondetails: (proposalId: string) => void; + /** The author of a version held back answers its objection: modify the article repeated, or reword. */ + onanswer: (objection: Objection) => void; /** On the Ğ1 chain: the page has the lines signed by the person's wallet. */ onsign: (lines: Line[]) => void; } = $props(); @@ -41,6 +48,7 @@ electorate: number; voteFloor: number; levels: LevelInfo[]; + objections: Objection[]; } let detail = $state(null); @@ -51,7 +59,9 @@ const view = $derived(doc.view); const access = $derived(view?.vote); const chain = $derived(access?.way === 'chain'); - const votable = $derived(p.status === 'open' || p.status === 'in_force'); + const live = $derived(p.status === 'open' || p.status === 'in_force'); + /** The objections holding this version back, and what each one asks of whom. */ + const held = $derived(p.blocked ? holding(detail?.objections ?? []) : []); const title = $derived(p.kind === 'text' ? p.title : titleOf(p, unit)); const voted = $derived(p.myVote !== null && !p.myVoteStale); const ballot = $derived(p.myBallot); @@ -60,6 +70,9 @@ const id = p.id; void p.tally.for; void p.tally.against; + void p.blocked; + void p.objections; + void p.revision; api .get(`/proposals/${id}`) .then((d) => { @@ -74,6 +87,22 @@ root?.scrollIntoView({ block: 'nearest', behavior: reduce ? 'auto' : 'smooth' }); }); + async function settle(o: Objection, gesture: 'lift' | 'reiterate') { + error = ''; + busy = true; + try { + const r = await api.post<{ entered?: Entered[] }>(`/objections/${o.id}/${gesture}`); + toasts.show(t(gesture === 'lift' ? 'objection.done.lifted' : 'objection.done.reiterated')); + await doc.reload(); + announce(doc, r.entered ?? []); + void session.refreshUnread(); + } catch (e) { + error = errorText(e); + } finally { + busy = false; + } + } + async function cast(choice: 'for' | 'against' | null) { error = ''; const lines: Line[] = [{ proposalId: p.id, choice }]; @@ -105,7 +134,38 @@

        {inertiaBadge(p.votedAtInertia)}

      - {#if p.blocked}

      {t('vote.blocked')}

      {/if} + {#if p.blocked} +
      +

      {t('vote.held')}

      + {#each held as o (o.id)} +
      +

      + {t(o.kind === 'redit' ? 'vote.held.redit' : 'vote.held.contredit', { other: o.other.title ?? '' })} + · {t('objection.by', { name: o.objector ?? '' })} +

      +

      {o.reason}

      + {#if o.mine} +

      {o.phase === 'objector' ? t('vote.held.mine.asked', { date: i18n.date(o.dueAt) }) : t('vote.held.mine')}

      + {:else if o.phase === 'objector'} +

      {t('vote.held.waiting', { name: o.objector ?? '', date: i18n.date(o.dueAt) })}

      + {:else if o.iAnswer} +

      {t(o.kind === 'redit' ? 'vote.held.author.redit' : 'vote.held.author.contredit')}

      + {/if} +
      + {#if o.iAnswer && !o.mine && o.phase === 'author'} + + {/if} + {#if o.canLift} + + {/if} + {#if o.canReiterate} + + {/if} +
      +
      + {/each} +
      + {/if}
      {#if ballot?.status === 'pending'} @@ -125,8 +185,10 @@ {/if}
      - {#if !votable} + {#if !live}

      {t('vote.closed')}

      + {:else if p.blocked} + {:else if access && !access.open}

      {t(access.reason === 'chain_off' ? 'vote.chain.off' : 'vote.members')}

      {:else if access} @@ -247,6 +309,34 @@ font-weight: 700; font-size: 0.9rem; } + .held { + display: grid; + gap: 0.7rem; + padding: 0.85rem 1rem; + border-radius: var(--r-input); + background: var(--ochre-wash); + } + .lead { + font-weight: 750; + } + .objection { + display: grid; + gap: 0.35rem; + font-size: 0.92rem; + } + .reason { + font-family: var(--font-text); + font-style: italic; + color: var(--ink-soft); + } + .gestures { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + } + .gestures:empty { + display: none; + } .more { display: grid; gap: 0.9rem; diff --git a/web/src/lib/diff.ts b/web/src/lib/diff.ts new file mode 100644 index 0000000..89e88c3 --- /dev/null +++ b/web/src/lib/diff.ts @@ -0,0 +1,31 @@ +// A typo fix is a sign or two in a long text: shown around its changes only, +// a few words on each side, the rest cut between words. + +import type { DiffPart } from './types'; + +function head(text: string, n: number): string { + if (text.length <= n) return text; + let s = text.slice(0, n); + if (!/\s/.test(text[n] ?? ' ') && /\s/.test(s)) s = s.slice(0, s.search(/\s\S*$/)); + return s.trimEnd(); +} + +function tail(text: string, n: number): string { + if (text.length <= n) return text; + let s = text.slice(-n); + if (!/\s/.test(text[text.length - n - 1] ?? ' ') && /\s/.test(s)) s = s.slice(s.search(/\s/) + 1); + return s.trimStart(); +} + +/** The parts of a diff, the unchanged text kept to `context` signs around each change. */ +export function condense(parts: DiffPart[], context = 40): DiffPart[] { + if (!parts.some((p) => p.op !== 'same')) return parts; + const last = parts.length - 1; + return parts.map((p, i) => { + if (p.op !== 'same') return p; + if (i === 0) return p.text.length <= context ? p : { op: 'same', text: `… ${tail(p.text, context)}` }; + if (i === last) return p.text.length <= context ? p : { op: 'same', text: `${head(p.text, context)} …` }; + if (p.text.length <= 2 * context + 5) return p; + return { op: 'same', text: `${head(p.text, context)} … ${tail(p.text, context)}` }; + }); +} diff --git a/web/src/lib/doc.svelte.ts b/web/src/lib/doc.svelte.ts index 0217acb..d1418de 100644 --- a/web/src/lib/doc.svelte.ts +++ b/web/src/lib/doc.svelte.ts @@ -19,6 +19,30 @@ export interface PanelState { /** The basket, opened on one section. */ section?: string | null; prefill?: { title: string; body: string } | null; + /** A new proposal that answers this objection (ADR 0010). */ + objectionId?: string | null; + /** The details of one's own version, its edit form open: to reword it. */ + edit?: boolean; +} + +/** The tabs as the URL names them. */ +export const TABS: Record = { panier: 'basket', propositions: 'proposals', proposer: 'propose', details: 'details', reglages: 'settings' }; +export const TAB_NAMES: Record = { basket: 'panier', proposals: 'propositions', propose: 'proposer', details: 'details', settings: 'reglages' }; + +/** The query of a reader's URL: the unfolded vote and the panel. */ +export function panelSearch(panel: PanelState | null, vote: string | null): string { + const q = new URLSearchParams(); + if (vote) q.set('vote', vote); + if (panel) { + q.set('onglet', TAB_NAMES[panel.tab]); + if (panel.unitId) q.set('article', panel.unitId); + if (panel.proposalId) q.set('version', panel.proposalId); + if (panel.draftId) q.set('brouillon', panel.draftId); + if (panel.section) q.set('section', panel.section); + if (panel.objectionId) q.set('objection', panel.objectionId); + if (panel.edit) q.set('modifier', '1'); + } + return q.toString(); } export interface Shown { diff --git a/web/src/lib/i18n/en.ts b/web/src/lib/i18n/en.ts index 07c867e..ca08c2d 100644 --- a/web/src/lib/i18n/en.ts +++ b/web/src/lib/i18n/en.ts @@ -11,6 +11,9 @@ export const en: Messages = { 'nav.mandates': 'Mandates', 'nav.protocols': 'Protocols', 'nav.groups': 'My groups', + 'nav.messages': 'Messages', + 'nav.messages.count.one': 'Messages, {n} unread', + 'nav.messages.count.other': 'Messages, {n} unread', 'nav.docs.count.one': '{label}, {n} document in progress', 'nav.docs.count.other': '{label}, {n} documents in progress', 'nav.access': 'Access', @@ -137,7 +140,7 @@ export const en: Messages = { 'deep.addition': 'new article', 'deep.removal': 'removal of “{title}”', 'deep.inertia': 'inertia to {level}', - 'deep.blocked': 'blocked', + 'deep.held': 'not open to vote: objection', 'deep.mine': 'your proposal', 'deep.stale': 'needs reconfirming', 'deep.wellrated': 'well-rated', @@ -198,7 +201,14 @@ export const en: Messages = { 'vote.stale': 'The text has changed in substance since you voted: your vote no longer counts until you reconfirm it.', 'vote.reconfirm': 'Reconfirm: {choice}', 'vote.private': 'Nobody sees your vote: only the totals are visible.', - 'vote.blocked': "A contradiction flag is holding it back: it can't come into force while the flag stands.", + 'vote.held': 'An objection is holding this version back: it cannot be voted on until the objection is lifted. Votes already cast stay.', + 'vote.held.redit': 'It repeats “{other}”.', + 'vote.held.contredit': 'It contradicts “{other}”.', + 'vote.held.author.redit': 'For it to be voted on again, change the article it repeats. The person who raised the objection then lifts it or reiterates it; with no answer from them within 3 days, it is lifted.', + 'vote.held.author.contredit': 'For it to be voted on again, reword it. The person who raised the objection then lifts it or reiterates it; with no answer from them within 3 days, it is lifted.', + 'vote.held.waiting': 'An answer came: {name} lifts or reiterates the objection by {date}; with no answer, it is lifted.', + 'vote.held.mine': 'You raised this objection: lift it when the answer suits you.', + 'vote.held.mine.asked': 'An answer came: lift your objection if it suits you, or reiterate it, by {date}. With no answer from you, it is lifted.', 'vote.entered': '“{title}” has come into force.', 'vote.entered.remove': '“{title}” has been removed from the text in force.', 'vote.entered.inertia': 'The inertia of “{title}” has changed.', @@ -217,9 +227,9 @@ export const en: Messages = { 'vote.choice.none': 'withdrawing your vote', 'vote.chain.note': 'Your vote leaves from your Ğ1 wallet: {amount} Ğ1, in a single gesture. The chain shows that you voted, never what you voted.', 'vote.chain.counts': 'Your voice counts for the Ğ1 identity of the wallet that signs, if it is a member of the web of trust.', - 'vote.chain.off': 'Voting through the Ğ1 chain is not open yet: the account that receives the votes is still to be created. You can read, propose and flag a contradiction.', + 'vote.chain.off': 'Voting through the Ğ1 chain is not open yet: the account that receives the votes is still to be created. You can read, propose and raise an objection.', 'vote.members': 'Only members of this group vote here.', - 'vote.details': 'Typos, translations, flags, history', + 'vote.details': 'Typos, translations, objections, history', 'ballot.reason.not_member': 'the wallet that signed is not that of a member of the Ğ1 web of trust.', 'ballot.reason.no_identity': 'the wallet that signed is linked to no Ğ1 identity.', 'ballot.reason.transfer': 'the transfer to the vote account is missing, or its amount is too low.', @@ -246,6 +256,7 @@ export const en: Messages = { 'basket.sent.other': '{n} votes saved.', 'basket.section': 'Vote on this section', 'basket.mine': 'your vote: {choice}', + 'basket.held': 'not open to vote: an objection holds it back', 'sign.title': 'Vote with your Ğ1 wallet', 'sign.summary.one': '{n} vote, in one transaction: {amount} Ğ1 to the vote account, with your sealed ballot.', 'sign.summary.other': '{n} votes, in one transaction: {amount} Ğ1 to the vote account, with your sealed ballot.', @@ -327,27 +338,98 @@ export const en: Messages = { 'history.typo': 'typo fixed', 'history.substantive': 'substantive change (votes to reconfirm)', 'history.amendment': 'starting text replaced', + 'history.revert': 'fix taken back by the author', 'history.by': 'by {name}', + 'history.typo.validated': 'validated by {name}', + 'history.typo.deadline': 'applied with no answer within 3 days', 'typo.title': 'Fix a typo', - 'typo.note': 'Accents, capitals, typos, words run together or split: the fix applies without a vote, with its author and its diff. Punctuation, figures, formulas, addresses and any change in meaning go through a proposal.', + 'typo.note': "Accents, capitals, typos, words run together or split: the fix is not voted on. On your own text, it applies at once. On someone else's, its author validates it (an administrator for a seed text); with no answer within 3 days, it applies. Punctuation, figures, formulas, addresses and any change in meaning go through a proposal.", 'typo.save': 'Save the fix', 'typo.done': 'Typo fixed, votes untouched.', 'typo.substantive': 'This edit affects the meaning: propose it as a change.', 'typo.asProposal': 'Make it a proposal', + 'typo.pending.author': 'Your fix waits for the author of the text: it applies when they validate it, or by itself on {date}.', + 'typo.pending.admin': 'Your fix waits for an administrator, as this text comes from the seed: it applies when they validate it, or by itself on {date}.', + 'typo.list': 'Suggested typo fixes', + 'typo.by': 'suggested by {name}', + 'typo.status.pending': 'waiting: applies by itself on {date}', + 'typo.status.applied': 'applied', + 'typo.status.refused': 'refused', + 'typo.status.stale': 'out of date: the text changed meanwhile', + 'typo.settled.by': 'by {name}', + 'typo.settled.deadline': 'with no answer within 3 days', + 'typo.validate': 'Validate', + 'typo.refuse': 'Refuse', + 'typo.undo': 'Go back to the text before', + 'typo.redo': 'Apply after all', + 'typo.validated': 'Fix applied.', + 'typo.refused': 'Fix refused.', - 'signal.title': 'Contradictions', - 'signal.none': 'No flags.', - 'signal.add': 'Flag a contradiction', - 'signal.target': 'Contradicts', - 'signal.reason': 'How these two texts contradict each other', - 'signal.send': 'Flag', - 'signal.sent': 'Flag saved.', - 'signal.note': "While a flag stands, these two texts can't be in force together. Its author lifts it; a substantive change makes it lapse.", - 'signal.lift': 'Lift', - 'signal.lifted': 'lifted', - 'signal.lifted.revision': 'lapsed: the text changed', - 'signal.by': 'by {name}', + 'objection.title': 'Objections', + 'objection.none': 'No objection.', + 'objection.add': 'Raise an objection', + 'objection.kind': 'Your objection', + 'objection.kind.redit': 'Repeats', + 'objection.kind.contredit': 'Contradicts', + 'objection.kind.redit.hint': 'this text repeats another article', + 'objection.kind.contredit.hint': 'this text says the opposite of another article', + 'objection.target': 'Which article?', + 'objection.reason.redit': 'How these two texts say the same thing', + 'objection.reason.contredit': 'How these two texts contradict each other', + 'objection.send': 'Send the objection', + 'objection.sent': 'Objection saved: the later of the two versions cannot be voted on until it is lifted.', + 'objection.note': 'An objection makes the later of the two versions not open to vote until it is lifted — never a text in force. Its author answers: for a repetition, they change the article it repeats; for a contradiction, they reword. You then lift the objection or reiterate it; with no answer from you within 3 days, it is lifted.', + 'objection.says.redit': '“{held}” repeats “{other}”', + 'objection.says.contredit': '“{held}” contradicts “{other}”', + 'objection.by': 'objection by {name}', + 'objection.held.here': 'this version cannot be voted on', + 'objection.phase.author': "waiting for the author's answer", + 'objection.phase.objector': 'answer received: lifted or reiterated by {date}, otherwise lifted by itself', + 'objection.answer.revision': 'answer: the held text was reworded', + 'objection.answer.other': 'answer: the other text changed', + 'objection.answer.proposal': 'answer: a linked proposal', + 'objection.lifted.objector': 'lifted by the person who raised it', + 'objection.lifted.admin': 'lifted by an administrator', + 'objection.lifted.deadline': 'lifted: 3 days without follow-up', + 'objection.lifted.withdrawn': 'lifted: the proposal was withdrawn', + 'objection.lifted.revision': 'lapsed: the text changed', + 'objection.lift': 'Lift the objection', + 'objection.lift.admin': 'Lift the objection (administrator)', + 'objection.reiterate': 'Reiterate the objection', + 'objection.done.lifted': 'Objection lifted.', + 'objection.done.reiterated': 'Objection reiterated: a new answer is awaited.', + 'objection.respond.redit': 'Change the article it repeats', + 'objection.respond.contredit': 'Reword', + 'objection.linked': 'This proposal answers an objection: once submitted, the person who raised it lifts it or reiterates it.', + 'messages.title': 'Messages', + 'messages.intro': 'What waits for a gesture from you: an objection to answer, a typo fix to validate. Each message carries its deadline; a message dealt with asks nothing more.', + 'messages.none': 'No messages for now.', + 'messages.readAll': 'Mark all as read', + 'messages.read': 'Mark as read', + 'messages.unread': 'unread', + 'messages.due': 'deadline: {date}', + 'messages.open': 'See it in the text', + 'messages.topic.objection': 'Objection', + 'messages.topic.typo': 'Typo', + 'messages.objection_raised.redit': '{name} raises an objection: your version “{held}” repeats “{other}”. It cannot be voted on until the objection is lifted. To answer, change the article it repeats.', + 'messages.objection_raised.contredit': '{name} raises an objection: your version “{held}” contradicts “{other}”. It cannot be voted on until the objection is lifted. To answer, reword it.', + 'messages.objection_reiterated': '{name} reiterates their objection: “{held}” still cannot be voted on. Answer again.', + 'messages.objection_answered': 'Your objection on “{held}” has been answered. Lift it if the answer suits you, or reiterate it. With no answer from you, it is lifted.', + 'messages.typo_pending.author': '{name} suggests a typo fix in your version “{title}”.', + 'messages.typo_pending.admin': '{name} suggests a typo fix in “{title}”, a seed text: an administrator validates it.', + 'messages.respond': 'Answer', + 'messages.outcome.answered': 'answered', + 'messages.outcome.reiterated': 'objection reiterated', + 'messages.outcome.lifted': 'objection lifted', + 'messages.outcome.admin': 'objection lifted by an administrator', + 'messages.outcome.deadline.objection': 'objection lifted: 3 days without follow-up', + 'messages.outcome.deadline.typo': 'fix applied: 3 days without an answer', + 'messages.outcome.withdrawn': 'proposal withdrawn', + 'messages.outcome.applied': 'fix applied', + 'messages.outcome.refused': 'fix refused', + 'messages.outcome.stale': 'fix out of date: the text changed', + 'messages.outcome.done': 'dealt with', 'propose.mode.replace': 'Change this text', 'propose.mode.remove': 'Remove this article', @@ -458,7 +540,7 @@ export const en: Messages = { 'error.internal': 'Something failed on our side. Try again in a moment.', 'error.title': 'The title is empty or too long (120 characters max).', 'error.body': 'The text is empty or too long (4,000 characters max).', - 'error.reason': 'Say in a few words how these texts contradict each other (10 to 600 characters).', + 'error.reason': 'Say in a few words what your objection rests on (10 to 600 characters).', 'error.substantive_not_author': "This edit affects the meaning: only the proposal's author can make it. Propose your own version.", 'error.substantive_in_force': 'This edit affects the meaning: propose it as a change.', 'error.same_unit': "These two texts target the same article: they can't be in force together anyway.", @@ -483,6 +565,15 @@ export const en: Messages = { 'error.author': 'This account is not a Ğ1 address.', 'error.not_votable': 'This version is no longer open for voting.', 'error.members_only': 'Only members of this group vote here.', + 'error.objection': 'An objection is holding this version back: it cannot be voted on until the objection is lifted.', + 'error.kind': 'Say whether this text repeats the other or contradicts it.', + 'error.other': 'Choose another article, in force or proposed.', + 'error.both_in_force': 'Both texts are in force: an objection always targets a version that is not in force yet.', + 'error.objection_exists': 'You already have an objection standing on these two texts.', + 'error.nothing_to_reiterate': 'Nothing to reiterate: no answer has come yet.', + 'error.lifted': 'This objection is already lifted.', + 'error.settled': 'This fix is already decided.', + 'error.link': 'This proposal cannot answer this objection: it targets another article, or the objection is lifted.', 'error.retry': 'Try again', 'common.cancel': 'Cancel', diff --git a/web/src/lib/i18n/es.ts b/web/src/lib/i18n/es.ts index 854aac4..f566441 100644 --- a/web/src/lib/i18n/es.ts +++ b/web/src/lib/i18n/es.ts @@ -11,6 +11,9 @@ export const es: Messages = { 'nav.mandates': 'Mandatos', 'nav.protocols': 'Protocolos', 'nav.groups': 'Mis grupos', + 'nav.messages': 'Mensajes', + 'nav.messages.count.one': 'Mensajes, {n} sin leer', + 'nav.messages.count.other': 'Mensajes, {n} sin leer', 'nav.docs.count.one': '{label}, {n} documento en curso', 'nav.docs.count.other': '{label}, {n} documentos en curso', 'nav.access': 'Accesos', @@ -137,7 +140,7 @@ export const es: Messages = { 'deep.addition': 'artículo nuevo', 'deep.removal': 'retirada de «{title}»', 'deep.inertia': 'inercia a {level}', - 'deep.blocked': 'bloqueada', + 'deep.held': 'no votable: objeción', 'deep.mine': 'tu propuesta', 'deep.stale': 'por reconfirmar', 'deep.wellrated': 'bien valorada', @@ -198,7 +201,14 @@ export const es: Messages = { 'vote.stale': 'El texto ha cambiado de fondo desde tu voto: tu voto deja de contar hasta que lo reconfirmes.', 'vote.reconfirm': 'Reconfirmar: {choice}', 'vote.private': 'Tu voto no se muestra a nadie: solo se ven los totales.', - 'vote.blocked': 'Un aviso de contradicción la retiene: no puede entrar en vigor mientras se mantenga.', + 'vote.held': 'Una objeción retiene esta versión: no se vota hasta que se levante. Los votos ya emitidos se mantienen.', + 'vote.held.redit': 'Repite «{other}».', + 'vote.held.contredit': 'Contradice «{other}».', + 'vote.held.author.redit': 'Para que vuelva a votarse, modifica el artículo que repite. La persona que hizo la objeción la levanta entonces o la reitera; sin respuesta suya en 3 días, se levanta.', + 'vote.held.author.contredit': 'Para que vuelva a votarse, reformúlala. La persona que hizo la objeción la levanta entonces o la reitera; sin respuesta suya en 3 días, se levanta.', + 'vote.held.waiting': 'Llegó una respuesta: {name} levanta o reitera la objeción antes del {date}; sin respuesta, se levanta.', + 'vote.held.mine': 'Hiciste esta objeción: levántala cuando la respuesta te convenga.', + 'vote.held.mine.asked': 'Llegó una respuesta: levanta tu objeción si te convence, o reitérala, antes del {date}. Sin respuesta tuya, se levanta.', 'vote.entered': '«{title}» ha entrado en vigor.', 'vote.entered.remove': '«{title}» se ha retirado del texto en vigor.', 'vote.entered.inertia': 'La inercia de «{title}» ha cambiado.', @@ -217,9 +227,9 @@ export const es: Messages = { 'vote.choice.none': 'retirar tu voto', 'vote.chain.note': 'Tu voto sale de tu monedero Ğ1: {amount} Ğ1, en un solo gesto. La cadena muestra que has votado, nunca lo que has votado.', 'vote.chain.counts': 'Tu voz cuenta para la identidad Ğ1 del monedero que firma, si es miembro de la red de confianza.', - 'vote.chain.off': 'La votación por la cadena Ğ1 aún no está abierta: falta crear la cuenta que recibe los votos. Puedes leer, proponer y señalar una contradicción.', + 'vote.chain.off': 'La votación por la cadena Ğ1 aún no está abierta: falta crear la cuenta que recibe los votos. Puedes leer, proponer y hacer una objeción.', 'vote.members': 'Aquí solo votan los miembros de este grupo.', - 'vote.details': 'Erratas, traducciones, señalamientos, historial', + 'vote.details': 'Erratas, traducciones, objeciones, historial', 'ballot.reason.not_member': 'el monedero que firmó no es el de un miembro de la red de confianza Ğ1.', 'ballot.reason.no_identity': 'el monedero que firmó no está vinculado a ninguna identidad Ğ1.', 'ballot.reason.transfer': 'falta la transferencia a la cuenta de los votos, o su importe es demasiado bajo.', @@ -246,6 +256,7 @@ export const es: Messages = { 'basket.sent.other': '{n} votos registrados.', 'basket.section': 'Votar esta sección', 'basket.mine': 'tu voto: {choice}', + 'basket.held': 'no votable: una objeción la retiene', 'sign.title': 'Votar con tu monedero Ğ1', 'sign.summary.one': '{n} voto, en una transacción: {amount} Ğ1 a la cuenta de los votos, con tu papeleta sellada.', 'sign.summary.other': '{n} votos, en una transacción: {amount} Ğ1 a la cuenta de los votos, con tu papeleta sellada.', @@ -327,27 +338,98 @@ export const es: Messages = { 'history.typo': 'errata corregida', 'history.substantive': 'cambio de fondo (votos por reconfirmar)', 'history.amendment': 'texto de partida sustituido', + 'history.revert': 'corrección retirada por el autor', 'history.by': 'por {name}', + 'history.typo.validated': 'validada por {name}', + 'history.typo.deadline': 'aplicada sin respuesta en 3 días', 'typo.title': 'Corregir una errata', - 'typo.note': 'Acentos, mayúsculas, erratas, palabras pegadas o separadas: la corrección se aplica sin votación, con su autor y su diff. La puntuación, las cifras, las fórmulas, las direcciones y todo cambio de sentido pasan por una propuesta.', + 'typo.note': 'Acentos, mayúsculas, erratas, palabras pegadas o separadas: la corrección no se vota. En tu texto, se aplica enseguida. En el de otra persona, su autor la valida (un administrador para un texto del seed); sin respuesta en 3 días, se aplica. La puntuación, las cifras, las fórmulas, las direcciones y todo cambio de sentido pasan por una propuesta.', 'typo.save': 'Guardar la corrección', 'typo.done': 'Errata corregida, sin tocar los votos.', 'typo.substantive': 'Este cambio afecta al sentido: proponlo como modificación.', 'typo.asProposal': 'Convertirlo en propuesta', + 'typo.pending.author': 'Tu corrección espera al autor del texto: se aplica cuando la valida, o sola el {date}.', + 'typo.pending.admin': 'Tu corrección espera a un administrador, porque este texto viene del seed: se aplica cuando la valida, o sola el {date}.', + 'typo.list': 'Erratas propuestas', + 'typo.by': 'propuesta por {name}', + 'typo.status.pending': 'en espera: se aplica sola el {date}', + 'typo.status.applied': 'aplicada', + 'typo.status.refused': 'rechazada', + 'typo.status.stale': 'caducada: el texto cambió entretanto', + 'typo.settled.by': 'por {name}', + 'typo.settled.deadline': 'sin respuesta en 3 días', + 'typo.validate': 'Validar', + 'typo.refuse': 'Rechazar', + 'typo.undo': 'Volver al texto anterior', + 'typo.redo': 'Aplicar al final', + 'typo.validated': 'Corrección aplicada.', + 'typo.refused': 'Corrección rechazada.', - 'signal.title': 'Contradicciones', - 'signal.none': 'Ningún aviso.', - 'signal.add': 'Señalar una contradicción', - 'signal.target': 'Contradice', - 'signal.reason': 'En qué se contradicen estos dos textos', - 'signal.send': 'Señalar', - 'signal.sent': 'Aviso registrado.', - 'signal.note': 'Mientras un aviso se mantenga, estos dos textos no pueden estar en vigor a la vez. Su autor lo levanta; un cambio de fondo lo deja sin efecto.', - 'signal.lift': 'Levantar', - 'signal.lifted': 'levantado', - 'signal.lifted.revision': 'sin efecto: el texto ha cambiado', - 'signal.by': 'por {name}', + 'objection.title': 'Objeciones', + 'objection.none': 'Ninguna objeción.', + 'objection.add': 'Hacer una objeción', + 'objection.kind': 'Tu objeción', + 'objection.kind.redit': 'Repite', + 'objection.kind.contredit': 'Contradice', + 'objection.kind.redit.hint': 'este texto repite otro artículo', + 'objection.kind.contredit.hint': 'este texto dice lo contrario de otro artículo', + 'objection.target': '¿Qué artículo?', + 'objection.reason.redit': 'En qué dicen lo mismo estos dos textos', + 'objection.reason.contredit': 'En qué se contradicen estos dos textos', + 'objection.send': 'Enviar la objeción', + 'objection.sent': 'Objeción registrada: la más reciente de las dos versiones no se vota hasta que se levante.', + 'objection.note': 'Una objeción hace no votable la más reciente de las dos versiones hasta que se levante — nunca un texto en vigor. Su autor responde: para una repetición, modifica el artículo que repite; para una contradicción, reformula. Tú levantas entonces la objeción o la reiteras; sin respuesta tuya en 3 días, se levanta.', + 'objection.says.redit': '«{held}» repite «{other}»', + 'objection.says.contredit': '«{held}» contradice «{other}»', + 'objection.by': 'objeción de {name}', + 'objection.held.here': 'esta versión no se vota', + 'objection.phase.author': 'en espera de la respuesta del autor', + 'objection.phase.objector': 'respuesta recibida: levantada o reiterada antes del {date}, si no se levanta sola', + 'objection.answer.revision': 'respuesta: el texto retenido se reformuló', + 'objection.answer.other': 'respuesta: el otro texto cambió', + 'objection.answer.proposal': 'respuesta: una propuesta vinculada', + 'objection.lifted.objector': 'levantada por la persona que la hizo', + 'objection.lifted.admin': 'levantada por un administrador', + 'objection.lifted.deadline': 'levantada: 3 días sin continuación', + 'objection.lifted.withdrawn': 'levantada: la propuesta se retiró', + 'objection.lifted.revision': 'caída: el texto cambió', + 'objection.lift': 'Levantar la objeción', + 'objection.lift.admin': 'Levantar la objeción (administrador)', + 'objection.reiterate': 'Reiterar la objeción', + 'objection.done.lifted': 'Objeción levantada.', + 'objection.done.reiterated': 'Objeción reiterada: se espera una nueva respuesta.', + 'objection.respond.redit': 'Modificar el artículo que repite', + 'objection.respond.contredit': 'Reformular', + 'objection.linked': 'Esta propuesta responde a una objeción: una vez enviada, la persona que la hizo la levanta o la reitera.', + 'messages.title': 'Mensajes', + 'messages.intro': 'Lo que espera un gesto tuyo: una objeción que responder, una corrección de errata que validar. Cada mensaje lleva su plazo; un mensaje tratado ya no pide nada.', + 'messages.none': 'Ningún mensaje por ahora.', + 'messages.readAll': 'Marcar todo como leído', + 'messages.read': 'Marcar como leído', + 'messages.unread': 'sin leer', + 'messages.due': 'plazo: {date}', + 'messages.open': 'Verlo en el texto', + 'messages.topic.objection': 'Objeción', + 'messages.topic.typo': 'Errata', + 'messages.objection_raised.redit': '{name} hace una objeción: tu versión «{held}» repite «{other}». No se vota hasta que se levante. Para responder, modifica el artículo que repite.', + 'messages.objection_raised.contredit': '{name} hace una objeción: tu versión «{held}» contradice «{other}». No se vota hasta que se levante. Para responder, reformúlala.', + 'messages.objection_reiterated': '{name} reitera su objeción: «{held}» sigue sin votarse. Responde de nuevo.', + 'messages.objection_answered': 'Tu objeción sobre «{held}» recibió una respuesta. Levántala si te convence, o reitérala. Sin respuesta tuya, se levanta.', + 'messages.typo_pending.author': '{name} propone una corrección de errata en tu versión «{title}».', + 'messages.typo_pending.admin': '{name} propone una corrección de errata en «{title}», un texto del seed: un administrador la valida.', + 'messages.respond': 'Responder', + 'messages.outcome.answered': 'respuesta dada', + 'messages.outcome.reiterated': 'objeción reiterada', + 'messages.outcome.lifted': 'objeción levantada', + 'messages.outcome.admin': 'objeción levantada por un administrador', + 'messages.outcome.deadline.objection': 'objeción levantada: 3 días sin continuación', + 'messages.outcome.deadline.typo': 'corrección aplicada: 3 días sin respuesta', + 'messages.outcome.withdrawn': 'propuesta retirada', + 'messages.outcome.applied': 'corrección aplicada', + 'messages.outcome.refused': 'corrección rechazada', + 'messages.outcome.stale': 'corrección caducada: el texto cambió', + 'messages.outcome.done': 'tratado', 'propose.mode.replace': 'Modificar este texto', 'propose.mode.remove': 'Retirar este artículo', @@ -458,7 +540,7 @@ export const es: Messages = { 'error.internal': 'Algo ha fallado por nuestra parte. Inténtalo de nuevo en un momento.', 'error.title': 'El título está vacío o es demasiado largo (120 caracteres como máximo).', 'error.body': 'El texto está vacío o es demasiado largo (4000 caracteres como máximo).', - 'error.reason': 'Di en pocas palabras en qué se contradicen estos textos (de 10 a 600 caracteres).', + 'error.reason': 'Di en pocas palabras en qué se basa tu objeción (de 10 a 600 caracteres).', 'error.substantive_not_author': 'Este cambio afecta al sentido: solo el autor de la propuesta puede hacerlo. Propón tu propia versión.', 'error.substantive_in_force': 'Este cambio afecta al sentido: proponlo como modificación.', 'error.same_unit': 'Estos dos textos se refieren al mismo artículo: de todos modos no pueden estar en vigor a la vez.', @@ -483,6 +565,15 @@ export const es: Messages = { 'error.author': 'Esta cuenta no es una dirección Ğ1.', 'error.not_votable': 'Esta versión ya no está en votación.', 'error.members_only': 'Aquí solo votan los miembros de este grupo.', + 'error.objection': 'Una objeción retiene esta versión: no se vota hasta que se levante.', + 'error.kind': 'Di si este texto repite el otro o lo contradice.', + 'error.other': 'Elige otro artículo, en vigor o propuesto.', + 'error.both_in_force': 'Los dos textos están en vigor: una objeción siempre apunta a una versión que aún no lo está.', + 'error.objection_exists': 'Ya tienes una objeción en curso sobre estos dos textos.', + 'error.nothing_to_reiterate': 'Nada que reiterar: aún no ha llegado ninguna respuesta.', + 'error.lifted': 'Esta objeción ya está levantada.', + 'error.settled': 'Esta corrección ya está decidida.', + 'error.link': 'Esta propuesta no puede responder a esta objeción: apunta a otro artículo, o la objeción está levantada.', 'error.retry': 'Reintentar', 'common.cancel': 'Cancelar', diff --git a/web/src/lib/i18n/fr.ts b/web/src/lib/i18n/fr.ts index 6f65cad..91375cc 100644 --- a/web/src/lib/i18n/fr.ts +++ b/web/src/lib/i18n/fr.ts @@ -14,6 +14,9 @@ export const fr = { 'nav.mandates': 'Mandats', 'nav.protocols': 'Protocoles', 'nav.groups': 'Mes groupes', + 'nav.messages': 'Messages', + 'nav.messages.count.one': 'Messages, {n} non lu', + 'nav.messages.count.other': 'Messages, {n} non lus', 'nav.docs.count.one': '{label}, {n} doc en cours', 'nav.docs.count.other': '{label}, {n} docs en cours', 'nav.access': 'Accès', @@ -140,7 +143,7 @@ export const fr = { 'deep.addition': 'nouvel article', 'deep.removal': 'retrait de « {title} »', 'deep.inertia': 'inertie vers {level}', - 'deep.blocked': 'bloquée', + 'deep.held': 'non votable : objection', 'deep.mine': 'ta proposition', 'deep.stale': 'à reconfirmer', 'deep.wellrated': 'bien cotée', @@ -201,7 +204,14 @@ export const fr = { 'vote.stale': "Le texte a changé de fond depuis ton vote : ton vote ne compte plus tant que tu ne l'as pas reconfirmé.", 'vote.reconfirm': 'Reconfirmer : {choice}', 'vote.private': 'Ton vote ne se montre à personne : seuls les totaux sont visibles.', - 'vote.blocked': "Un signalement de contradiction la retient : elle ne peut pas entrer en vigueur tant qu'il tient.", + 'vote.held': "Une objection retient cette version : elle ne se vote plus jusqu'à sa levée. Les votes déjà faits restent.", + 'vote.held.redit': 'Elle redit « {other} ».', + 'vote.held.contredit': 'Elle contredit « {other} ».', + 'vote.held.author.redit': "Pour qu'elle se vote à nouveau, modifie l'article visé. La personne qui a fait l'objection la lève alors, ou la réitère ; sans réponse de sa part sous 3 jours, elle est levée.", + 'vote.held.author.contredit': "Pour qu'elle se vote à nouveau, reformule-la. La personne qui a fait l'objection la lève alors, ou la réitère ; sans réponse de sa part sous 3 jours, elle est levée.", + 'vote.held.waiting': "Une réponse est arrivée : {name} lève ou réitère l'objection d'ici le {date} ; sans réponse, elle est levée.", + 'vote.held.mine': 'Tu as fait cette objection : lève-la quand la réponse te convient.', + 'vote.held.mine.asked': "Une réponse est arrivée : lève ton objection si elle te convient, ou réitère-la, d'ici le {date}. Sans réponse de ta part, elle est levée.", 'vote.entered': '« {title} » est entré en vigueur.', 'vote.entered.remove': '« {title} » est retiré du texte en vigueur.', 'vote.entered.inertia': "L'inertie de « {title} » a changé.", @@ -220,9 +230,9 @@ export const fr = { 'vote.choice.none': 'retrait de ton vote', 'vote.chain.note': 'Ton vote part de ton portefeuille Ğ1 : {amount} Ğ1, en un seul geste. La chaîne montre que tu as voté, jamais ce que tu as voté.', 'vote.chain.counts': "Ta voix compte pour l'identité Ğ1 du portefeuille qui signe, si elle est membre de la toile de confiance.", - 'vote.chain.off': "Le vote par la chaîne Ğ1 n'est pas encore ouvert : le compte qui reçoit les votes reste à créer. Tu peux lire, proposer et signaler une contradiction.", + 'vote.chain.off': "Le vote par la chaîne Ğ1 n'est pas encore ouvert : le compte qui reçoit les votes reste à créer. Tu peux lire, proposer et faire une objection.", 'vote.members': 'Seuls les membres de ce groupe votent ici.', - 'vote.details': 'Coquilles, traductions, signalements, historique', + 'vote.details': 'Coquilles, traductions, objections, historique', 'ballot.reason.not_member': "le portefeuille qui a signé n'est pas celui d'un membre de la toile de confiance Ğ1.", 'ballot.reason.no_identity': "le portefeuille qui a signé n'est rattaché à aucune identité Ğ1.", 'ballot.reason.transfer': 'le virement vers le compte des votes manque, ou son montant est trop faible.', @@ -249,6 +259,7 @@ export const fr = { 'basket.sent.other': '{n} votes enregistrés.', 'basket.section': 'Voter cette section', 'basket.mine': 'ton vote : {choice}', + 'basket.held': 'non votable : une objection la retient', 'sign.title': 'Voter avec ton portefeuille Ğ1', 'sign.summary.one': '{n} vote, en une transaction : {amount} Ğ1 vers le compte des votes, avec ton bulletin scellé.', 'sign.summary.other': '{n} votes, en une transaction : {amount} Ğ1 vers le compte des votes, avec ton bulletin scellé.', @@ -330,27 +341,98 @@ export const fr = { 'history.typo': 'coquille corrigée', 'history.substantive': 'changement de fond (votes à reconfirmer)', 'history.amendment': 'texte de départ remplacé', + 'history.revert': "correction retirée par l'auteur", 'history.by': 'par {name}', + 'history.typo.validated': 'validée par {name}', + 'history.typo.deadline': 'appliquée sans réponse sous 3 jours', 'typo.title': 'Corriger une coquille', - 'typo.note': "Accents, majuscules, faute de frappe, mot collé ou coupé : la correction s'applique sans vote, avec son auteur et son diff. La ponctuation, les chiffres, les formules, les adresses et tout changement de sens passent par une proposition.", + 'typo.note': "Accents, majuscules, faute de frappe, mot collé ou coupé : la correction ne se vote pas. Sur ton texte, elle s'applique tout de suite. Sur celui d'une autre personne, son auteur la valide (un administrateur pour un texte du seed) ; sans réponse sous 3 jours, elle s'applique. La ponctuation, les chiffres, les formules, les adresses et tout changement de sens passent par une proposition.", 'typo.save': 'Enregistrer la correction', 'typo.done': 'Coquille corrigée, sans toucher aux votes.', 'typo.substantive': 'Ce changement touche au sens : propose-le comme modification.', 'typo.asProposal': 'En faire une proposition', + 'typo.pending.author': "Ta correction attend l'auteur du texte : elle s'applique quand il la valide, ou d'elle-même le {date}.", + 'typo.pending.admin': "Ta correction attend un administrateur, ce texte venant du seed : elle s'applique quand il la valide, ou d'elle-même le {date}.", + 'typo.list': 'Coquilles proposées', + 'typo.by': 'proposée par {name}', + 'typo.status.pending': "en attente : s'applique d'elle-même le {date}", + 'typo.status.applied': 'appliquée', + 'typo.status.refused': 'refusée', + 'typo.status.stale': 'périmée : le texte a changé entre-temps', + 'typo.settled.by': 'par {name}', + 'typo.settled.deadline': 'sans réponse sous 3 jours', + 'typo.validate': 'Valider', + 'typo.refuse': 'Refuser', + 'typo.undo': "Revenir au texte d'avant", + 'typo.redo': 'Appliquer finalement', + 'typo.validated': 'Correction appliquée.', + 'typo.refused': 'Correction refusée.', - 'signal.title': 'Contradictions', - 'signal.none': 'Aucun signalement.', - 'signal.add': 'Signaler une contradiction', - 'signal.target': 'Contredit', - 'signal.reason': 'En quoi ces deux textes se contredisent', - 'signal.send': 'Signaler', - 'signal.sent': 'Signalement enregistré.', - 'signal.note': "Tant qu'un signalement tient, ces deux textes ne peuvent pas être en vigueur ensemble. Son auteur le lève ; un changement de fond le fait tomber.", - 'signal.lift': 'Lever', - 'signal.lifted': 'levé', - 'signal.lifted.revision': 'tombé : le texte a changé', - 'signal.by': 'par {name}', + 'objection.title': 'Objections', + 'objection.none': 'Aucune objection.', + 'objection.add': 'Faire une objection', + 'objection.kind': 'Ton objection', + 'objection.kind.redit': 'Redit', + 'objection.kind.contredit': 'Contredit', + 'objection.kind.redit.hint': 'ce texte répète un autre article', + 'objection.kind.contredit.hint': "ce texte dit le contraire d'un autre article", + 'objection.target': 'Quel article ?', + 'objection.reason.redit': 'En quoi ces deux textes disent la même chose', + 'objection.reason.contredit': 'En quoi ces deux textes se contredisent', + 'objection.send': "Envoyer l'objection", + 'objection.sent': "Objection enregistrée : la dernière arrivée des deux versions ne se vote plus jusqu'à sa levée.", + 'objection.note': "Une objection rend la dernière arrivée des deux versions non votable jusqu'à sa levée — jamais un texte en vigueur. Son auteur répond : pour une redite, il modifie l'article visé ; pour une contradiction, il reformule. Tu lèves alors l'objection ou tu la réitères ; sans réponse de ta part sous 3 jours, elle est levée.", + 'objection.says.redit': '« {held} » redit « {other} »', + 'objection.says.contredit': '« {held} » contredit « {other} »', + 'objection.by': 'objection de {name}', + 'objection.held.here': 'cette version ne se vote plus', + 'objection.phase.author': "en attente d'une réponse de l'auteur", + 'objection.phase.objector': "réponse arrivée : levée ou réitérée d'ici le {date}, sinon levée d'elle-même", + 'objection.answer.revision': 'réponse : le texte retenu a été reformulé', + 'objection.answer.other': "réponse : l'autre texte a changé", + 'objection.answer.proposal': 'réponse : une proposition liée', + 'objection.lifted.objector': "levée par la personne qui l'a faite", + 'objection.lifted.admin': 'levée par un administrateur', + 'objection.lifted.deadline': 'levée : 3 jours sans suite', + 'objection.lifted.withdrawn': 'levée : la proposition a été retirée', + 'objection.lifted.revision': 'tombée : le texte a changé', + 'objection.lift': "Lever l'objection", + 'objection.lift.admin': "Lever l'objection (administrateur)", + 'objection.reiterate': "Réitérer l'objection", + 'objection.done.lifted': 'Objection levée.', + 'objection.done.reiterated': 'Objection réitérée : une nouvelle réponse est attendue.', + 'objection.respond.redit': "Modifier l'article visé", + 'objection.respond.contredit': 'Reformuler', + 'objection.linked': "Cette proposition répond à une objection : une fois soumise, la personne qui l'a faite la lève ou la réitère.", + 'messages.title': 'Messages', + 'messages.intro': 'Ce qui attend un geste de toi : une objection à laquelle répondre, une correction de coquille à valider. Chaque message porte son échéance ; un message traité ne demande plus rien.', + 'messages.none': "Aucun message pour l'instant.", + 'messages.readAll': 'Tout marquer comme lu', + 'messages.read': 'Marquer comme lu', + 'messages.unread': 'non lu', + 'messages.due': 'échéance : {date}', + 'messages.open': 'Voir dans le texte', + 'messages.topic.objection': 'Objection', + 'messages.topic.typo': 'Coquille', + 'messages.objection_raised.redit': "{name} fait une objection : ta version « {held} » redit « {other} ». Elle ne se vote plus jusqu'à sa levée. Pour répondre, modifie l'article visé.", + 'messages.objection_raised.contredit': "{name} fait une objection : ta version « {held} » contredit « {other} ». Elle ne se vote plus jusqu'à sa levée. Pour répondre, reformule-la.", + 'messages.objection_reiterated': '{name} réitère son objection : « {held} » ne se vote toujours pas. Réponds à nouveau.', + 'messages.objection_answered': 'Ton objection sur « {held} » a reçu une réponse. Lève-la si elle te convient, ou réitère-la. Sans réponse de ta part, elle est levée.', + 'messages.typo_pending.author': '{name} propose une correction de coquille dans ta version « {title} ».', + 'messages.typo_pending.admin': '{name} propose une correction de coquille dans « {title} », un texte du seed : un administrateur la valide.', + 'messages.respond': 'Répondre', + 'messages.outcome.answered': 'réponse donnée', + 'messages.outcome.reiterated': 'objection réitérée', + 'messages.outcome.lifted': 'objection levée', + 'messages.outcome.admin': 'objection levée par un administrateur', + 'messages.outcome.deadline.objection': 'objection levée : 3 jours sans suite', + 'messages.outcome.deadline.typo': 'correction appliquée : 3 jours sans réponse', + 'messages.outcome.withdrawn': 'proposition retirée', + 'messages.outcome.applied': 'correction appliquée', + 'messages.outcome.refused': 'correction refusée', + 'messages.outcome.stale': 'correction périmée : le texte a changé', + 'messages.outcome.done': 'traité', 'propose.mode.replace': 'Modifier ce texte', 'propose.mode.remove': 'Retirer cet article', @@ -461,7 +543,7 @@ export const fr = { 'error.internal': 'Quelque chose a échoué de notre côté. Réessaie dans un moment.', 'error.title': 'Le titre est vide ou trop long (120 signes au plus).', 'error.body': 'Le texte est vide ou trop long (4 000 signes au plus).', - 'error.reason': 'Dis en quelques mots en quoi ces textes se contredisent (10 à 600 signes).', + 'error.reason': 'Dis en quelques mots ce qui fonde ton objection (10 à 600 signes).', 'error.substantive_not_author': "Ce changement touche au sens : seul l'auteur de la proposition peut le faire. Propose ta propre version.", 'error.substantive_in_force': 'Ce changement touche au sens : propose-le comme modification.', 'error.same_unit': "Ces deux textes visent le même article : ils ne peuvent de toute façon pas être en vigueur ensemble.", @@ -486,6 +568,15 @@ export const fr = { 'error.author': "Ce compte n'est pas une adresse Ğ1.", 'error.not_votable': 'Cette version ne se vote plus.', 'error.members_only': 'Seuls les membres de ce groupe votent ici.', + 'error.objection': "Une objection retient cette version : elle ne se vote plus jusqu'à sa levée.", + 'error.kind': "Dis si ce texte redit l'autre ou le contredit.", + 'error.other': 'Choisis un autre article, en vigueur ou proposé.', + 'error.both_in_force': 'Ces deux textes sont en vigueur : une objection vise toujours une version qui ne l’est pas encore.', + 'error.objection_exists': 'Tu as déjà une objection en cours sur ces deux textes.', + 'error.nothing_to_reiterate': "Rien à réitérer : aucune réponse n'est encore arrivée.", + 'error.lifted': 'Cette objection est déjà levée.', + 'error.settled': 'Cette correction est déjà tranchée.', + 'error.link': 'Cette proposition ne peut pas répondre à cette objection : elle vise un autre article, ou l’objection est levée.', 'error.retry': 'Réessayer', 'common.cancel': 'Annuler', diff --git a/web/src/lib/objections.ts b/web/src/lib/objections.ts new file mode 100644 index 0000000..3c2a298 --- /dev/null +++ b/web/src/lib/objections.ts @@ -0,0 +1,28 @@ +// Answering an objection (Yvv, 2026-10-30: « Pour ce qui constitue une +// répétition → modifier l'article cible ; pour une contradiction → +// reformuler »): where the author of the version held back is taken. + +import type { PanelState } from './doc.svelte'; +import type { Objection } from './types'; + +/** + * A repetition: a proposal that changes the article repeated, linked to the + * objection, starting from its text. A contradiction: one's own open version is + * reworded in place; a seeded one, by a linked proposal on its article. + */ +export function answerPanel(o: Objection, me: string | null): PanelState | null { + const held = o.heldVersion; + if (!held) return null; + if (o.kind === 'redit') { + return { tab: 'propose', unitId: o.other.unitId, proposalId: o.other.id, objectionId: o.id }; + } + if (me && held.authorId === me && held.status === 'open') { + return { tab: 'details', unitId: held.unitId, proposalId: held.id, edit: true }; + } + return { tab: 'propose', unitId: held.unitId, proposalId: held.id, objectionId: o.id }; +} + +/** The objections standing against a version, held back by them. */ +export function holding(objections: Objection[]): Objection[] { + return objections.filter((o) => o.held && o.phase !== 'lifted'); +} diff --git a/web/src/lib/session.svelte.ts b/web/src/lib/session.svelte.ts index fe32d1b..400dea8 100644 --- a/web/src/lib/session.svelte.ts +++ b/web/src/lib/session.svelte.ts @@ -1,11 +1,17 @@ import { api } from './api'; import type { Me } from './types'; +/** How often the number of unread messages is looked at again, while signed in. */ +const UNREAD_EVERY_MS = 60_000; + class Session { me = $state(null); ready = $state(false); g1 = $state(false); dev = $state(false); + /** Unread messages of one's box, next to « Messages » in the menu (ADR 0010). */ + unread = $state(0); + #timer: ReturnType | undefined; async load() { const [me, cfg] = await Promise.all([ @@ -16,11 +22,33 @@ class Session { this.g1 = cfg.g1; this.dev = cfg.dev; this.ready = true; + this.watch(); + } + + /** Looks at the box again: on a navigation, after a gesture, every minute. */ + async refreshUnread() { + if (!this.me) { + this.unread = 0; + return; + } + try { + this.unread = (await api.get<{ unread: number }>('/messages/unread')).unread; + } catch { + /* the count waits for the next look */ + } + } + + /** A sign-in on another page sets `me` directly: the count follows on the next look. */ + private watch() { + clearInterval(this.#timer); + void this.refreshUnread(); + this.#timer = setInterval(() => void this.refreshUnread(), UNREAD_EVERY_MS); } async logout() { await api.post('/auth/logout'); this.me = null; + this.unread = 0; } } diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 01ff98d..52dd63e 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -96,8 +96,12 @@ export interface Proposal { tally: Tally; evaluation: Evaluation | null; votedAtInertia: Inertia; + /** Held back by a standing objection: the latest arrival of the two (never a text in force). */ blocked: boolean; - signals: number; + /** May be voted now: open or in force, and not held back (ADR 0010). */ + votable: boolean; + /** Standing objections around this version. */ + objections: number; myVote: 'for' | 'against' | null; myVoteStale: boolean; /** « Ton vote » was cast on the Ğ1 chain (confirmed). */ @@ -215,3 +219,83 @@ export interface Entered { code: string; kind: 'text' | 'remove' | 'inertia'; } + +export type ObjectionKind = 'redit' | 'contredit'; + +/** A version as an objection names it. */ +export interface VersionRef { + id: string; + title: string | null; + status: Proposal['status'] | null; + unitId: string | null; + code: string | null; + author: string | null; + authorId: string | null; +} + +/** An objection, as the session sees it (ADR 0010). */ +export interface Objection { + id: string; + kind: ObjectionKind; + reason: string; + objector: string | null; + objectorInvited: boolean; + /** This session raised it. */ + mine: boolean; + at: string; + /** The author answers, then the objector lifts or reiterates; or it is lifted. */ + phase: 'author' | 'objector' | 'lifted'; + cycle: number; + /** While the objector is asked: past this, it is lifted. */ + dueAt: string | null; + answer: 'revision' | 'other' | 'proposal' | null; + answerId: string | null; + answeredAt: string | null; + lift: 'objector' | 'admin' | 'deadline' | 'withdrawn' | 'revision' | null; + liftedAt: string | null; + /** The latest arrival of the two, not votable while it stands. */ + heldVersion: VersionRef | null; + other: VersionRef; + /** This session may answer by a proposal linked to it (the author of the held version, or anyone for a seeded one). */ + iAnswer: boolean; + canLift: boolean; + canReiterate: boolean; + /** Around one version: whether that version is the one held back. */ + held?: boolean; +} + +/** A typo fix written by someone else than the author (ADR 0010). */ +export interface TypoFix { + id: string; + status: 'pending' | 'applied' | 'refused' | 'stale'; + writer: string | null; + writerInvited: boolean; + at: string; + dueAt: string; + settledAt: string | null; + /** A name, or `deadline` (passed without an answer), or `changed` (the text moved on). */ + settledBy: string | null; + /** Who answers: the author of the version, or an administrator for a seeded text. */ + answerer: 'author' | 'admin'; + diff: DiffPart[]; + gestures: ('validate' | 'refuse')[]; + version?: { id: string; title: string; status: Proposal['status']; unitId: string; code: string | null }; +} + +/** A message of one's box: about an objection or a typo fix, with its gestures now. */ +export interface Message { + id: string; + topic: 'objection' | 'typo'; + kind: 'objection_raised' | 'objection_answered' | 'objection_reiterated' | 'typo_pending'; + cycle: number; + createdAt: string; + dueAt: string | null; + read: boolean; + /** Dealt with: no gesture any more. */ + done: boolean; + outcome: string | null; + gestures: ('respond' | 'lift' | 'reiterate' | 'validate' | 'refuse')[]; + doc: { id: string; groupSlug: string; slug: string; title: LangMap }; + objection?: Objection; + typo?: TypoFix; +} diff --git a/web/src/lib/versions.ts b/web/src/lib/versions.ts index 24ef7c0..3b7d08e 100644 --- a/web/src/lib/versions.ts +++ b/web/src/lib/versions.ts @@ -22,7 +22,8 @@ export function level(p: Proposal): number { /** * Negative when `a` comes before `b`: the highest water, then more for, then fewer - * against. A blocked version comes after the others: it cannot enter into force. + * against. A version held back by an objection comes after the others: it is not + * votable, and cannot enter into force, until the objection is lifted. */ export function compare(a: Proposal, b: Proposal): number { return ( diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index b73cc5e..787e4e5 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -26,6 +26,7 @@ afterNavigate(() => { drawer = false; + if (session.ready) void session.refreshUnread(); }); @@ -43,7 +44,8 @@
      @@ -157,6 +159,7 @@ text-decoration: none; } .menu { + position: relative; display: grid; place-items: center; width: 2.5rem; @@ -167,5 +170,21 @@ box-shadow: var(--shadow); color: var(--ink); } + /* Unread messages, seen without opening the menu. */ + .badge { + position: absolute; + top: -0.3rem; + right: -0.3rem; + min-width: 1.25rem; + height: 1.25rem; + padding: 0 0.3rem; + border-radius: var(--r-pill); + background: var(--ochre); + color: var(--on-water); + font-size: 0.72rem; + font-weight: 800; + line-height: 1.25rem; + text-align: center; + } } diff --git a/web/src/routes/g/[group]/docs/[doc]/+page.svelte b/web/src/routes/g/[group]/docs/[doc]/+page.svelte index ea76c99..49f9a74 100644 --- a/web/src/routes/g/[group]/docs/[doc]/+page.svelte +++ b/web/src/routes/g/[group]/docs/[doc]/+page.svelte @@ -9,10 +9,12 @@ import UnitBlock from '$lib/components/UnitBlock.svelte'; import VoteFold from '$lib/components/VoteFold.svelte'; import WaveRule from '$lib/components/WaveRule.svelte'; - import { sections } from '$lib/basket'; - import { DocState, shown, type PanelState, type Tab, type View } from '$lib/doc.svelte'; + import { sections, votableIds } from '$lib/basket'; + import { DocState, panelSearch, shown, TABS, type PanelState, type View } from '$lib/doc.svelte'; import { i18n, LANGS, t } from '$lib/i18n/index.svelte'; + import { answerPanel } from '$lib/objections'; import { BasketState } from '$lib/picks.svelte'; + import { session } from '$lib/session.svelte'; import type { Lang, Proposal, Unit } from '$lib/types'; import type { Line } from '$lib/vote'; import { articles, type Filter } from '$lib/versions'; @@ -24,23 +26,30 @@ $effect(() => { void doc.load(group, slug); }); - // The basket of this document, kept for the tab; versions no longer open to vote leave it. + // The basket of this document, kept for the tab; versions no longer open to vote + // (or held back by an objection) leave it. $effect(() => { const v = doc.view; if (!v) return; - const known = new Set(sections(v.units).flatMap((s) => s.slots.flatMap((x) => [...x.versions, ...x.inertia].map((p) => p.id)))); - basket.load(v.doc.id, known); + basket.load(v.doc.id, votableIds(sections(v.units))); }); // The panel and the unfolded vote live in the URL: a link (or a feedback report) reopens them. - const TABS: Record = { panier: 'basket', propositions: 'proposals', proposer: 'propose', details: 'details', reglages: 'settings' }; - const TAB_NAMES: Record = { basket: 'panier', proposals: 'propositions', propose: 'proposer', details: 'details', settings: 'reglages' }; let prefill = $state(null); const panel = $derived.by((): PanelState | null => { const q = page.url.searchParams; const tab = TABS[q.get('onglet') ?? '']; if (!tab) return null; - return { unitId: q.get('article'), proposalId: q.get('version'), tab, draftId: q.get('brouillon'), section: q.get('section'), prefill }; + return { + unitId: q.get('article'), + proposalId: q.get('version'), + tab, + draftId: q.get('brouillon'), + section: q.get('section'), + prefill, + objectionId: q.get('objection'), + edit: q.get('modifier') === '1', + }; }); /** The version whose vote is unfolded under its article (`?vote=`; the old `onglet=vote` too). */ const vote = $derived.by(() => { @@ -50,16 +59,7 @@ function go(nextPanel: PanelState | null, nextVote: string | null) { prefill = nextPanel?.prefill ?? null; - const q = new URLSearchParams(); - if (nextVote) q.set('vote', nextVote); - if (nextPanel) { - q.set('onglet', TAB_NAMES[nextPanel.tab]); - if (nextPanel.unitId) q.set('article', nextPanel.unitId); - if (nextPanel.proposalId) q.set('version', nextPanel.proposalId); - if (nextPanel.draftId) q.set('brouillon', nextPanel.draftId); - if (nextPanel.section) q.set('section', nextPanel.section); - } - const search = q.toString(); + const search = panelSearch(nextPanel, nextVote); void goto(`${page.url.pathname}${search ? `?${search}` : ''}`, { replaceState: (!!panel && !!nextPanel) || (!nextPanel && !panel), noScroll: true, @@ -122,6 +122,7 @@ onopen={(id) => go(panel, id)} onsettings={(id) => openPanel({ unitId: unit.id, proposalId: id, tab: 'settings' })} ondetails={(id) => openPanel({ unitId: unit.id, proposalId: id, tab: 'details' })} + onanswer={(o) => openPanel(answerPanel(o, session.me?.id ?? null))} onsign={sign} /> {/snippet} diff --git a/web/src/routes/messages/+page.svelte b/web/src/routes/messages/+page.svelte new file mode 100644 index 0000000..e0bb3b0 --- /dev/null +++ b/web/src/routes/messages/+page.svelte @@ -0,0 +1,285 @@ + + +{t('messages.title')} · libreDecision + +{#if !session.me} + +{:else if box.error} + box.load()} /> +{:else} +
      +
      +

      {t('messages.title')}

      +

      {t('messages.intro')}

      + {#if unread} + + {/if} +
      + {#if error}{/if} + + {#if box.value} +
        + {#each box.value.messages as m (m.id)} +
      • +

        + {t(`messages.topic.${m.topic}`)} + {m.doc.title[i18n.lang] ?? m.doc.title.fr ?? ''} + {i18n.date(m.createdAt)} + {#if !m.read}{t('messages.unread')}{/if} +

        +

        {textOf(m)}

        + {#if m.objection?.reason && m.kind !== 'objection_answered'}

        {m.objection.reason}

        {/if} + {#if m.typo}{/if} + {#if m.done} +

        {outcomeOf(m)}

        + {:else if m.dueAt} +

        {t('messages.due', { date: i18n.date(m.dueAt) })}

        + {/if} +
        + {#if !m.done} + {#each m.gestures as g (g)} + + {/each} + {/if} + void read(m)}>{t('messages.open')} + {#if !m.read}{/if} +
        +
      • + {:else} +
      • {t('messages.none')}
      • + {/each} +
      + {:else} +

      {t('common.loading')}

      + {/if} +
      +{/if} + + diff --git a/web/tests/unit/basket.test.ts b/web/tests/unit/basket.test.ts index b0c54b0..4b636b8 100644 --- a/web/tests/unit/basket.test.ts +++ b/web/tests/unit/basket.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { clearScope, counts, fill, lines, sections, waiting } from '../../src/lib/basket'; +import { clearScope, counts, fill, lines, sections, votableIds, waiting } from '../../src/lib/basket'; import type { Proposal, Unit } from '../../src/lib/types'; function prop(id: string, unitId: string, over: Partial = {}): Proposal { @@ -7,7 +7,7 @@ function prop(id: string, unitId: string, over: Partial = {}): Proposa id, unitId, kind: 'text', inertiaTarget: null, title: unitId, body: `texte ${id}`, status: 'open', origin: 'seed', label: null, author: null, mine: false, revision: 1, createdAt: '', updatedAt: '', submittedAt: null, enteredAt: null, confirmedAt: null, seedMinimal: false, tally: { for: 0, against: 0 }, evaluation: null, votedAtInertia: 3, - blocked: false, signals: 0, myVote: null, myVoteStale: false, myVoteChain: false, myBallot: null, translations: {}, + blocked: false, votable: true, objections: 0, myVote: null, myVoteStale: false, myVoteChain: false, myBallot: null, translations: {}, ...over, }; } @@ -72,4 +72,26 @@ describe('the basket of a vote', () => { ]); expect(counts({ i1: 'for', e2: 'none', e4: 'against', e5: 'for' })).toEqual({ for: 2, against: 1, none: 1, total: 4 }); }); + + it('a version held back by an objection is shown apart: no grouped gesture fills it, nothing is sent for it', () => { + // E5 has the text in force and a rival held back; E6 is a new article, held back. + const e5 = unit('E5', 'technique', prop('e5', 'E5', { status: 'in_force' }), [ + prop('e5r', 'E5', { blocked: true, votable: false }), + ]); + const e6 = unit('E6', 'technique', null, [prop('e6', 'E6', { blocked: true, votable: false })]); + const s = sections([e5, e6]); + const [slotE5, slotE6] = s[0]?.slots ?? []; + expect(slotE5?.versions.map((p) => p.id)).toEqual(['e5']); + expect(slotE5?.held.map((p) => p.id)).toEqual(['e5r']); + // A held article stays in sight, with nothing to choose. + expect(slotE6?.versions).toEqual([]); + expect(slotE6?.held.map((p) => p.id)).toEqual(['e6']); + const all = s.flatMap((x) => x.slots); + // The held rival does not compete now: the text in force is the only choice. + expect(fill(all, 'for', {})).toEqual({ e5: 'for' }); + expect(waiting(all, {})).toEqual([]); + // A choice kept from before the objection is not sent. + expect(lines({ e5: 'for', e5r: 'for' }, votableIds(s))).toEqual([{ proposalId: 'e5', choice: 'for' }]); + expect(clearScope(all, { e5: 'for', e5r: 'against', i1: 'for' })).toEqual({ i1: 'for' }); + }); }); diff --git a/web/tests/unit/diff.test.ts b/web/tests/unit/diff.test.ts new file mode 100644 index 0000000..30a53c3 --- /dev/null +++ b/web/tests/unit/diff.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { condense } from '../../src/lib/diff'; +import type { DiffPart } from '../../src/lib/types'; + +const text = (parts: DiffPart[]) => parts.map((p) => (p.op === 'same' ? p.text : `[${p.op === 'added' ? '+' : '-'}${p.text}]`)).join(''); + +describe('a typo fix, told around its change', () => { + it('keeps a few words on each side of a change, cut between words', () => { + const parts: DiffPart[] = [ + { op: 'same', text: "Raison d'être\nDerrière une clé il n'y a qu'un seul être humain vivant. C" }, + { op: 'removed', text: "'" }, + { op: 'added', text: '’' }, + { op: 'same', text: "est le seul et unique propos de la certification, qui donne à notre toile fiduciaire sa valeur." }, + ]; + expect(text(condense(parts, 24))).toBe("… être humain vivant. C[-'][+’]est le seul et unique …"); + }); + + it('two changes far apart: the middle is cut; close ones stay together', () => { + const far: DiffPart[] = [ + { op: 'same', text: 'Je m' }, + { op: 'removed', text: "'" }, + { op: 'added', text: '’' }, + { op: 'same', text: 'engage à vérifier la clé publique avec la personne, en sa présence, avant toute certification de son compte, et à n' }, + { op: 'removed', text: "'" }, + { op: 'added', text: '’' }, + { op: 'same', text: 'en rien faire sinon.' }, + ]; + expect(text(condense(far, 20))).toBe("Je m[-'][+’]engage à vérifier la … son compte, et à n[-'][+’]en rien faire sinon."); + const near: DiffPart[] = [ + { op: 'same', text: 'Je m' }, + { op: 'removed', text: "'" }, + { op: 'added', text: '’' }, + { op: 'same', text: 'engage à n' }, + { op: 'removed', text: "'" }, + { op: 'added', text: '’' }, + { op: 'same', text: 'utiliser qu' }, + ]; + expect(condense(near, 20)).toEqual(near); + }); + + it('nothing changed: the text as it is', () => { + const same: DiffPart[] = [{ op: 'same', text: 'Un texte.' }]; + expect(condense(same)).toEqual(same); + }); +}); diff --git a/web/tests/unit/objections.test.ts b/web/tests/unit/objections.test.ts new file mode 100644 index 0000000..1e2c147 --- /dev/null +++ b/web/tests/unit/objections.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { answerPanel, holding } from '../../src/lib/objections'; +import type { Objection, VersionRef } from '../../src/lib/types'; + +const ref = (id: string, over: Partial = {}): VersionRef => ({ + id, title: id, status: 'open', unitId: `u-${id}`, code: id.toUpperCase(), author: null, authorId: null, ...over, +}); + +function objection(over: Partial = {}): Objection { + return { + id: 'o1', kind: 'redit', reason: 'Ces deux textes disent la même chose.', objector: 'Yvv', objectorInvited: false, + mine: false, at: '', phase: 'author', cycle: 1, dueAt: null, answer: null, answerId: null, answeredAt: null, + lift: null, liftedAt: null, heldVersion: ref('k1', { author: 'Chiara', authorId: 'dev:chiara' }), + other: ref('e2', { status: 'in_force' }), iAnswer: true, canLift: false, canReiterate: false, held: true, ...over, + }; +} + +describe('answering an objection', () => { + it('a repetition: a proposal linked to it, on the article repeated, from its text', () => { + expect(answerPanel(objection(), 'dev:chiara')).toEqual({ tab: 'propose', unitId: 'u-e2', proposalId: 'e2', objectionId: 'o1' }); + }); + + it('a contradiction: one rewords one’s own version in place', () => { + expect(answerPanel(objection({ kind: 'contredit' }), 'dev:chiara')).toEqual({ tab: 'details', unitId: 'u-k1', proposalId: 'k1', edit: true }); + }); + + it('a contradiction on a seeded version: a linked proposal on its article', () => { + const o = objection({ kind: 'contredit', heldVersion: ref('k1') }); + expect(answerPanel(o, 'dev:chiara')).toEqual({ tab: 'propose', unitId: 'u-k1', proposalId: 'k1', objectionId: 'o1' }); + }); + + it('nothing held back, nothing to answer; a lifted objection holds nothing', () => { + expect(answerPanel(objection({ heldVersion: null }), 'dev:chiara')).toBeNull(); + const list = [objection(), objection({ id: 'o2', phase: 'lifted' }), objection({ id: 'o3', held: false })]; + expect(holding(list).map((o) => o.id)).toEqual(['o1']); + }); +}); diff --git a/web/tests/unit/versions.test.ts b/web/tests/unit/versions.test.ts index 5276c76..383086a 100644 --- a/web/tests/unit/versions.test.ts +++ b/web/tests/unit/versions.test.ts @@ -8,7 +8,7 @@ function prop(id: string, unitId: string, over: Partial = {}): Proposa label: null, author: null, mine: false, revision: 1, createdAt: '', updatedAt: '', submittedAt: null, enteredAt: null, confirmedAt: null, seedMinimal: false, tally: { for: 0, against: 0 }, evaluation: { cast: 0, required: 50, reached: false, missing: 50, requiredRatio: 1, wellRated: false }, - votedAtInertia: 3, blocked: false, signals: 0, myVote: null, myVoteStale: false, myVoteChain: false, myBallot: null, translations: {}, + votedAtInertia: 3, blocked: false, votable: true, objections: 0, myVote: null, myVoteStale: false, myVoteChain: false, myBallot: null, translations: {}, ...over, }; }