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 <noreply@anthropic.com>
286 lines
9.1 KiB
Svelte
286 lines
9.1 KiB
Svelte
<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>
|