@micha.bigler/ui-core-micha 2.21.3 → 2.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/WORK_ORDERS.md +1 -0
- package/dist/i18n/messagingTranslations.js +2 -1
- package/dist/messaging/ConversationList.js +8 -1
- package/dist/messaging/ReadTicks.js +52 -10
- package/dist/messaging/Thread.js +29 -1
- package/package.json +1 -1
- package/src/i18n/messagingTranslations.ts +2 -1
- package/src/messaging/ConversationList.jsx +8 -1
- package/src/messaging/ReadTicks.jsx +55 -8
- package/src/messaging/Thread.jsx +24 -1
- package/tests/ConversationList.test.jsx +52 -0
- package/tests/Thread.test.jsx +9 -8
- package/tests/messagingChunk6.test.jsx +16 -9
- package/tests/messagingCompactBubble.test.jsx +3 -3
- package/tests/messagingMsg6.test.jsx +24 -9
- package/tests/messagingMsg6f.test.jsx +155 -0
- package/tests/messagingThreadReceipts.test.jsx +10 -2
- package/tests/messagingThreadingQuoting.test.jsx +1 -1
- package/work-orders/MSG-6f.md +214 -0
package/WORK_ORDERS.md
CHANGED
|
@@ -38,3 +38,4 @@ prefixed ID; never reuse a bare flat number across workstreams.
|
|
|
38
38
|
| MSG-6c | Three MSG-3f/MSG-6 follow-on bugs: scrollbar, DM announcement icon, direct-conversation names | Reported live on jg-ferien staging right after MSG-3f/MSG-6 shipped: (1) a horizontal scrollbar appeared under short bubbles; (2) the Composer showed a broadcast/announcement icon even in a 1:1 direct conversation; (3) direct conversations showed the generic "Unbenannte Unterhaltung" fallback instead of the counterpart's name. | 2026-08-02 | done | 50e966e | No WO file — three operator-reported bugs, fixed directly. **(1) Scrollbar**: `Thread.jsx`'s scroll `Box` had `overflowY: auto` only, leaving `overflowX` at its browser-promoted `auto` default — any few-px overflow anywhere produced a persistent horizontal scrollbar. Fixed with explicit `overflowX: hidden`, plus `MessageBubble.jsx`'s meta row got `flexWrap`/`minWidth: 0` so it can't force the shrink-to-content bubble wider than its `maxWidth` cap. `ui_reviewer` pass 1 found a real P1 regression risk in this fix: MSG-3f's overflow icon (`top: -12, right: -12`) could get clipped by the new `overflowX: hidden` for own/right-aligned bubbles in a short conversation with no vertical scrollbar (the exact scenario in the original bug screenshot) — fixed with extra right padding (moved onto the inner message `Stack`, not the outer scroll container, after `ui_reviewer` pass 2 flagged a P3 cosmetic side effect where the shared container's asymmetric padding skewed the centered "Load older" button; both re-verified live in the harness: no clipping with 8.8px margin, "Load older" back to 0px off-center). **(2) Announcement icon**: `Composer` now accepts a `conversation` prop; `canAnnounce = allowAnnouncement && conversation?.kind !== 'direct'` gates both the icon and the dialog — backward compatible (a host not passing `conversation` behaves exactly as before). **(3) Unnamed conversations**: `ConversationList.jsx`'s `titleOf` now tries a new optional `resolveDirectUserName(other_user_id)` host resolver before falling back to the generic label, consuming dcm's new `other_user_id` field (see django-core-micha `MSG-6b`) — bare id only, ucm still resolves no names itself. Independent `reviewer`: no findings (both passes). Scoped gate: 90/90 across 14 test files. Published as npm 2.21.1, verified live. jg-ferien pin bumped 2.21.0→2.21.1 and wired: `conversation` prop passed to `Composer`, `resolveDirectUserName` supplied from jg's existing DM-candidate data (best-effort — a counterpart no longer among current candidates, e.g. a lapsed shared event, falls back to the generic label; accepted gap, not chased). |
|
|
39
39
|
| MSG-3f | Compact chat-bubble message layout | A one-word message currently renders as **six stacked rows** (~260px): `MessageBubble`'s vertical `Stack spacing={0.75}` gives every element its own full-width row — meta line, body, `ReactionBar`, the reply control (a full MUI `Button`, hence "Antworten" centred on its own line), the overflow `IconButton`, and `children` where the host's `ReadTicks` lands as a "Zugestellt an 0" text line. Rebuild as WhatsApp-style bubbles: own right-aligned and tinted, incoming left-aligned and neutral, shrink-to-content; **timestamp and status icons inline at the end of the final text line** (this is the line-count fix, everything else is cosmetic); status as tick icons instead of text, own messages only; reply/react/edit/delete/copy collapsed into one hover-or-long-press menu; reaction chips overlapping the bubble edge; reply quote as a tinted block inside the bubble. **Acceptance: a short message with no reactions/attachments/quote occupies at most TWO visual lines** (text plus, at most, a narrow right-aligned meta line inside the bubble) — down from six. | 2026-08-02 | done | 1cd777b | Envelope + map: [work-orders/MSG-3f.md](work-orders/MSG-3f.md). Independent `reviewer`: no findings. `ui_reviewer` pass 1 (static-only, no browser tooling that session): one P2 (U1) — the touch-tap reveal (`actionsVisible`) had no dismiss path, permanently pinning the overflow icon visible after any click that bubbled to the bubble (poll option, announcement link, reply quote), violating the "no visible affordance at rest" requirement; one P3 (U2, a consequence of U1). Fixed by the Orchestrator: a `bubbleRef` spanning the Paper + reaction-picker sibling, plus a document-level capture-phase `pointerdown` listener that dismisses `actionsVisible` on any outside interaction — live-verified in the running dev harness (reveal, dismiss-on-outside-click, and reaction-chip-click-does-not-dismiss all confirmed via DOM/computed-style inspection; screenshot tooling was unavailable this session, verification was structural/computed-style based). `ui_reviewer` pass 2 (re-review of the fix): confirmed the fix sound (event-phase ordering, per-instance isolation, cleanup all correct), but found one more gap (U3, P2) — no regression test pinned the dismiss behavior. Closed by adding two tests to `tests/messagingMessageActions.test.jsx`: outside-click dismiss + reaction-chip-does-not-dismiss, and cross-instance isolation (two bubbles, revealing one doesn't affect the other). Scoped gate: 42/42 passing across all 7 named test files. Acceptance criterion verified live: a short incoming direct-conversation message (no sender name, no reactions) renders at exactly 2 visual lines, 78px total height (down from ~260px). Published as npm 2.21.0, verified live on the registry. New `ui-core-micha-dev-harness` launch.json entry added (port 5140) for this and future ucm visual-verification WOs. | Approval Gate #1 granted by the operator 2026-08-02, who also supplied WhatsApp reference screenshots; the envelope records what those pin down beyond the prose (meta rides the LAST line of a multi-line body, not a line of its own; incoming messages carry no status icon at all; **no** action affordance visible at rest; meta visually recessed with read state carried by tick colour). **Tier 2** — shared-core, and an opinionated visual decision imposed on every future ucm messaging consumer. Acceptable now because jg-ferien is the only messaging consumer today (spesix deferred per the messaging-centralization course correction), so it lands with one adopter instead of being retrofitted — but jg vocabulary/layout assumptions must not leak in, same line MSG-3d drew for `external_key`. **Two non-negotiables:** every icon keeps the text it replaced as its accessible name (no i18n key deleted just because its text stopped rendering), and the action affordance must be keyboard-operable without hover or long-press — today's `opacity: 0` hover-only menu is unreachable on touch AND invisible to keyboard users, and reproducing that pre-existing defect is not acceptable. **Two deliberate cost-cuts by the operator (2026-08-02), both after being shown what they cost:** (1) the meta does **not** have to ride the last text line — inside the bubble and right-aligned is enough, a trailing meta line is fine; the inline pattern is listed as a risk to be *avoided*, not managed. (2) **No long-press and no swipe** — desktop uses hover plus keyboard focus (`opacity: 0` does not remove an element from the tab order, so a `:focus-within` rule closes the keyboard half cheaply), touch uses a plain tap on the bubble; React expands the existing `ReactionBar` emoji row rather than being nested inside the MUI `Menu`, which does not nest gracefully. **Trap found while scoping:** today the whole menu is gated on `canAct` (own message or moderator) because it only held Edit/Delete/Copy — once Reply lives there an **incoming** message needs a menu too, so there are two variants (any message: Reply/React/Copy; actionable message: plus Edit/Delete). Getting this wrong makes replying to anyone else impossible and will not surface in a fixture containing only own messages. Remaining risk: announcements/polls/attachments also render through this bubble and will look broken if only plain text is considered. **Read-status observation, deliberately NOT a precondition:** `ReadTicks` fetches per message and dcm's `serialize_message` carries no read state, so a thread fires one REST call per own message; tick icons make it more visible but not worse (`canShowReadTicks` already bounds it to own messages, deliberately). Putting the delivered/read aggregate on `serialize_message` — viewer-independent, exactly as `reply_count`/`last_reply_at` ride it since MSG-3d — would remove the fan-out and is a worthwhile dcm follow-up, but gating a UI fix behind a dcm release is the wrong trade. Deferred: swipe-to-reply, and the (also tall) Composer. Independent of MSG-3e — different files, but coordinate version bumps if both are in flight. |
|
|
40
40
|
| MSG-6e | Messaging surface defects found by click-through: timestamps, poll cache key, read-state frames, double read-marking, plurals | Six independent defects reproduced in a running app: (A) `MessageBubble.jsx:133` formats with a bare `toLocaleString()` — runtime locale, not i18next's, and the full date instead of `HH:MM` + a day separator; (B) `createConversationPoll` (`MessagingProvider.jsx:403`) does `poll?.message \|\| poll`, but dcm's `_poll_response` has no `message` key, so a phantom entry is cached under the POLL id with no `created_at`; (C) `applyFrame` discards dcm's `read_state`/`delivered`/`thread_read_state` frames; (D) `markConversationRead` fires twice per open (Thread's useEffect + ConversationList's onClick); (E) `SHOW_REPLIES` has no plural forms. | 2026-08-03 | done | 3c7cfbd | Envelope: [work-orders/MSG-6e.md](work-orders/MSG-6e.md). **Reviewed independently: `reviewer` + `ui_reviewer` (requested per envelope for scope A), both clean after one fix round.** Fix round 1 closed a **P1** (`reviewer`): the read-state receipt fix only worked through `recipient_detail`, which dcm never returns for direct (1:1) conversations — the most common case — so `ReadTicks` silently never updated for DMs; fixed by deriving the receipt from `cache.receipts` keyed by `conversation.other_user_id` for the direct case. **Scope C landed narrower than the original envelope** (see the WO's "OPERATOR DECISIONS" section): only `read_state`/`thread_read_state` are handled — `delivered` was deliberately left out, since `django-core-micha` MSG-7 (same release) retires its only publisher (`mark_delivered`). **Not fixed, tracked as follow-ups (P2/P3, `ui_reviewer`, non-blocking):** the day-separator is a bespoke `Divider`+`Typography` pair rather than reusing `LoginForm.jsx`'s existing divider-with-label convention; the separator text has no `nowrap` guard (unlike `MessageBubble`'s timestamp); the first day in a thread never gets a separator (matches the WO's literal spec, not a deviation). Full suite 215→222 passed, 0 failed; `tsc` clean. jg's pin bump is a separate step, still NOT part of this WO — jg also needs `django-core-micha` MSG-7. **B is the one that corrupts state and the most instructive:** the `\|\| poll` fallback turns a wrong assumption about the response shape into silent data corruption instead of an error — observed as a fresh poll rendering with no timestamp and sorting to the TOP of the timeline, above messages from two days earlier, because `chronological()` sorts on `new Date(undefined)` → NaN. Correct after reload. **Explicit non-goal: do NOT touch `isOwn`/`canShowReadTicks`/`senderName`** — the own/incoming alignment, missing ticks and "Unbekannte Person" all trace to dcm never sending a `sender` object; ucm's code is already correct (`MessageBubble.jsx:100/108/109` implements the alignment in full, it just never sees `isOwn === true`). That fix is `django-core-micha` MSG-7. **Ordering caveat on C:** jg MSG-9 finding 1 (live WS push ~10 min late) is undiagnosed — handling frames that do not arrive fixes nothing, so do C last and report if finding 1 is still open. jg's pin bump is NOT part of this WO: jg also needs dcm MSG-7, and bumping one alone leaves the surface half-fixed. |
|
|
41
|
+
| MSG-6f | `titleOf` never calls the host resolver for a broadcast; the read ticks claim a delivery that does not exist | (A) `ConversationList.jsx` `titleOf()` gates the host callback on `conversation.kind === 'managed'`, so jg-ferien MSG-10's correct broadcast branch in `resolveManagedConversationLabel` is **unreachable from the list** — the row still falls through to `MessagingList.UNTITLED`. (B) dcm MSG-7 removed `delivered_count` from `read-status`, but `ReadTicks.jsx` still renders `t('MessagingReadTicks.DELIVERED', { count: renderedStatus.delivered_count \|\| 0 })`, so `undefined \|\| 0` produces the identical "Zugestellt an 0" label as before — a claimed delivery with no data source behind it. (C) a viewer already sitting in an open thread when a new message lands is recorded as "not read" until they navigate away and back — the mount-effect only re-marks once per open. | 2026-08-03 | done | (pending push) | Envelope: [work-orders/MSG-6f.md](work-orders/MSG-6f.md); all three scopes released together per operator's critical-path directive (one release instead of two, since A and C never depended on dcm) — this also stands as the operator confirmation the envelope asked for before implementing B's vocabulary change. **A:** kind gate removed entirely from `titleOf()`; a host resolver returning `null` already means "no label", so gating duplicated a decision that belongs to the host. **B:** `DELIVERED` retired; dcm MSG-9 (published 2.40.0) added `read_count`/`recipient_count` to `read-status`, gated the same way as `recipient_detail` (non-direct, `read_receipt_detail`-eligible). `ReadTicks.jsx` now renders: counts present → a read ratio (`READ_RATIO`, e.g. "18/40"), no counts + `direct` → two-state tick (`SENT`/`ALL_READ`), no counts + non-direct → nothing (was a permanently-grey "always delivered" tick before). **C:** `Thread.jsx` now also subscribes to `MESSAGING_ENVELOPE` directly (independent of `MessagingProvider`'s own subscription — the registry supports multiple handlers per envelope) and re-marks read on a live `message` frame for the open conversation, skipped when the tab is hidden. **Reviewed independently: `reviewer` + `ui_reviewer`, both clean after one fix round.** Fix round 1 closed a **P1** (`reviewer`, self-caught during the fix, not shipped): the ratio was briefly recomputed client-side from `recipientDetail.filter(r => r.last_read_at).length` to make it "live-update" from `read_state` frames — this silently contradicts the WO's explicit instruction and is wrong, because `recipientDetail`'s `last_read_at` is never thresholded against the specific message's `created_at` the way dcm's own aggregate is (`last_read_at__gte=message.created_at`, dcm `services.py`); in a real group with history it would read as close to N/N regardless of who actually saw *this* message. Reverted to trusting dcm's `read_count`/`recipient_count` verbatim, with the trade-off documented in `ReadTicks.jsx`: the ratio no longer live-updates from a `read_state` frame, only on the next read-status fetch (unlike the DM tick and the per-person popover, both viewer/timestamp-relative facts that safely reuse `cache.receipts`). Also fixed a P3 (`ui_reviewer`): the ratio's `color="text.secondary"` diverged from the sibling meta row's `inherit` convention, changed to `inherit`. **Known, disclosed, non-blocking follow-up (`ui_reviewer` P2, not fixed here):** the DM tick's fixed `grey.500`/`primary.main` colour pair is not fully dark-mode contrast-neutral — verified by hand (WCAG-style luminance/contrast math against both themes' actual palette values, not just accepting the reviewer's claim): light-mode state separation ≈1.71:1, dark-mode ≈1.53:1 (worse, since `primary.main` lightens substantially in dark mode while `grey.500` is a fixed, non-mode-aware value). No single grey threads both themes well — the two themes need opposite-direction adjustments relative to their own inverted primary-blue lightness — and the icon shape (`Done` vs `DoneAll`) remains the primary, non-colour signal per the component's own design principle, so this is tracked rather than guessed at further without live visual verification. Test-infrastructure fix alongside C, not a behavioural change: `Thread.jsx`'s new `subscribe()` call broke 3 existing test files' single-captured-handler mock pattern (`frameHandler = handler`, overwritten per call) — converted to Set/Map-based multi-handler dispatch in `messagingChunk6.test.jsx`, `messagingMsg6.test.jsx`, `messagingThreadReceipts.test.jsx`. Full suite 234/234 (was 222), `tsc` clean. Published as npm 2.22.0 (pending). jg-ferien pin bump to `dcm 2.40.0` / `ucm 2.22.0` is a separate, subsequent step. |
|
|
@@ -55,7 +55,8 @@ export const messagingTranslations = {
|
|
|
55
55
|
'MessagingDirect.CANCEL': { de: 'Abbrechen', en: 'Cancel', fr: 'Annuler' },
|
|
56
56
|
'MessagingDirect.START_ERROR': { de: 'Direktnachricht konnte nicht gestartet werden.', en: 'Direct message could not be started.', fr: 'Impossible de démarrer le message direct.' },
|
|
57
57
|
'MessagingReadTicks.ALL_READ': { de: 'Von allen gelesen', en: 'Read by everyone', fr: 'Lu par tout le monde' },
|
|
58
|
-
'MessagingReadTicks.
|
|
58
|
+
'MessagingReadTicks.SENT': { de: 'Gesendet', en: 'Sent', fr: 'Envoyé' },
|
|
59
|
+
'MessagingReadTicks.READ_RATIO': { de: '{{read}} von {{total}} gelesen', en: '{{read}} of {{total}} read', fr: '{{read}} sur {{total}} lu(s)' },
|
|
59
60
|
'MessagingReadTicks.RECIPIENTS': { de: 'Lesebestätigungen', en: 'Read recipients', fr: 'Destinataires lus' },
|
|
60
61
|
'MessagingComposer.LABEL': { de: 'Nachricht verfassen', en: 'Compose message', fr: 'Rédiger un message' },
|
|
61
62
|
'MessagingComposer.MESSAGE': { de: 'Nachricht', en: 'Message', fr: 'Message' },
|
|
@@ -15,7 +15,14 @@ import { ConversationLaunchers } from './ConversationLaunchers';
|
|
|
15
15
|
// like a managed conversation's label above.
|
|
16
16
|
function titleOf(conversation, t, resolveManagedLabel, resolveDirectUserName) {
|
|
17
17
|
var _a, _b;
|
|
18
|
-
|
|
18
|
+
// Not gated on kind === 'managed': a host resolver already returns null to
|
|
19
|
+
// mean "I have no label for this conversation" (the delegation contract
|
|
20
|
+
// this comment block describes above), so gating here duplicated a
|
|
21
|
+
// decision that belongs to the host and silently made the resolver
|
|
22
|
+
// unreachable for any kind the host later decided to label (found live:
|
|
23
|
+
// jg-ferien's resolver was taught to handle 'broadcast' too, but never got
|
|
24
|
+
// a chance to run because this gate excluded it).
|
|
25
|
+
if (resolveManagedLabel) {
|
|
19
26
|
const label = resolveManagedLabel(conversation);
|
|
20
27
|
if (label)
|
|
21
28
|
return label;
|
|
@@ -1,11 +1,31 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import DoneAllOutlinedIcon from '@mui/icons-material/DoneAllOutlined';
|
|
3
3
|
import DoneOutlinedIcon from '@mui/icons-material/DoneOutlined';
|
|
4
|
-
import { ButtonBase, List, ListItem, Popover } from '@mui/material';
|
|
4
|
+
import { ButtonBase, List, ListItem, Popover, Typography } from '@mui/material';
|
|
5
5
|
import { useEffect, useState } from 'react';
|
|
6
6
|
import { useTranslation } from 'react-i18next';
|
|
7
7
|
import { useMessaging } from './MessagingProvider';
|
|
8
|
-
|
|
8
|
+
// Explicit tokens, not `inherit`/`primary` -- `inherit` inside the message
|
|
9
|
+
// meta Stack resolved to `text.disabled`, which read too close to the "read"
|
|
10
|
+
// state at the rendered 1rem icon size. The icon shape (Done vs DoneAll)
|
|
11
|
+
// already differs, so the colour distinction is a second, independent signal
|
|
12
|
+
// rather than the only one.
|
|
13
|
+
const DM_SENT_COLOR = 'grey.500';
|
|
14
|
+
const DM_READ_COLOR = 'primary.main';
|
|
15
|
+
/**
|
|
16
|
+
* Shape-driven, no client-side role/permission branching: dcm only ever
|
|
17
|
+
* includes `read_count`/`recipient_count` inside the same `read_receipt_detail`-
|
|
18
|
+
* gated block as `recipient_detail`, and never for a `direct` conversation
|
|
19
|
+
* (the DM privacy carve-out). So "counts present" already means "team member,
|
|
20
|
+
* non-direct" -- checking anything else here would re-implement a decision
|
|
21
|
+
* dcm's response shape already makes.
|
|
22
|
+
*
|
|
23
|
+
* - counts present -> a read ratio ("18/40"), numeric, no icon ladder.
|
|
24
|
+
* - no counts, `direct` -> a two-state tick (sent / read).
|
|
25
|
+
* - no counts, non-direct -> no indicator at all (an ordinary participant in
|
|
26
|
+
* a group; a permanently-grey tick here would be the "looks always
|
|
27
|
+
* delivered" defect this component exists to remove).
|
|
28
|
+
*/
|
|
9
29
|
export function ReadTicks({ messageId, conversation }) {
|
|
10
30
|
var _a;
|
|
11
31
|
const { t } = useTranslation();
|
|
@@ -26,13 +46,35 @@ export function ReadTicks({ messageId, conversation }) {
|
|
|
26
46
|
? cache.receipts[`${conversation.id}:${conversation.other_user_id}`]
|
|
27
47
|
: null;
|
|
28
48
|
const allRead = status.all_read || Boolean(directReceipt === null || directReceipt === void 0 ? void 0 : directReceipt.last_read_at) || Boolean((recipientDetail === null || recipientDetail === void 0 ? void 0 : recipientDetail.length) && recipientDetail.every((recipient) => recipient.last_read_at));
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
49
|
+
const hasCounts = typeof status.read_count === 'number' && typeof status.recipient_count === 'number';
|
|
50
|
+
if (!hasCounts && (conversation === null || conversation === void 0 ? void 0 : conversation.kind) !== 'direct')
|
|
51
|
+
return null;
|
|
32
52
|
// The server never returns DM detail; enforce the same carve-out defensively.
|
|
33
|
-
const detail = (conversation === null || conversation === void 0 ? void 0 : conversation.kind) === 'direct' ? null :
|
|
53
|
+
const detail = (conversation === null || conversation === void 0 ? void 0 : conversation.kind) === 'direct' ? null : recipientDetail;
|
|
34
54
|
const recipients = (detail === null || detail === void 0 ? void 0 : detail.map((recipient) => recipient.display_name || recipient.username || recipient.user_id).filter(Boolean)) || [];
|
|
35
|
-
if (
|
|
36
|
-
|
|
37
|
-
|
|
55
|
+
if (hasCounts) {
|
|
56
|
+
// Read count/recipient_count exactly as dcm computed them -- do NOT
|
|
57
|
+
// recompute from recipientDetail here. dcm thresholds each participant's
|
|
58
|
+
// last_read_at against THIS message's created_at
|
|
59
|
+
// (`last_read_at__gte=message.created_at`, services.py); recipientDetail
|
|
60
|
+
// only carries each participant's raw, unthresholded last_read_at, and
|
|
61
|
+
// ReadTicks has no access to the message's own created_at to reproduce
|
|
62
|
+
// that comparison. Recomputing from recipientDetail (tried during
|
|
63
|
+
// implementation, caught in review) silently counted anyone who had ever
|
|
64
|
+
// read the conversation at any point as "read this message" -- in a real
|
|
65
|
+
// group with history, that reads as close to N/N regardless of who
|
|
66
|
+
// actually saw this specific message, the opposite of the WO's intent.
|
|
67
|
+
// The trade-off: unlike the DM tick and the per-person popover below
|
|
68
|
+
// (both viewer/timestamp-relative facts, not message-relative, so they
|
|
69
|
+
// safely reuse cache.receipts), this numeric ratio only updates on the
|
|
70
|
+
// next read-status fetch, not live from a read_state frame.
|
|
71
|
+
const label = t('MessagingReadTicks.READ_RATIO', { read: status.read_count, total: status.recipient_count });
|
|
72
|
+
const ratio = _jsxs(Typography, { component: "span", variant: "caption", color: "inherit", children: [status.read_count, "/", status.recipient_count] });
|
|
73
|
+
if (!recipients.length)
|
|
74
|
+
return _jsx("span", { role: "img", "aria-label": label, children: ratio });
|
|
75
|
+
return _jsxs(_Fragment, { children: [_jsx(ButtonBase, { "aria-label": label, "aria-haspopup": "dialog", "aria-expanded": Boolean(anchorEl), onClick: (event) => setAnchorEl(event.currentTarget), sx: { display: 'inline-flex' }, children: ratio }), _jsx(Popover, { open: Boolean(anchorEl), anchorEl: anchorEl, onClose: () => setAnchorEl(null), children: _jsx(List, { "aria-label": t('MessagingReadTicks.RECIPIENTS'), children: recipients.map((recipient) => _jsx(ListItem, { children: recipient }, recipient)) }) })] });
|
|
76
|
+
}
|
|
77
|
+
const label = allRead ? t('MessagingReadTicks.ALL_READ') : t('MessagingReadTicks.SENT');
|
|
78
|
+
const StatusIcon = allRead ? DoneAllOutlinedIcon : DoneOutlinedIcon;
|
|
79
|
+
return _jsx(StatusIcon, { role: "img", "aria-label": label, sx: { color: allRead ? DM_READ_COLOR : DM_SENT_COLOR } });
|
|
38
80
|
}
|
package/dist/messaging/Thread.js
CHANGED
|
@@ -4,8 +4,9 @@ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'r
|
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
5
5
|
import { AuthContext } from '../auth/AuthContext';
|
|
6
6
|
import { MessageBubble } from './MessageBubble';
|
|
7
|
-
import { useMessaging } from './MessagingProvider';
|
|
7
|
+
import { MESSAGING_ENVELOPE, useMessaging } from './MessagingProvider';
|
|
8
8
|
import { ReadTicks } from './ReadTicks';
|
|
9
|
+
import { useRealtime } from '../notifications/realtime';
|
|
9
10
|
function chronological(messages) { return [...messages].sort((left, right) => new Date(left.created_at || 0) - new Date(right.created_at || 0)); }
|
|
10
11
|
function replyToId(message) { var _a; return (_a = message.reply_to_id) !== null && _a !== void 0 ? _a : message.reply_to; }
|
|
11
12
|
// last_reply_at/reply_count are viewer-independent (always on serialize_message);
|
|
@@ -45,6 +46,7 @@ export function Thread({ conversationId, onReplyTargetChange, canModerateMessage
|
|
|
45
46
|
const { t, i18n } = useTranslation();
|
|
46
47
|
const { user } = useContext(AuthContext) || {};
|
|
47
48
|
const { cache, loadMoreMessages, loadThreadReplies, markConversationRead, markThreadRead, refreshThread } = useMessaging();
|
|
49
|
+
const { subscribe } = useRealtime();
|
|
48
50
|
const scrollRef = useRef(null);
|
|
49
51
|
const wasNearBottomRef = useRef(true);
|
|
50
52
|
const messageCountRef = useRef(0);
|
|
@@ -59,6 +61,32 @@ export function Thread({ conversationId, onReplyTargetChange, canModerateMessage
|
|
|
59
61
|
const datedRoots = useMemo(() => roots.map((message, index) => ({ message, showDateSeparator: index > 0 && new Date(roots[index - 1].created_at).toDateString() !== new Date(message.created_at).toDateString() })), [roots]);
|
|
60
62
|
useEffect(() => { if (conversationId != null)
|
|
61
63
|
markConversationRead(conversationId).catch(() => { }); }, [conversationId, markConversationRead]);
|
|
64
|
+
// Re-mark read when a live message frame arrives for the conversation
|
|
65
|
+
// currently open -- without this, `read_count` biases downward for the
|
|
66
|
+
// person paying the most attention: the mount-effect above only fires once
|
|
67
|
+
// per open, so a viewer already sitting in the thread when a new message
|
|
68
|
+
// lands is recorded as "not read" until they navigate away and back.
|
|
69
|
+
// Subscribes independently of MessagingProvider's own subscription to the
|
|
70
|
+
// same envelope (the registry supports multiple handlers per envelope) so
|
|
71
|
+
// this stays a Thread-local concern, not a MessagingProvider reducer change.
|
|
72
|
+
// No sender check needed: dcm's `resolve_live_recipients(sender=actor)`
|
|
73
|
+
// already excludes the sender from the live fan-out, so a `message` frame
|
|
74
|
+
// for this conversation structurally cannot be the viewer's own send.
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (conversationId == null)
|
|
77
|
+
return undefined;
|
|
78
|
+
return subscribe(MESSAGING_ENVELOPE, (frame) => {
|
|
79
|
+
var _a, _b;
|
|
80
|
+
if (frame.type !== 'message')
|
|
81
|
+
return;
|
|
82
|
+
const frameConversationId = (_a = frame.conversation_id) !== null && _a !== void 0 ? _a : (_b = frame.message) === null || _b === void 0 ? void 0 : _b.conversation_id;
|
|
83
|
+
if (String(frameConversationId) !== String(conversationId))
|
|
84
|
+
return;
|
|
85
|
+
if (document.visibilityState === 'hidden')
|
|
86
|
+
return;
|
|
87
|
+
markConversationRead(conversationId).catch(() => { });
|
|
88
|
+
});
|
|
89
|
+
}, [conversationId, markConversationRead, subscribe]);
|
|
62
90
|
useEffect(() => { wasNearBottomRef.current = true; messageCountRef.current = 0; }, [conversationId]);
|
|
63
91
|
// Nothing else in this component ever fetches messages for the conversation
|
|
64
92
|
// actually being opened — `cache.messages` is otherwise only populated by a
|
package/package.json
CHANGED
|
@@ -55,7 +55,8 @@ export const messagingTranslations = {
|
|
|
55
55
|
'MessagingDirect.CANCEL': { de: 'Abbrechen', en: 'Cancel', fr: 'Annuler' },
|
|
56
56
|
'MessagingDirect.START_ERROR': { de: 'Direktnachricht konnte nicht gestartet werden.', en: 'Direct message could not be started.', fr: 'Impossible de démarrer le message direct.' },
|
|
57
57
|
'MessagingReadTicks.ALL_READ': { de: 'Von allen gelesen', en: 'Read by everyone', fr: 'Lu par tout le monde' },
|
|
58
|
-
'MessagingReadTicks.
|
|
58
|
+
'MessagingReadTicks.SENT': { de: 'Gesendet', en: 'Sent', fr: 'Envoyé' },
|
|
59
|
+
'MessagingReadTicks.READ_RATIO': { de: '{{read}} von {{total}} gelesen', en: '{{read}} of {{total}} read', fr: '{{read}} sur {{total}} lu(s)' },
|
|
59
60
|
'MessagingReadTicks.RECIPIENTS': { de: 'Lesebestätigungen', en: 'Read recipients', fr: 'Destinataires lus' },
|
|
60
61
|
'MessagingComposer.LABEL': { de: 'Nachricht verfassen', en: 'Compose message', fr: 'Rédiger un message' },
|
|
61
62
|
'MessagingComposer.MESSAGE': { de: 'Nachricht', en: 'Message', fr: 'Message' },
|
|
@@ -15,7 +15,14 @@ import { ConversationLaunchers } from './ConversationLaunchers';
|
|
|
15
15
|
// name — so a direct conversation's counterpart name is host-supplied, exactly
|
|
16
16
|
// like a managed conversation's label above.
|
|
17
17
|
function titleOf(conversation, t, resolveManagedLabel, resolveDirectUserName) {
|
|
18
|
-
|
|
18
|
+
// Not gated on kind === 'managed': a host resolver already returns null to
|
|
19
|
+
// mean "I have no label for this conversation" (the delegation contract
|
|
20
|
+
// this comment block describes above), so gating here duplicated a
|
|
21
|
+
// decision that belongs to the host and silently made the resolver
|
|
22
|
+
// unreachable for any kind the host later decided to label (found live:
|
|
23
|
+
// jg-ferien's resolver was taught to handle 'broadcast' too, but never got
|
|
24
|
+
// a chance to run because this gate excluded it).
|
|
25
|
+
if (resolveManagedLabel) {
|
|
19
26
|
const label = resolveManagedLabel(conversation);
|
|
20
27
|
if (label) return label;
|
|
21
28
|
}
|
|
@@ -1,12 +1,33 @@
|
|
|
1
1
|
import DoneAllOutlinedIcon from '@mui/icons-material/DoneAllOutlined';
|
|
2
2
|
import DoneOutlinedIcon from '@mui/icons-material/DoneOutlined';
|
|
3
|
-
import { ButtonBase, List, ListItem, Popover } from '@mui/material';
|
|
3
|
+
import { ButtonBase, List, ListItem, Popover, Typography } from '@mui/material';
|
|
4
4
|
import { useEffect, useState } from 'react';
|
|
5
5
|
import { useTranslation } from 'react-i18next';
|
|
6
6
|
|
|
7
7
|
import { useMessaging } from './MessagingProvider';
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
// Explicit tokens, not `inherit`/`primary` -- `inherit` inside the message
|
|
10
|
+
// meta Stack resolved to `text.disabled`, which read too close to the "read"
|
|
11
|
+
// state at the rendered 1rem icon size. The icon shape (Done vs DoneAll)
|
|
12
|
+
// already differs, so the colour distinction is a second, independent signal
|
|
13
|
+
// rather than the only one.
|
|
14
|
+
const DM_SENT_COLOR = 'grey.500';
|
|
15
|
+
const DM_READ_COLOR = 'primary.main';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Shape-driven, no client-side role/permission branching: dcm only ever
|
|
19
|
+
* includes `read_count`/`recipient_count` inside the same `read_receipt_detail`-
|
|
20
|
+
* gated block as `recipient_detail`, and never for a `direct` conversation
|
|
21
|
+
* (the DM privacy carve-out). So "counts present" already means "team member,
|
|
22
|
+
* non-direct" -- checking anything else here would re-implement a decision
|
|
23
|
+
* dcm's response shape already makes.
|
|
24
|
+
*
|
|
25
|
+
* - counts present -> a read ratio ("18/40"), numeric, no icon ladder.
|
|
26
|
+
* - no counts, `direct` -> a two-state tick (sent / read).
|
|
27
|
+
* - no counts, non-direct -> no indicator at all (an ordinary participant in
|
|
28
|
+
* a group; a permanently-grey tick here would be the "looks always
|
|
29
|
+
* delivered" defect this component exists to remove).
|
|
30
|
+
*/
|
|
10
31
|
export function ReadTicks({ messageId, conversation }) {
|
|
11
32
|
const { t } = useTranslation();
|
|
12
33
|
const { cache, getMessageReadStatus } = useMessaging();
|
|
@@ -18,17 +39,43 @@ export function ReadTicks({ messageId, conversation }) {
|
|
|
18
39
|
return () => { mounted = false; };
|
|
19
40
|
}, [getMessageReadStatus, messageId]);
|
|
20
41
|
if (!status) return null;
|
|
42
|
+
|
|
21
43
|
const recipientDetail = status.recipient_detail?.map((recipient) => ({ ...recipient, last_read_at: cache.receipts[`${conversation?.id}:${recipient.user_id}`]?.last_read_at ?? recipient.last_read_at })) || status.recipient_detail;
|
|
22
44
|
const directReceipt = conversation?.kind === 'direct' && conversation?.other_user_id != null
|
|
23
45
|
? cache.receipts[`${conversation.id}:${conversation.other_user_id}`]
|
|
24
46
|
: null;
|
|
25
47
|
const allRead = status.all_read || Boolean(directReceipt?.last_read_at) || Boolean(recipientDetail?.length && recipientDetail.every((recipient) => recipient.last_read_at));
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
48
|
+
const hasCounts = typeof status.read_count === 'number' && typeof status.recipient_count === 'number';
|
|
49
|
+
|
|
50
|
+
if (!hasCounts && conversation?.kind !== 'direct') return null;
|
|
51
|
+
|
|
29
52
|
// The server never returns DM detail; enforce the same carve-out defensively.
|
|
30
|
-
const detail = conversation?.kind === 'direct' ? null :
|
|
53
|
+
const detail = conversation?.kind === 'direct' ? null : recipientDetail;
|
|
31
54
|
const recipients = detail?.map((recipient) => recipient.display_name || recipient.username || recipient.user_id).filter(Boolean) || [];
|
|
32
|
-
|
|
33
|
-
|
|
55
|
+
|
|
56
|
+
if (hasCounts) {
|
|
57
|
+
// Read count/recipient_count exactly as dcm computed them -- do NOT
|
|
58
|
+
// recompute from recipientDetail here. dcm thresholds each participant's
|
|
59
|
+
// last_read_at against THIS message's created_at
|
|
60
|
+
// (`last_read_at__gte=message.created_at`, services.py); recipientDetail
|
|
61
|
+
// only carries each participant's raw, unthresholded last_read_at, and
|
|
62
|
+
// ReadTicks has no access to the message's own created_at to reproduce
|
|
63
|
+
// that comparison. Recomputing from recipientDetail (tried during
|
|
64
|
+
// implementation, caught in review) silently counted anyone who had ever
|
|
65
|
+
// read the conversation at any point as "read this message" -- in a real
|
|
66
|
+
// group with history, that reads as close to N/N regardless of who
|
|
67
|
+
// actually saw this specific message, the opposite of the WO's intent.
|
|
68
|
+
// The trade-off: unlike the DM tick and the per-person popover below
|
|
69
|
+
// (both viewer/timestamp-relative facts, not message-relative, so they
|
|
70
|
+
// safely reuse cache.receipts), this numeric ratio only updates on the
|
|
71
|
+
// next read-status fetch, not live from a read_state frame.
|
|
72
|
+
const label = t('MessagingReadTicks.READ_RATIO', { read: status.read_count, total: status.recipient_count });
|
|
73
|
+
const ratio = <Typography component="span" variant="caption" color="inherit">{status.read_count}/{status.recipient_count}</Typography>;
|
|
74
|
+
if (!recipients.length) return <span role="img" aria-label={label}>{ratio}</span>;
|
|
75
|
+
return <><ButtonBase aria-label={label} aria-haspopup="dialog" aria-expanded={Boolean(anchorEl)} onClick={(event) => setAnchorEl(event.currentTarget)} sx={{ display: 'inline-flex' }}>{ratio}</ButtonBase><Popover open={Boolean(anchorEl)} anchorEl={anchorEl} onClose={() => setAnchorEl(null)}><List aria-label={t('MessagingReadTicks.RECIPIENTS')}>{recipients.map((recipient) => <ListItem key={recipient}>{recipient}</ListItem>)}</List></Popover></>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const label = allRead ? t('MessagingReadTicks.ALL_READ') : t('MessagingReadTicks.SENT');
|
|
79
|
+
const StatusIcon = allRead ? DoneAllOutlinedIcon : DoneOutlinedIcon;
|
|
80
|
+
return <StatusIcon role="img" aria-label={label} sx={{ color: allRead ? DM_READ_COLOR : DM_SENT_COLOR }} />;
|
|
34
81
|
}
|
package/src/messaging/Thread.jsx
CHANGED
|
@@ -4,8 +4,9 @@ import { useTranslation } from 'react-i18next';
|
|
|
4
4
|
|
|
5
5
|
import { AuthContext } from '../auth/AuthContext';
|
|
6
6
|
import { MessageBubble } from './MessageBubble';
|
|
7
|
-
import { useMessaging } from './MessagingProvider';
|
|
7
|
+
import { MESSAGING_ENVELOPE, useMessaging } from './MessagingProvider';
|
|
8
8
|
import { ReadTicks } from './ReadTicks';
|
|
9
|
+
import { useRealtime } from '../notifications/realtime';
|
|
9
10
|
|
|
10
11
|
function chronological(messages) { return [...messages].sort((left, right) => new Date(left.created_at || 0) - new Date(right.created_at || 0)); }
|
|
11
12
|
function replyToId(message) { return message.reply_to_id ?? message.reply_to; }
|
|
@@ -42,6 +43,7 @@ export function Thread({ conversationId, onReplyTargetChange, canModerateMessage
|
|
|
42
43
|
const { t, i18n } = useTranslation();
|
|
43
44
|
const { user } = useContext(AuthContext) || {};
|
|
44
45
|
const { cache, loadMoreMessages, loadThreadReplies, markConversationRead, markThreadRead, refreshThread } = useMessaging();
|
|
46
|
+
const { subscribe } = useRealtime();
|
|
45
47
|
const scrollRef = useRef(null);
|
|
46
48
|
const wasNearBottomRef = useRef(true);
|
|
47
49
|
const messageCountRef = useRef(0);
|
|
@@ -56,6 +58,27 @@ export function Thread({ conversationId, onReplyTargetChange, canModerateMessage
|
|
|
56
58
|
const datedRoots = useMemo(() => roots.map((message, index) => ({ message, showDateSeparator: index > 0 && new Date(roots[index - 1].created_at).toDateString() !== new Date(message.created_at).toDateString() })), [roots]);
|
|
57
59
|
|
|
58
60
|
useEffect(() => { if (conversationId != null) markConversationRead(conversationId).catch(() => {}); }, [conversationId, markConversationRead]);
|
|
61
|
+
// Re-mark read when a live message frame arrives for the conversation
|
|
62
|
+
// currently open -- without this, `read_count` biases downward for the
|
|
63
|
+
// person paying the most attention: the mount-effect above only fires once
|
|
64
|
+
// per open, so a viewer already sitting in the thread when a new message
|
|
65
|
+
// lands is recorded as "not read" until they navigate away and back.
|
|
66
|
+
// Subscribes independently of MessagingProvider's own subscription to the
|
|
67
|
+
// same envelope (the registry supports multiple handlers per envelope) so
|
|
68
|
+
// this stays a Thread-local concern, not a MessagingProvider reducer change.
|
|
69
|
+
// No sender check needed: dcm's `resolve_live_recipients(sender=actor)`
|
|
70
|
+
// already excludes the sender from the live fan-out, so a `message` frame
|
|
71
|
+
// for this conversation structurally cannot be the viewer's own send.
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (conversationId == null) return undefined;
|
|
74
|
+
return subscribe(MESSAGING_ENVELOPE, (frame) => {
|
|
75
|
+
if (frame.type !== 'message') return;
|
|
76
|
+
const frameConversationId = frame.conversation_id ?? frame.message?.conversation_id;
|
|
77
|
+
if (String(frameConversationId) !== String(conversationId)) return;
|
|
78
|
+
if (document.visibilityState === 'hidden') return;
|
|
79
|
+
markConversationRead(conversationId).catch(() => {});
|
|
80
|
+
});
|
|
81
|
+
}, [conversationId, markConversationRead, subscribe]);
|
|
59
82
|
useEffect(() => { wasNearBottomRef.current = true; messageCountRef.current = 0; }, [conversationId]);
|
|
60
83
|
// Nothing else in this component ever fetches messages for the conversation
|
|
61
84
|
// actually being opened — `cache.messages` is otherwise only populated by a
|
|
@@ -79,6 +79,58 @@ describe('ConversationList', () => {
|
|
|
79
79
|
expect(resolveDirectUserName).not.toHaveBeenCalled();
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
+
it('calls resolveManagedLabel for a broadcast conversation, not just managed -- must fail against the kind-gated code (MSG-6f scope A)', async () => {
|
|
83
|
+
api.listConversations.mockResolvedValueOnce({
|
|
84
|
+
results: [{ id: 9, kind: 'broadcast', external_key: 'broadcast', title: null, last_message: { excerpt: 'Hi all' } }],
|
|
85
|
+
next_cursor: null,
|
|
86
|
+
});
|
|
87
|
+
const resolveManagedLabel = vi.fn((conversation) => (conversation.kind === 'broadcast' ? 'Event-Ankündigungen' : null));
|
|
88
|
+
renderList(api, { resolveManagedLabel });
|
|
89
|
+
await screen.findByText('Event-Ankündigungen');
|
|
90
|
+
expect(resolveManagedLabel).toHaveBeenCalledWith(expect.objectContaining({ id: 9, kind: 'broadcast' }));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('falls through to the existing title chain unchanged when resolveManagedLabel returns null, for any kind', async () => {
|
|
94
|
+
api.listConversations.mockResolvedValueOnce({
|
|
95
|
+
results: [{ id: 10, kind: 'broadcast', title: null, last_message: { excerpt: 'Hi' } }],
|
|
96
|
+
next_cursor: null,
|
|
97
|
+
});
|
|
98
|
+
const resolveManagedLabel = vi.fn(() => null);
|
|
99
|
+
renderList(api, { resolveManagedLabel });
|
|
100
|
+
await screen.findByText('MessagingList.UNTITLED');
|
|
101
|
+
expect(resolveManagedLabel).toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('a truthy resolveManagedLabel wins over resolveDirectUserName for a direct conversation -- documents the new precedence, not previously reachable when the gate excluded every kind but managed', async () => {
|
|
105
|
+
api.listConversations.mockResolvedValueOnce({
|
|
106
|
+
results: [{ id: 12, kind: 'direct', other_user_id: 42, title: null, last_message: { excerpt: 'Hi' } }],
|
|
107
|
+
next_cursor: null,
|
|
108
|
+
});
|
|
109
|
+
const resolveManagedLabel = vi.fn(() => 'Host label wins');
|
|
110
|
+
const resolveDirectUserName = vi.fn(() => 'Alex');
|
|
111
|
+
renderList(api, { resolveManagedLabel, resolveDirectUserName });
|
|
112
|
+
await screen.findByText('Host label wins');
|
|
113
|
+
// resolveManagedLabel is checked first, unconditionally -- a host whose
|
|
114
|
+
// resolver ever returns non-null for kind: 'direct' pre-empts the
|
|
115
|
+
// id-based resolveDirectUserName lookup entirely. Deliberate per the
|
|
116
|
+
// WO's delegation contract (a host is trusted to return null for kinds
|
|
117
|
+
// it doesn't handle), but was structurally impossible before this WO
|
|
118
|
+
// (the old kind === 'managed' gate excluded every other kind), so it is
|
|
119
|
+
// now a real, if host-controlled, precedence a resolver author must know.
|
|
120
|
+
expect(resolveDirectUserName).not.toHaveBeenCalled();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('keeps managed event_all/event_team labelling unchanged (regression guard)', async () => {
|
|
124
|
+
api.listConversations.mockResolvedValueOnce({
|
|
125
|
+
results: [{ id: 11, kind: 'managed', external_key: 'event_all', title: null, last_message: { excerpt: 'Hi' } }],
|
|
126
|
+
next_cursor: null,
|
|
127
|
+
});
|
|
128
|
+
const resolveManagedLabel = vi.fn((conversation) => (conversation.external_key === 'event_all' ? 'Alle - Sommerlager' : null));
|
|
129
|
+
renderList(api, { resolveManagedLabel });
|
|
130
|
+
await screen.findByText('Alle - Sommerlager');
|
|
131
|
+
expect(resolveManagedLabel).toHaveBeenCalledWith(expect.objectContaining({ id: 11, kind: 'managed', external_key: 'event_all' }));
|
|
132
|
+
});
|
|
133
|
+
|
|
82
134
|
it('wires mute and archive through participant APIs', async () => {
|
|
83
135
|
renderList(api);
|
|
84
136
|
await screen.findByText('First');
|
package/tests/Thread.test.jsx
CHANGED
|
@@ -18,7 +18,7 @@ function makeApi() {
|
|
|
18
18
|
getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
|
|
19
19
|
listMessages: vi.fn().mockResolvedValue({ results: [{ id: 2, conversation_id: 1, body: 'Visible message', created_at: '2026-07-31T10:00:00Z' }], next_cursor: 'signed-opaque-cursor' }),
|
|
20
20
|
listThread: vi.fn().mockResolvedValue({ results: [] }),
|
|
21
|
-
getReadStatus: vi.fn().mockResolvedValue({ all_read: false
|
|
21
|
+
getReadStatus: vi.fn().mockResolvedValue({ all_read: false }),
|
|
22
22
|
};
|
|
23
23
|
}
|
|
24
24
|
// No `activeConversationId` on the provider: this matches how jg-ferien
|
|
@@ -56,17 +56,17 @@ describe('messaging Thread surfaces', () => {
|
|
|
56
56
|
expect(screen.queryByText('Stale secret')).toBeNull();
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
-
it('renders
|
|
60
|
-
api.getReadStatus.mockResolvedValue({ all_read: false,
|
|
59
|
+
it('renders the two-state direct tick and never exposes supplied recipient details for a direct conversation', async () => {
|
|
60
|
+
api.getReadStatus.mockResolvedValue({ all_read: false, recipient_detail: [{ display_name: 'Private recipient' }] });
|
|
61
61
|
render(<MessagingProvider api={api} active={false}><ReadTicks messageId={2} conversation={{ id: 1, kind: 'direct' }} /></MessagingProvider>);
|
|
62
|
-
await screen.findByLabelText('MessagingReadTicks.
|
|
62
|
+
await screen.findByLabelText('MessagingReadTicks.SENT');
|
|
63
63
|
expect(screen.queryByText('Private recipient')).toBeNull();
|
|
64
64
|
});
|
|
65
65
|
|
|
66
|
-
it('renders supplied recipient detail for a non-direct conversation (the permitted case)', async () => {
|
|
67
|
-
api.getReadStatus.mockResolvedValue({ all_read: false,
|
|
66
|
+
it('renders supplied recipient detail for a non-direct conversation with counts (the permitted case)', async () => {
|
|
67
|
+
api.getReadStatus.mockResolvedValue({ all_read: false, read_count: 1, recipient_count: 2, recipient_detail: [{ display_name: 'Visible recipient' }] });
|
|
68
68
|
render(<MessagingProvider api={api} active={false}><ReadTicks messageId={2} conversation={{ id: 1, kind: 'group' }} /></MessagingProvider>);
|
|
69
|
-
const tick = await screen.findByLabelText('MessagingReadTicks.
|
|
69
|
+
const tick = await screen.findByLabelText('MessagingReadTicks.READ_RATIO');
|
|
70
70
|
fireEvent.click(tick);
|
|
71
71
|
await screen.findByText('Visible recipient');
|
|
72
72
|
});
|
|
@@ -100,8 +100,9 @@ describe('messaging Thread surfaces', () => {
|
|
|
100
100
|
renderThread(api);
|
|
101
101
|
await screen.findByLabelText('MessagingThread.TIMELINE');
|
|
102
102
|
cleanup();
|
|
103
|
+
api.getReadStatus.mockResolvedValue({ all_read: false, read_count: 0, recipient_count: 3 });
|
|
103
104
|
render(<MessagingProvider api={api} active={false}><ReadTicks messageId={2} conversation={{ id: 1, kind: 'group' }} /></MessagingProvider>);
|
|
104
|
-
await waitFor(() => expect(screen.getByLabelText('MessagingReadTicks.
|
|
105
|
+
await waitFor(() => expect(screen.getByLabelText('MessagingReadTicks.READ_RATIO')).toBeTruthy());
|
|
105
106
|
});
|
|
106
107
|
|
|
107
108
|
it('fetches and shows a conversation\'s historical messages on open, with no realtime frame or optimistic send involved', async () => {
|
|
@@ -3,9 +3,14 @@ import React from 'react';
|
|
|
3
3
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
4
|
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
// Thread.jsx (MSG-6f scope C) now subscribes to the messaging envelope
|
|
7
|
+
// independently of MessagingProvider's own subscription -- a Set of handlers,
|
|
8
|
+
// not a single captured variable, so frameHandler(...) still reaches every
|
|
9
|
+
// current subscriber, matching the real registry's multi-handler support.
|
|
10
|
+
let frameHandlers;
|
|
11
|
+
function frameHandler(frame) { frameHandlers.forEach((handler) => handler(frame)); }
|
|
7
12
|
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options?.count != null ? `${key}:${options.count}` : key, i18n: { language: 'en' } }) }));
|
|
8
|
-
vi.mock('../src/notifications/realtime', () => ({ useRealtime: () => ({ subscribe: (_envelope, handler) => {
|
|
13
|
+
vi.mock('../src/notifications/realtime', () => ({ useRealtime: () => ({ subscribe: (_envelope, handler) => { frameHandlers.add(handler); return () => frameHandlers.delete(handler); }, onReconnect: () => () => {} }) }));
|
|
9
14
|
|
|
10
15
|
import { Composer } from '../src/messaging/Composer';
|
|
11
16
|
import { ConversationList } from '../src/messaging/ConversationList';
|
|
@@ -20,14 +25,15 @@ function makeApi(overrides = {}) {
|
|
|
20
25
|
listMessages: vi.fn().mockResolvedValue({ results: [] }),
|
|
21
26
|
listThread: vi.fn().mockResolvedValue({ results: [] }),
|
|
22
27
|
getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
|
|
23
|
-
getReadStatus: vi.fn().mockResolvedValue({ all_read: false
|
|
28
|
+
getReadStatus: vi.fn().mockResolvedValue({ all_read: false }),
|
|
24
29
|
markConversationRead: vi.fn().mockResolvedValue({}), markThreadRead: vi.fn().mockResolvedValue({}),
|
|
25
30
|
createPoll: vi.fn().mockResolvedValue({}),
|
|
26
31
|
...overrides,
|
|
27
32
|
};
|
|
28
33
|
}
|
|
29
34
|
|
|
30
|
-
|
|
35
|
+
beforeEach(() => { frameHandlers = new Set(); });
|
|
36
|
+
afterEach(() => { cleanup(); vi.useRealTimers(); vi.restoreAllMocks(); frameHandlers = new Set(); });
|
|
31
37
|
|
|
32
38
|
describe('MSG-3b chunk 6', () => {
|
|
33
39
|
it('renders localized relative list timestamps for now, minutes, hours, and days', async () => {
|
|
@@ -84,16 +90,17 @@ describe('MSG-3b chunk 6', () => {
|
|
|
84
90
|
expect(screen.queryByText('Ava')).toBeNull();
|
|
85
91
|
});
|
|
86
92
|
|
|
87
|
-
it('opens receipt recipients by click for non-direct conversations and never exposes direct details', async () => {
|
|
88
|
-
const api = makeApi({ getReadStatus: vi.fn().mockResolvedValue({ all_read: false,
|
|
93
|
+
it('opens receipt recipients by click for non-direct conversations with counts, and never exposes direct details', async () => {
|
|
94
|
+
const api = makeApi({ getReadStatus: vi.fn().mockResolvedValue({ all_read: false, read_count: 1, recipient_count: 2, recipient_detail: [{ display_name: 'Ava' }] }) });
|
|
89
95
|
const view = render(<MessagingProvider api={api} active={false}><ReadTicks messageId={7} conversation={{ kind: 'group' }} /></MessagingProvider>);
|
|
90
|
-
const detail = await screen.findByRole('button', { name: 'MessagingReadTicks.
|
|
96
|
+
const detail = await screen.findByRole('button', { name: 'MessagingReadTicks.READ_RATIO' });
|
|
91
97
|
fireEvent.click(detail);
|
|
92
98
|
expect(await screen.findByText('Ava')).toBeTruthy();
|
|
93
99
|
view.unmount();
|
|
100
|
+
api.getReadStatus.mockResolvedValue({ all_read: false, recipient_detail: [{ display_name: 'Ava' }] });
|
|
94
101
|
render(<MessagingProvider api={api} active={false}><ReadTicks messageId={7} conversation={{ kind: 'direct' }} /></MessagingProvider>);
|
|
95
|
-
await screen.findByLabelText('MessagingReadTicks.
|
|
96
|
-
expect(screen.queryByRole('button', { name: 'MessagingReadTicks.
|
|
102
|
+
await screen.findByLabelText('MessagingReadTicks.SENT');
|
|
103
|
+
expect(screen.queryByRole('button', { name: 'MessagingReadTicks.READ_RATIO' })).toBeNull();
|
|
97
104
|
expect(screen.queryByText('Ava')).toBeNull();
|
|
98
105
|
});
|
|
99
106
|
|
|
@@ -12,7 +12,7 @@ import { MessagingProvider } from '../src/messaging/MessagingProvider';
|
|
|
12
12
|
import { ReadTicks } from '../src/messaging/ReadTicks';
|
|
13
13
|
|
|
14
14
|
const api = () => ({
|
|
15
|
-
getReadStatus: vi.fn().mockResolvedValue({ all_read: false
|
|
15
|
+
getReadStatus: vi.fn().mockResolvedValue({ all_read: false }),
|
|
16
16
|
getAttachment: vi.fn(), getAttachmentThumbnail: vi.fn(), addReaction: vi.fn(), removeReaction: vi.fn(),
|
|
17
17
|
votePoll: vi.fn(), closePoll: vi.fn(),
|
|
18
18
|
});
|
|
@@ -24,8 +24,8 @@ afterEach(cleanup);
|
|
|
24
24
|
|
|
25
25
|
describe('compact message bubbles', () => {
|
|
26
26
|
it('keeps the timestamp and own-message status icon inside the bubble and has no standalone plain-message controls', async () => {
|
|
27
|
-
const { container } = renderBubble({ id: 1, body: 'Hallo', sender: { id: 1, display_name: 'Me' }, created_at: '2026-08-02T10:00:00Z' }, { child: <ReadTicks messageId={1} conversation={{ kind: '
|
|
28
|
-
const tick = await screen.findByLabelText('MessagingReadTicks.
|
|
27
|
+
const { container } = renderBubble({ id: 1, body: 'Hallo', sender: { id: 1, display_name: 'Me' }, created_at: '2026-08-02T10:00:00Z' }, { child: <ReadTicks messageId={1} conversation={{ kind: 'direct' }} /> });
|
|
28
|
+
const tick = await screen.findByLabelText('MessagingReadTicks.SENT');
|
|
29
29
|
const paper = container.querySelector('[data-message-id="1"] .MuiPaper-root');
|
|
30
30
|
expect(paper.contains(tick)).toBe(true);
|
|
31
31
|
expect(paper.querySelector('[data-testid="message-meta"]')).toBeTruthy();
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
import React from 'react';
|
|
3
|
-
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
4
|
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
// Thread.jsx (MSG-6f scope C) now subscribes to the messaging envelope
|
|
7
|
+
// independently of MessagingProvider's own subscription -- a Set of handlers,
|
|
8
|
+
// not a single captured variable, so frameHandler(...) still reaches every
|
|
9
|
+
// current subscriber, matching the real registry's multi-handler support.
|
|
10
|
+
let frameHandlers;
|
|
11
|
+
function frameHandler(frame) { frameHandlers.forEach((handler) => handler(frame)); }
|
|
7
12
|
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options?.count != null ? `${key}:${options.count}` : key, i18n: { language: 'de-DE' } }) }));
|
|
8
|
-
vi.mock('../src/notifications/realtime', () => ({ useRealtime: () => ({ subscribe: (_envelope, handler) => {
|
|
13
|
+
vi.mock('../src/notifications/realtime', () => ({ useRealtime: () => ({ subscribe: (_envelope, handler) => { frameHandlers.add(handler); return () => frameHandlers.delete(handler); }, onReconnect: () => () => {} }) }));
|
|
9
14
|
|
|
10
15
|
import { MessageBubble } from '../src/messaging/MessageBubble';
|
|
11
16
|
import { ConversationList } from '../src/messaging/ConversationList';
|
|
@@ -15,8 +20,9 @@ import { Thread } from '../src/messaging/Thread';
|
|
|
15
20
|
import { messagingTranslations } from '../src/i18n/messagingTranslations';
|
|
16
21
|
import { AuthContext } from '../src/auth/AuthContext';
|
|
17
22
|
|
|
18
|
-
const api = (overrides = {}) => ({ listConversations: vi.fn().mockResolvedValue({ results: [{ id: 'c1', kind: 'group' }] }), listMessages: vi.fn().mockResolvedValue({ results: [] }), listThread: vi.fn().mockResolvedValue({ results: [] }), getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }), getReadStatus: vi.fn().mockResolvedValue({ all_read: false,
|
|
23
|
+
const api = (overrides = {}) => ({ listConversations: vi.fn().mockResolvedValue({ results: [{ id: 'c1', kind: 'group' }] }), listMessages: vi.fn().mockResolvedValue({ results: [] }), listThread: vi.fn().mockResolvedValue({ results: [] }), getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }), getReadStatus: vi.fn().mockResolvedValue({ all_read: false, read_count: 0, recipient_count: 1, recipient_detail: [{ user_id: 'u2', display_name: 'Ava' }] }), markConversationRead: vi.fn().mockResolvedValue({}), ...overrides });
|
|
19
24
|
const state = () => ({ conversations: {}, messages: {}, threads: {}, polls: {}, reactions: {}, receipts: {}, unread: { unread_count: 0, by_conversation: {} }, cursors: { conversations: null, messages: {}, threads: {} } });
|
|
25
|
+
beforeEach(() => { frameHandlers = new Set(); });
|
|
20
26
|
afterEach(cleanup);
|
|
21
27
|
|
|
22
28
|
function PollHarness() { const { createConversationPoll, cache } = useMessaging(); return <><button onClick={() => createConversationPoll('c1', { question: 'Q' })}>create</button><output>{JSON.stringify(cache.messages)}</output></>; }
|
|
@@ -44,21 +50,30 @@ describe('MSG-6 messaging regressions', () => {
|
|
|
44
50
|
expect(screen.getAllByTestId('day-separator')).toHaveLength(1);
|
|
45
51
|
});
|
|
46
52
|
|
|
47
|
-
it('applies read
|
|
53
|
+
it('applies read frames to cache (for the per-person popover) but the group read ratio stays dcm\'s own numbers, not recomputed', async () => {
|
|
48
54
|
const client = api();
|
|
49
55
|
render(<MessagingProvider api={client} active={false}><ReadTicks messageId="m1" conversation={{ id: 'c1', kind: 'group' }} /></MessagingProvider>);
|
|
50
|
-
await screen.findByLabelText('MessagingReadTicks.
|
|
56
|
+
const tick = await screen.findByLabelText('MessagingReadTicks.READ_RATIO');
|
|
57
|
+
expect(tick.textContent).toBe('0/1');
|
|
51
58
|
act(() => frameHandler({ type: 'read_state', conversation_id: 'c1', user_id: 'u2', last_read_at: '2026-08-03T10:00:00Z' }));
|
|
52
|
-
|
|
59
|
+
// Deliberate, documented trade-off (see ReadTicks.jsx): the numeric ratio
|
|
60
|
+
// is dcm's own read_count/recipient_count verbatim, never recomputed from
|
|
61
|
+
// recipientDetail -- recipientDetail has no way to threshold against
|
|
62
|
+
// this specific message's created_at, only dcm's own aggregate does.
|
|
63
|
+
// So a read_state frame updates the *popover's* per-person list (proven
|
|
64
|
+
// by the receipts assertion below) but the ratio digits only change on
|
|
65
|
+
// the next read-status fetch, same as before this frame arrived.
|
|
66
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
67
|
+
expect(screen.getByLabelText('MessagingReadTicks.READ_RATIO').textContent).toBe('0/1');
|
|
53
68
|
expect(client.getReadStatus).toHaveBeenCalledTimes(1);
|
|
54
69
|
const next = applyFrame(state(), { type: 'thread_read_state', root_id: 'root-1', user_id: 'u2', last_read_at: '2026-08-03T10:00:00Z' });
|
|
55
70
|
expect(next.receipts['thread:root-1:u2'].last_read_at).toBe('2026-08-03T10:00:00Z');
|
|
56
71
|
});
|
|
57
72
|
|
|
58
73
|
it('updates direct-conversation ReadTicks from a read frame without another REST call', async () => {
|
|
59
|
-
const client = api();
|
|
74
|
+
const client = api({ getReadStatus: vi.fn().mockResolvedValue({ all_read: false }) });
|
|
60
75
|
render(<MessagingProvider api={client} active={false}><ReadTicks messageId="m1" conversation={{ id: 'c1', kind: 'direct', other_user_id: 'u2' }} /></MessagingProvider>);
|
|
61
|
-
await screen.findByLabelText('MessagingReadTicks.
|
|
76
|
+
await screen.findByLabelText('MessagingReadTicks.SENT');
|
|
62
77
|
act(() => frameHandler({ type: 'read_state', conversation_id: 'c1', user_id: 'u2', last_read_at: '2026-08-03T10:00:00Z' }));
|
|
63
78
|
await screen.findByLabelText('MessagingReadTicks.ALL_READ');
|
|
64
79
|
expect(client.getReadStatus).toHaveBeenCalledTimes(1);
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
|
|
5
|
+
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
|
6
|
+
|
|
7
|
+
// Thread.jsx (scope C) subscribes to the messaging envelope independently of
|
|
8
|
+
// MessagingProvider's own subscription -- a Set of handlers per envelope, not
|
|
9
|
+
// one, so a dispatched frame reaches every current subscriber.
|
|
10
|
+
let frameHandlers;
|
|
11
|
+
function dispatchFrame(frame) { frameHandlers.forEach((handler) => handler(frame)); }
|
|
12
|
+
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options && Object.keys(options).length ? `${key}:${JSON.stringify(options)}` : key, i18n: { language: 'en' } }) }));
|
|
13
|
+
vi.mock('../src/notifications/realtime', () => ({
|
|
14
|
+
useRealtime: () => ({
|
|
15
|
+
subscribe: (_envelope, handler) => { frameHandlers.add(handler); return () => frameHandlers.delete(handler); },
|
|
16
|
+
onReconnect: () => () => {},
|
|
17
|
+
}),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
import { AuthContext } from '../src/auth/AuthContext';
|
|
21
|
+
import { MessagingProvider } from '../src/messaging/MessagingProvider';
|
|
22
|
+
import { ReadTicks } from '../src/messaging/ReadTicks';
|
|
23
|
+
import { Thread } from '../src/messaging/Thread';
|
|
24
|
+
|
|
25
|
+
const theme = createTheme();
|
|
26
|
+
function withTheme(children) { return <ThemeProvider theme={theme}>{children}</ThemeProvider>; }
|
|
27
|
+
|
|
28
|
+
function makeApi(overrides = {}) {
|
|
29
|
+
return {
|
|
30
|
+
listConversations: vi.fn().mockResolvedValue({ results: [] }),
|
|
31
|
+
listMessages: vi.fn().mockResolvedValue({ results: [] }),
|
|
32
|
+
listThread: vi.fn().mockResolvedValue({ results: [] }),
|
|
33
|
+
getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
|
|
34
|
+
markConversationRead: vi.fn().mockResolvedValue({}),
|
|
35
|
+
getReadStatus: vi.fn().mockResolvedValue({ all_read: false }),
|
|
36
|
+
...overrides,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
beforeEach(() => { frameHandlers = new Set(); });
|
|
41
|
+
afterEach(cleanup);
|
|
42
|
+
|
|
43
|
+
describe('MSG-6f scope B: read ratio vs two-state tick', () => {
|
|
44
|
+
it('4a: a direct conversation renders a two-state tick, and the two states resolve to visibly different colours', async () => {
|
|
45
|
+
const api = makeApi({ getReadStatus: vi.fn().mockResolvedValue({ all_read: false }) });
|
|
46
|
+
const { rerender } = render(withTheme(
|
|
47
|
+
<MessagingProvider api={api} active={false}><ReadTicks messageId="m1" conversation={{ id: 'c1', kind: 'direct' }} /></MessagingProvider>
|
|
48
|
+
));
|
|
49
|
+
const sentIcon = await screen.findByLabelText('MessagingReadTicks.SENT');
|
|
50
|
+
const sentColor = getComputedStyle(sentIcon).color;
|
|
51
|
+
|
|
52
|
+
api.getReadStatus.mockResolvedValue({ all_read: true });
|
|
53
|
+
cleanup();
|
|
54
|
+
render(withTheme(
|
|
55
|
+
<MessagingProvider api={api} active={false}><ReadTicks messageId="m1" conversation={{ id: 'c1', kind: 'direct' }} /></MessagingProvider>
|
|
56
|
+
));
|
|
57
|
+
const readIcon = await screen.findByLabelText('MessagingReadTicks.ALL_READ');
|
|
58
|
+
const readColor = getComputedStyle(readIcon).color;
|
|
59
|
+
|
|
60
|
+
expect(sentColor).toBeTruthy();
|
|
61
|
+
expect(readColor).toBeTruthy();
|
|
62
|
+
expect(sentColor).not.toBe(readColor);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('4b: a group WITH counts renders dcm\'s own read_count/recipient_count verbatim, never recomputed from recipientDetail (must fail if recomputed -- recipientDetail is not thresholded against this message\'s created_at, only dcm\'s own counts are)', async () => {
|
|
66
|
+
// Deliberately inconsistent with read_count/recipient_count: every
|
|
67
|
+
// participant has SOME last_read_at (e.g. from reading the conversation
|
|
68
|
+
// long before this message existed), which is exactly the shape that
|
|
69
|
+
// would make a naive "count non-null last_read_at" recomputation report
|
|
70
|
+
// 40/40 -- the bug caught in review. dcm's own read_count (18) is the
|
|
71
|
+
// only correct source; recipientDetail must not be trusted for the ratio.
|
|
72
|
+
const allEverRead = Array.from({ length: 40 }, (_, i) => ({ user_id: `u-${i}`, last_read_at: '2020-01-01T00:00:00Z' }));
|
|
73
|
+
const api = makeApi({ getReadStatus: vi.fn().mockResolvedValue({ all_read: false, read_count: 18, recipient_count: 40, recipient_detail: allEverRead }) });
|
|
74
|
+
render(<MessagingProvider api={api} active={false}><ReadTicks messageId="m1" conversation={{ id: 'c1', kind: 'group' }} /></MessagingProvider>);
|
|
75
|
+
const ratio = await screen.findByLabelText(/READ_RATIO/);
|
|
76
|
+
expect(ratio.textContent).toBe('18/40');
|
|
77
|
+
expect(document.body.querySelector('[data-testid="DoneOutlinedIcon"]')).toBeNull();
|
|
78
|
+
expect(document.body.querySelector('[data-testid="DoneAllOutlinedIcon"]')).toBeNull();
|
|
79
|
+
expect(document.body.textContent).not.toMatch(/Zugestellt/);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('4c: a group WITHOUT counts (an ordinary participant) renders no indicator at all', async () => {
|
|
83
|
+
const api = makeApi({ getReadStatus: vi.fn().mockResolvedValue({ all_read: false }) });
|
|
84
|
+
const { container } = render(<MessagingProvider api={api} active={false}><ReadTicks messageId="m1" conversation={{ id: 'c1', kind: 'group' }} /></MessagingProvider>);
|
|
85
|
+
await waitFor(() => expect(api.getReadStatus).toHaveBeenCalled());
|
|
86
|
+
expect(container.textContent).toBe('');
|
|
87
|
+
expect(container.querySelector('svg')).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('5: all_read still renders the double check with its own unchanged label', async () => {
|
|
91
|
+
const api = makeApi({ getReadStatus: vi.fn().mockResolvedValue({ all_read: true }) });
|
|
92
|
+
render(<MessagingProvider api={api} active={false}><ReadTicks messageId="m1" conversation={{ id: 'c1', kind: 'direct' }} /></MessagingProvider>);
|
|
93
|
+
const icon = await screen.findByLabelText('MessagingReadTicks.ALL_READ');
|
|
94
|
+
expect(icon.getAttribute('data-testid')).toBe('DoneAllOutlinedIcon');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('MSG-6f scope C: re-mark read on a live message frame for the open conversation', () => {
|
|
99
|
+
function renderThread(api, { userId = 1 } = {}) {
|
|
100
|
+
return render(
|
|
101
|
+
<AuthContext.Provider value={{ user: { id: userId } }}>
|
|
102
|
+
<MessagingProvider api={api}><Thread conversationId="c1" /></MessagingProvider>
|
|
103
|
+
</AuthContext.Provider>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
it('marks read again when a message frame arrives for the currently-open conversation', async () => {
|
|
108
|
+
const api = makeApi();
|
|
109
|
+
renderThread(api);
|
|
110
|
+
await waitFor(() => expect(api.markConversationRead).toHaveBeenCalledTimes(1));
|
|
111
|
+
api.markConversationRead.mockClear();
|
|
112
|
+
|
|
113
|
+
act(() => dispatchFrame({ type: 'message', conversation_id: 'c1', message: { id: 'm2', conversation_id: 'c1', sender: { id: 2 } } }));
|
|
114
|
+
|
|
115
|
+
await waitFor(() => expect(api.markConversationRead).toHaveBeenCalled());
|
|
116
|
+
expect(api.markConversationRead.mock.calls[0][0]).toBe('c1');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('does not re-mark for a message frame belonging to a different conversation', async () => {
|
|
120
|
+
const api = makeApi();
|
|
121
|
+
renderThread(api);
|
|
122
|
+
await waitFor(() => expect(api.markConversationRead).toHaveBeenCalledTimes(1));
|
|
123
|
+
api.markConversationRead.mockClear();
|
|
124
|
+
|
|
125
|
+
act(() => dispatchFrame({ type: 'message', conversation_id: 'other-conversation', message: { id: 'm2' } }));
|
|
126
|
+
|
|
127
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
128
|
+
expect(api.markConversationRead).not.toHaveBeenCalled();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('does not re-mark while the document is hidden', async () => {
|
|
132
|
+
const api = makeApi();
|
|
133
|
+
renderThread(api);
|
|
134
|
+
await waitFor(() => expect(api.markConversationRead).toHaveBeenCalledTimes(1));
|
|
135
|
+
api.markConversationRead.mockClear();
|
|
136
|
+
|
|
137
|
+
const visibilitySpy = vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden');
|
|
138
|
+
act(() => dispatchFrame({ type: 'message', conversation_id: 'c1', message: { id: 'm2' } }));
|
|
139
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
140
|
+
expect(api.markConversationRead).not.toHaveBeenCalled();
|
|
141
|
+
visibilitySpy.mockRestore();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('does not re-mark for a non-message frame type (e.g. reaction) for the same conversation', async () => {
|
|
145
|
+
const api = makeApi();
|
|
146
|
+
renderThread(api);
|
|
147
|
+
await waitFor(() => expect(api.markConversationRead).toHaveBeenCalledTimes(1));
|
|
148
|
+
api.markConversationRead.mockClear();
|
|
149
|
+
|
|
150
|
+
act(() => dispatchFrame({ type: 'reaction', conversation_id: 'c1', message_id: 'm2' }));
|
|
151
|
+
|
|
152
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
153
|
+
expect(api.markConversationRead).not.toHaveBeenCalled();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -7,14 +7,22 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
|
|
|
7
7
|
|
|
8
8
|
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options?.count !== undefined ? `${key}:${options.count}` : key, i18n: { language: 'en' } }) }));
|
|
9
9
|
|
|
10
|
+
// Thread.jsx (MSG-6f scope C) now subscribes to the messaging envelope
|
|
11
|
+
// independently of MessagingProvider's own subscription -- a Set of handlers
|
|
12
|
+
// per envelope, not one handler per envelope, so a dispatched frame still
|
|
13
|
+
// reaches every current subscriber, matching the real registry's multi-handler support.
|
|
10
14
|
const realtimeSubscribers = new Map();
|
|
11
15
|
vi.mock('../src/notifications/realtime', () => ({
|
|
12
16
|
useRealtime: () => ({
|
|
13
|
-
subscribe: (envelope, handler) => {
|
|
17
|
+
subscribe: (envelope, handler) => {
|
|
18
|
+
if (!realtimeSubscribers.has(envelope)) realtimeSubscribers.set(envelope, new Set());
|
|
19
|
+
realtimeSubscribers.get(envelope).add(handler);
|
|
20
|
+
return () => realtimeSubscribers.get(envelope)?.delete(handler);
|
|
21
|
+
},
|
|
14
22
|
onReconnect: () => () => {},
|
|
15
23
|
}),
|
|
16
24
|
}));
|
|
17
|
-
function dispatchMessagingFrame(frame) { realtimeSubscribers.get('messaging')?.(frame); }
|
|
25
|
+
function dispatchMessagingFrame(frame) { realtimeSubscribers.get('messaging')?.forEach((handler) => handler(frame)); }
|
|
18
26
|
|
|
19
27
|
import { ConversationList } from '../src/messaging/ConversationList';
|
|
20
28
|
import { Thread } from '../src/messaging/Thread';
|
|
@@ -19,7 +19,7 @@ function makeApi(messages = []) {
|
|
|
19
19
|
getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
|
|
20
20
|
markConversationRead: vi.fn().mockResolvedValue({}),
|
|
21
21
|
markThreadRead: vi.fn().mockResolvedValue({}),
|
|
22
|
-
getReadStatus: vi.fn().mockResolvedValue({ all_read: false
|
|
22
|
+
getReadStatus: vi.fn().mockResolvedValue({ all_read: false }),
|
|
23
23
|
};
|
|
24
24
|
}
|
|
25
25
|
function renderBubble(ui) {
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# WORK ORDER MSG-6f (ui-core-micha) — the host title resolver is never called for broadcasts, and the read ticks claim a delivery that does not exist
|
|
2
|
+
|
|
3
|
+
**EXECUTION DIRECTIVE.** If you are the implementer reading this as your own spec, this section is not
|
|
4
|
+
addressed to you — it tells the Orchestrator how to invoke you; you ARE that invocation, do not shell
|
|
5
|
+
out to `codex exec`. Orchestrator: implement through `codex exec` in the background, invoked **directly
|
|
6
|
+
via Bash** (never the `debugger`/`*_coder` Agent wrappers) with **both** flags `--skip-git-repo-check`
|
|
7
|
+
and `--dangerously-bypass-approvals-and-sandbox`, prompt passed as a positional argument from this
|
|
8
|
+
file. Fall back to direct Claude implementation only on Codex quota / rate-limit / non-zero exit.
|
|
9
|
+
|
|
10
|
+
Feature-level WO (Expertenchat envelope). Both defects were found by **verifying the landed MSG-6e /
|
|
11
|
+
MSG-7 / MSG-8 / MSG-10 work in a running app** (jg-ferien local, flag on, ucm 2.21.3, dcm 2.39.5,
|
|
12
|
+
2026-08-03). Both are cases of "the other side shipped its half; ucm still renders the old behaviour".
|
|
13
|
+
|
|
14
|
+
## TIER
|
|
15
|
+
Tier 2 — shared-core, consumed by every app. Independent `reviewer` mandatory. `ui_reviewer` for
|
|
16
|
+
scope B (it changes what a user believes about message delivery).
|
|
17
|
+
|
|
18
|
+
## SCOPE
|
|
19
|
+
|
|
20
|
+
**A. `titleOf` never calls the host resolver for a broadcast — jg's fix is dead code.**
|
|
21
|
+
|
|
22
|
+
`ConversationList.jsx` `titleOf()` gates the host callback on kind:
|
|
23
|
+
|
|
24
|
+
```js
|
|
25
|
+
if (conversation.kind === 'managed' && resolveManagedLabel) {
|
|
26
|
+
const label = resolveManagedLabel(conversation);
|
|
27
|
+
if (label) return label;
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
jg-ferien MSG-10 correctly taught its `resolveManagedConversationLabel` to handle
|
|
32
|
+
`kind === 'broadcast'` — but ucm never calls it for that kind, so the branch is unreachable from the
|
|
33
|
+
list and the row still falls through to `t('MessagingList.UNTITLED')`.
|
|
34
|
+
|
|
35
|
+
**Verified live after MSG-10 landed:** the same conversation (`kind: "broadcast"`,
|
|
36
|
+
`external_key: "broadcast"`, `title: null`) renders **"Unbenannte Unterhaltung"** in the list and
|
|
37
|
+
**"Event-Ankündigungen"** in the open thread header — the thread header works only because jg's own
|
|
38
|
+
`resolveConversationTitle` calls the resolver directly, with no kind gate.
|
|
39
|
+
|
|
40
|
+
**The fix: stop gating on kind.** Call `resolveManagedLabel` for **every** conversation and let the
|
|
41
|
+
host decide — a `null` return already means "I have no label", which is exactly the delegation contract,
|
|
42
|
+
and the comment directly above `titleOf` already describes the label as host-supplied. Gating on kind
|
|
43
|
+
inside ucm duplicates a decision that belongs to the host and will break again for the next kind.
|
|
44
|
+
|
|
45
|
+
Check `resolveDirectUserName`'s gate (`kind === 'direct' && other_user_id != null`) in the same pass and
|
|
46
|
+
state whether it has the same problem. Do not change it blindly — a direct conversation is the one kind
|
|
47
|
+
where the id-based lookup is genuinely required.
|
|
48
|
+
|
|
49
|
+
**B. The read ticks assert a delivery that the platform does not track.**
|
|
50
|
+
|
|
51
|
+
Operator report, 2026-08-03: *"die Häkchen geben das Gefühl, dass jede Nachricht sofort zugestellt ist,
|
|
52
|
+
auch wenn das Browserfenster geschlossen ist."* Correct, and it is now strictly worse than when it was
|
|
53
|
+
written:
|
|
54
|
+
|
|
55
|
+
- dcm MSG-7 scope C **removed `delivered_count`** from the `read-status` response — verified live, the
|
|
56
|
+
response now carries only `all_read` and `recipient_detail`.
|
|
57
|
+
- `ReadTicks.jsx` still renders
|
|
58
|
+
`t('MessagingReadTicks.DELIVERED', { count: renderedStatus.delivered_count || 0 })`. With the field
|
|
59
|
+
gone, `undefined || 0` produces the **identical** label as before. Verified live: every own message
|
|
60
|
+
shows `aria-label="Zugestellt an 0"`.
|
|
61
|
+
|
|
62
|
+
So the UI states "Zugestellt an 0" — simultaneously claiming a delivery and reporting that it reached
|
|
63
|
+
nobody — on a single outlined check that reads as WhatsApp's delivered-tick. There is no delivery
|
|
64
|
+
signal behind it at all.
|
|
65
|
+
|
|
66
|
+
**Resolution (operator-directed, 2026-08-03): show a read PROPORTION in group and broadcast, keep a
|
|
67
|
+
two-state tick in DMs.**
|
|
68
|
+
|
|
69
|
+
The operational need in a camp is *"did a relevant share of the group read this?"* — a leader posting
|
|
70
|
+
"bus leaves at 14:00" to forty participants needs a number, not a checkmark. `all_read` is the wrong
|
|
71
|
+
measurement, not a useless feature: in any group it is essentially never true, so a boolean pins the
|
|
72
|
+
indicator to its first state precisely where the information matters most.
|
|
73
|
+
|
|
74
|
+
Operator decision, 2026-08-03:
|
|
75
|
+
|
|
76
|
+
| Conversation kind | Viewer | Display |
|
|
77
|
+
|---|---|---|
|
|
78
|
+
| `direct` | sender | two-state tick — `DoneOutlinedIcon` **light grey** "Gesendet" → `DoneAllOutlinedIcon` **dark blue** "Gelesen" |
|
|
79
|
+
| `group`, `broadcast`, `managed` | team member | a **ratio**: "18/40" (read / members). Numeric, no icon ladder. |
|
|
80
|
+
| `group`, `broadcast`, `managed` | ordinary participant | **nothing** — render no indicator at all |
|
|
81
|
+
| per-recipient detail | team member | unchanged — already correctly gated (see below) |
|
|
82
|
+
|
|
83
|
+
**The contrast between the two DM states must be clearly stronger than today.** Currently
|
|
84
|
+
`color={status.all_read ? 'primary' : 'inherit'}`, and `inherit` inside the meta `Stack` resolves to
|
|
85
|
+
`text.disabled` — the two states are barely distinguishable. Use an explicit light grey and an explicit
|
|
86
|
+
dark blue rather than `inherit`/`primary`, and verify the difference is obvious at the rendered
|
|
87
|
+
`1rem` icon size in both light and dark theme. The icon shape already differs (`Done` vs `DoneAll`), so
|
|
88
|
+
the distinction does not rest on colour alone — keep it that way.
|
|
89
|
+
|
|
90
|
+
**"nothing" for an ordinary participant in a group is deliberate, not an oversight.** A permanently
|
|
91
|
+
grey tick — which is what `all_read` would produce in any real group — is the "looks stuck" failure this
|
|
92
|
+
scope exists to remove. No indicator is better than a misleading one.
|
|
93
|
+
|
|
94
|
+
**No client-side permission logic.** dcm MSG-9 returns the counts only to viewers holding
|
|
95
|
+
`read_receipt_detail`, so the rule is purely shape-driven: *counts present → ratio; else `direct` →
|
|
96
|
+
tick; else → render nothing*. Do not branch on roles or capabilities in ucm.
|
|
97
|
+
|
|
98
|
+
**The server already has the number and throws it away.** `read_status` (`services.py:344-347`) computes
|
|
99
|
+
`all_read` from exactly the queryset that yields the count:
|
|
100
|
+
`participants.filter(last_read_at__gte=message.created_at).count()` — the same shape as the
|
|
101
|
+
`delivered_count` that had no writer. **A `read_count` + `recipient_count` pair is the counter
|
|
102
|
+
`delivered_count` should have been.**
|
|
103
|
+
|
|
104
|
+
Adding them is a `django-core-micha` change, not ucm's: **this scope is blocked on `django-core-micha`
|
|
105
|
+
MSG-9**, which adds `read_count` and `recipient_count` inside the existing `read_receipt_detail`-gated
|
|
106
|
+
block. Keep `all_read` — the DM branch still uses it.
|
|
107
|
+
|
|
108
|
+
Do not derive the ratio client-side by counting `recipient_detail` yourself. It would happen to work
|
|
109
|
+
(both are gated on the same right), but it re-implements a server aggregate in the client and would
|
|
110
|
+
silently produce a wrong denominator the moment dcm's participant filtering changes. Read the counts
|
|
111
|
+
dcm sends, or render nothing.
|
|
112
|
+
|
|
113
|
+
Retire `MessagingReadTicks.DELIVERED` and its `{{count}}` regardless — the new ratio string is a
|
|
114
|
+
different key with different semantics, and reusing "Zugestellt" would carry the false claim forward.
|
|
115
|
+
Also stop reading the now-always-`null` `last_delivered_at` out of `recipient_detail`.
|
|
116
|
+
|
|
117
|
+
**Per-recipient detail stays as it is.** `services.py:340` already gates it on
|
|
118
|
+
`kind != DIRECT and "read_receipt_detail" in rights` (in jg: `MANAGER_RIGHTS`), and DMs never expose it
|
|
119
|
+
even to moderators — a documented design invariant. This WO does not touch that; the popover keeps
|
|
120
|
+
working for managers.
|
|
121
|
+
|
|
122
|
+
**Why not implement delivery tracking as well** (wiring `mark_delivered` from a client ack, technically
|
|
123
|
+
possible since dcm MSG-8 made WS frames arrive): in a browser app "delivered" can only mean "a tab had
|
|
124
|
+
the socket open at that moment". For a recipient who is simply not online it would sit at the first
|
|
125
|
+
state indefinitely — replacing "looks always delivered" with "looks permanently stuck", plus a write per
|
|
126
|
+
received message. It only becomes meaningful with guaranteed device-level push. A read ratio answers the
|
|
127
|
+
real question without that ambiguity. **If the operator wants delivery too, it is a feature WO.**
|
|
128
|
+
|
|
129
|
+
**C. The read signal misses the most attentive reader — fix this or the ratio understates.**
|
|
130
|
+
|
|
131
|
+
What `read_count` actually measures must be stated plainly, because it is weaker than "read":
|
|
132
|
+
`mark_read` (`dcm services.py:288-294`) sets `ConversationParticipant.last_read_at` to *now*, and after
|
|
133
|
+
MSG-6e scope D there is exactly **one** caller — `Thread.jsx:58`, a `useEffect` keyed on
|
|
134
|
+
`[conversationId, markConversationRead]`. So the recorded fact is **"opened this conversation"**, and
|
|
135
|
+
`read_count` counts participants who opened it at some point after the message was posted.
|
|
136
|
+
|
|
137
|
+
It is not tab-open, not socket-connected, not per-message interaction, not viewport visibility.
|
|
138
|
+
|
|
139
|
+
Two asymmetries follow. The first is acceptable, the second is a defect:
|
|
140
|
+
|
|
141
|
+
1. Opening a conversation marks its **entire** history read in one stroke. Someone who opens it and
|
|
142
|
+
leaves immediately counts as having read a 200-message backlog. This is the universal convention
|
|
143
|
+
(WhatsApp behaves the same) — accept it, do not try to fix it here.
|
|
144
|
+
2. **A participant already sitting in the open conversation when the message arrives is NOT counted.**
|
|
145
|
+
The effect is keyed on `conversationId`, so it does not re-fire when a new message lands. The person
|
|
146
|
+
who saw it in real time is recorded as not having read it, until they navigate away and back.
|
|
147
|
+
|
|
148
|
+
Asymmetry 2 is newly common and newly consequential: before dcm MSG-8 no frame was ever delivered, so
|
|
149
|
+
no thread ever updated live and this case did not arise. Now it is the *normal* case for anyone
|
|
150
|
+
actually paying attention — and it biases the ratio **downward**, which is the wrong direction for a
|
|
151
|
+
leader deciding whether to chase people about the bus time.
|
|
152
|
+
|
|
153
|
+
**Fix: re-mark read when a new message arrives in the conversation currently open.** Trigger on the
|
|
154
|
+
incoming `message` frame for the active conversation, not on a timer. Guard it: do not fire for the
|
|
155
|
+
viewer's **own** send (dcm already excludes the sender from the fan-out, so no frame arrives — verify
|
|
156
|
+
rather than assume), and do not fire when the thread is mounted but the document is hidden
|
|
157
|
+
(`document.visibilityState`), or a background tab would report reads nobody performed. `mark_read` is
|
|
158
|
+
already idempotent — it only writes when the new timestamp is later (`services.py:291`) — so a
|
|
159
|
+
redundant call is cheap, but a *wrong* one is not.
|
|
160
|
+
|
|
161
|
+
**Known cost, do not silently ignore:** `Thread` mounts `ReadTicks` per own message and each instance
|
|
162
|
+
fires its own `read-status` request — O(n) for a sender with many messages in a thread, which is exactly
|
|
163
|
+
the broadcast-heavy leader this feature serves. A batch endpoint (message ids → counts) is the natural
|
|
164
|
+
answer and belongs in the same dcm WO. Do **not** fold the counts into `serialize_message` instead:
|
|
165
|
+
`views.py:50-54` documents why viewer-specific read state must stay out of it — it would leak into the
|
|
166
|
+
`message`/`message_edited` realtime frames, the same trap `thread_last_read_at` was kept out of.
|
|
167
|
+
|
|
168
|
+
## NON-GOALS / DO NOT TOUCH
|
|
169
|
+
- Do not reintroduce `delivered_count` on either side.
|
|
170
|
+
- Do not change `all_read`'s semantics or how `read_state` frames update the cached receipt (that is
|
|
171
|
+
MSG-6e scope C, already landed — leave it working).
|
|
172
|
+
- No change to `isOwn`/`senderName`/`canShowReadTicks`; the `sender` object from dcm 2.39.4 fixed those
|
|
173
|
+
and they are **verified working** (all server-loaded messages now render `own` with a tick).
|
|
174
|
+
- Do not touch jg-ferien. Its `resolveManagedConversationLabel` is already correct and must stay as it
|
|
175
|
+
is — scope A is what makes it reachable.
|
|
176
|
+
|
|
177
|
+
## RISKS
|
|
178
|
+
- **A changes behaviour for every host app**, including any that relies on `resolveManagedLabel` being
|
|
179
|
+
called only for `managed`. A host returning a non-null label for an unexpected kind would newly see
|
|
180
|
+
it win over `conversation.title`. Check the ordering in `titleOf` after the change: a host label
|
|
181
|
+
should still lose to nothing it previously lost to, apart from the intended new case.
|
|
182
|
+
- B changes a user-facing claim. Getting it wrong in the other direction (removing the indicator
|
|
183
|
+
entirely) loses the "did my message go out" affordance — hence two states, not zero.
|
|
184
|
+
- The retired i18n key may be referenced by host apps' tests. Grep before deleting and note any hits.
|
|
185
|
+
|
|
186
|
+
## REQUIRED TESTS TO WRITE
|
|
187
|
+
Narrow and behavioural. Do NOT run the full suite.
|
|
188
|
+
|
|
189
|
+
1. **A:** a conversation with `kind: 'broadcast'`, `title: null`, and a host `resolveManagedLabel` that
|
|
190
|
+
returns a string renders **that string**, not `MessagingList.UNTITLED`. This must **fail against
|
|
191
|
+
today's code** — prove it.
|
|
192
|
+
2. **A:** a host resolver returning `null` still falls through to the existing chain unchanged
|
|
193
|
+
(regression guard for the delegation contract).
|
|
194
|
+
3. **A:** the existing `managed` / `event_all` / `event_team` behaviour is unchanged.
|
|
195
|
+
4. **B:** `direct` renders the two-state tick and the two states use visibly different explicit colours (assert the resolved colours differ, not just that a colour prop is set); a `group` WITH counts renders the ratio; a `group` WITHOUT counts renders **no** indicator. Assert no label containing "Zugestellt" is produced anywhere in `ReadTicks`.
|
|
196
|
+
that **no** label containing a `{{count}}`-derived number is produced anywhere in `ReadTicks`.
|
|
197
|
+
5. **B:** `all_read` still renders the double check in `primary` with the unchanged label.
|
|
198
|
+
|
|
199
|
+
**Non-vacuity:** tests 1 and 4 must fail on the current code. Test 1 is the one that matters — jg
|
|
200
|
+
shipped a fix for this exact symptom that did nothing, because the test asserted on the wrong side of
|
|
201
|
+
the boundary. Assert on the **rendered list row**, not on the resolver function in isolation.
|
|
202
|
+
|
|
203
|
+
## TEST SCOPE FOR THE GATE (orchestrator)
|
|
204
|
+
`ConversationList` and `ReadTicks` test files plus the messaging i18n tests. Not the full suite.
|
|
205
|
+
|
|
206
|
+
## TARGET REPO
|
|
207
|
+
`C:\Users\biglmi\Documents\webapps\ui-core-micha`. Branch `main` (or `develop` if present). Publish per
|
|
208
|
+
the repo's release flow. jg's pin bump is a separate step; jg needs it to close its own MSG-9
|
|
209
|
+
finding 4/12, which cannot be fixed from jg at all.
|
|
210
|
+
|
|
211
|
+
## PROGRESS CONTRACT
|
|
212
|
+
Emit `PLAN: <steps>` up front, then a single-line `PROGRESS: [<n>/<total>] <action>` before every
|
|
213
|
+
relevant action and `PROGRESS: [<n>/<total>] done` on completion, spaced so no gap exceeds ~2 min,
|
|
214
|
+
stdout unbuffered, and exactly one final `RESULT: DONE|BLOCKED <reason>`.
|