Fusion du lot F : objections redit / contredit, coquilles validées par leur auteur, messagerie, délai de 3 jours

Conflits avec B2 : docs.rs garde shown_tally / shown_evaluation et votable ;
proposals.rs sert les totaux montrés et « votable » ; CONTROLES réunit C24
(« faire une objection », le panier par son adresse), C28–C33 et les deux passages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-31 05:57:21 +01:00
co-authored by Claude Opus 5.5
55 changed files with 5435 additions and 456 deletions
+20 -1
View File
@@ -26,6 +26,7 @@
afterNavigate(() => {
drawer = false;
if (session.ready) void session.refreshUnread();
});
</script>
@@ -43,7 +44,8 @@
<header class="topbar">
<a class="brand" href="/"><Logo size={1.2} /></a>
<button class="menu" type="button" aria-expanded={drawer} aria-controls="drawer" onclick={() => (drawer = true)}>
<Icon name="menu" /><span class="visually-hidden">{t('nav.menu')}</span>
<Icon name="menu" /><span class="visually-hidden">{t('nav.menu')}{#if session.unread} · {i18n.plural('nav.messages.count', session.unread)}{/if}</span>
{#if session.unread}<span class="badge" aria-hidden="true">{session.unread}</span>{/if}
</button>
</header>
@@ -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;
}
}
</style>
@@ -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<string, Tab> = { panier: 'basket', propositions: 'proposals', proposer: 'propose', details: 'details', reglages: 'settings' };
const TAB_NAMES: Record<Tab, string> = { basket: 'panier', proposals: 'propositions', propose: 'proposer', details: 'details', settings: 'reglages' };
let prefill = $state<PanelState['prefill']>(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}
+285
View File
@@ -0,0 +1,285 @@
<script lang="ts">
// The box « Messages » of trustinWallet, in the application (2026-10-31, lot F,
// ADR 0010): what waits for a gesture of the person — an objection to answer,
// to lift or to reiterate, a typo fix to validate or refuse —, each with its
// deadline. A message dealt with offers no gesture any more. No mail.
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import DiffText from '$lib/components/DiffText.svelte';
import Guard from '$lib/components/Guard.svelte';
import { condense } from '$lib/diff';
import { panelSearch } from '$lib/doc.svelte';
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
import { Remote } from '$lib/load.svelte';
import { answerPanel } from '$lib/objections';
import { session } from '$lib/session.svelte';
import { toasts } from '$lib/toast.svelte';
import type { Message } from '$lib/types';
import { errorText } from '$lib/vote';
const box = new Remote(() => api.get<{ unread: number; messages: Message[] }>('/messages'));
onMount(() => {
if (session.me) void box.load();
});
let busy = $state(false);
let error = $state('');
const unread = $derived(box.value?.unread ?? 0);
async function reload() {
await box.load();
session.unread = box.value?.unread ?? session.unread;
}
async function read(m: Message) {
if (m.read) return;
const r = await api.post<{ unread: number }>(`/messages/${m.id}/read`).catch(() => null);
if (r) session.unread = r.unread;
await reload();
}
async function readAll() {
await api.post('/messages/read').catch(() => null);
await reload();
}
/** The text of a message, in the language of the reader, from its subject. */
function textOf(m: Message): string {
const o = m.objection;
const fix = m.typo;
if (o) {
const held = o.heldVersion?.title ?? '';
if (m.kind === 'objection_raised') return t(`messages.objection_raised.${o.kind}`, { name: o.objector ?? '', held, other: o.other.title ?? '' });
if (m.kind === 'objection_reiterated') return t('messages.objection_reiterated', { name: o.objector ?? '', held });
return t('messages.objection_answered', { held });
}
if (fix) return t(fix.answerer === 'admin' ? 'messages.typo_pending.admin' : 'messages.typo_pending.author', { name: fix.writer ?? '', title: fix.version?.title ?? '' });
return '';
}
function outcomeOf(m: Message): string {
const o = m.outcome;
if (!o) return t('messages.outcome.done');
if (o === 'deadline') return t(m.topic === 'typo' ? 'messages.outcome.deadline.typo' : 'messages.outcome.deadline.objection');
const key = `messages.outcome.${o}`;
return key in OUTCOMES ? t(key as Key) : t('messages.outcome.done');
}
const OUTCOMES: Record<string, true> = Object.fromEntries(
['answered', 'reiterated', 'lifted', 'admin', 'withdrawn', 'applied', 'refused', 'stale'].map((k) => [`messages.outcome.${k}`, true]),
);
/** The version a message is about, in the reader: its details. */
function hrefOf(m: Message): string {
const v = m.objection?.heldVersion ?? m.typo?.version ?? null;
const base = `/g/${m.doc.groupSlug}/docs/${m.doc.slug}`;
if (!v) return base;
return `${base}?${panelSearch({ tab: 'details', unitId: v.unitId ?? null, proposalId: v.id }, null)}`;
}
async function gesture(m: Message, g: Message['gestures'][number]) {
error = '';
if (g === 'respond' && m.objection) {
const panel = answerPanel(m.objection, session.me?.id ?? null);
void read(m);
await goto(`/g/${m.doc.groupSlug}/docs/${m.doc.slug}?${panelSearch(panel, null)}`);
return;
}
busy = true;
try {
if ((g === 'lift' || g === 'reiterate') && m.objection) {
await api.post(`/objections/${m.objection.id}/${g}`);
toasts.show(t(g === 'lift' ? 'objection.done.lifted' : 'objection.done.reiterated'));
} else if ((g === 'validate' || g === 'refuse') && m.typo) {
await api.post(`/typos/${m.typo.id}/${g}`);
toasts.show(t(g === 'validate' ? 'typo.validated' : 'typo.refused'));
}
await reload();
} catch (e) {
error = errorText(e);
await reload();
} finally {
busy = false;
}
}
function label(m: Message, g: Message['gestures'][number]): string {
switch (g) {
case 'respond':
return m.objection ? t(m.objection.kind === 'redit' ? 'objection.respond.redit' : 'objection.respond.contredit') : t('messages.respond');
case 'lift':
return t('objection.lift');
case 'reiterate':
return t('objection.reiterate');
case 'validate':
return t('typo.validate');
case 'refuse':
return t('typo.refuse');
}
}
</script>
<svelte:head><title>{t('messages.title')} · libreDecision</title></svelte:head>
{#if !session.me}
<Guard code="unauthenticated" />
{:else if box.error}
<Guard code={box.error} onretry={() => box.load()} />
{:else}
<div class="page">
<header class="head">
<h1>{t('messages.title')}</h1>
<p class="muted">{t('messages.intro')}</p>
{#if unread}
<button class="btn small" type="button" onclick={readAll}>{t('messages.readAll')}</button>
{/if}
</header>
{#if error}<p class="warn" role="alert">{error}</p>{/if}
{#if box.value}
<ul class="box">
{#each box.value.messages as m (m.id)}
<li class="message" class:unread={!m.read} class:done={m.done}>
<p class="top">
<span class="tag" class:ochre={m.topic === 'objection'} class:water={m.topic === 'typo'}>{t(`messages.topic.${m.topic}`)}</span>
<span class="doc">{m.doc.title[i18n.lang] ?? m.doc.title.fr ?? ''}</span>
<span class="faint">{i18n.date(m.createdAt)}</span>
{#if !m.read}<span class="new">{t('messages.unread')}</span>{/if}
</p>
<p class="text">{textOf(m)}</p>
{#if m.objection?.reason && m.kind !== 'objection_answered'}<p class="reason">{m.objection.reason}</p>{/if}
{#if m.typo}<DiffText parts={condense(m.typo.diff)} />{/if}
{#if m.done}
<p class="outcome">{outcomeOf(m)}</p>
{:else if m.dueAt}
<p class="due">{t('messages.due', { date: i18n.date(m.dueAt) })}</p>
{/if}
<div class="actions">
{#if !m.done}
{#each m.gestures as g (g)}
<button class="btn small" class:primary={g === 'respond' || g === 'lift' || g === 'validate'} type="button" disabled={busy} onclick={() => gesture(m, g)}>{label(m, g)}</button>
{/each}
{/if}
<a class="linkish" href={hrefOf(m)} onclick={() => void read(m)}>{t('messages.open')}</a>
{#if !m.read}<button class="linkish" type="button" onclick={() => read(m)}>{t('messages.read')}</button>{/if}
</div>
</li>
{:else}
<li class="empty muted">{t('messages.none')}</li>
{/each}
</ul>
{:else}
<p class="faint">{t('common.loading')}</p>
{/if}
</div>
{/if}
<style>
.page {
max-width: 44rem;
margin: 0 auto;
padding: clamp(2.2rem, 7vh, 4.5rem) 1.3rem 5rem;
display: grid;
gap: 1.4rem;
}
.head {
display: grid;
gap: 0.7rem;
}
.head .btn {
justify-self: start;
}
h1 {
font-family: var(--font-text);
font-weight: 500;
font-size: clamp(2rem, 1.5rem + 1.6vw, 2.5rem);
}
.box {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.8rem;
}
.message {
display: grid;
gap: 0.5rem;
padding: 1rem 1.1rem;
border-radius: var(--r-card);
background: var(--surface);
box-shadow: var(--shadow);
}
.message.unread {
background: color-mix(in srgb, var(--surface) 88%, var(--water));
}
.message.done {
opacity: 0.78;
box-shadow: none;
background: var(--surface-2);
}
.top {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem 0.6rem;
font-size: 0.84rem;
}
.doc {
font-weight: 750;
}
.new {
margin-left: auto;
padding: 0.1rem 0.55rem;
border-radius: var(--r-pill);
background: var(--water);
color: var(--on-water);
font-size: 0.75rem;
font-weight: 800;
}
.text {
font-size: 1rem;
line-height: 1.5;
}
.reason {
font-family: var(--font-text);
font-style: italic;
color: var(--ink-soft);
font-size: 0.92rem;
}
.due {
font-weight: 750;
color: var(--ochre);
font-size: 0.88rem;
}
.outcome {
font-weight: 700;
color: var(--ink-soft);
font-size: 0.88rem;
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem 0.8rem;
}
.linkish {
border: 0;
background: none;
padding: 0.2rem 0;
min-height: 2.25rem;
display: inline-flex;
align-items: center;
color: var(--water);
font-weight: 750;
font-size: 0.9rem;
text-decoration: underline;
text-underline-offset: 3px;
}
.warn {
color: var(--ochre);
font-weight: 700;
}
.empty {
padding: 1rem 0;
}
</style>