@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 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.DELIVERED': { de: 'Zugestellt an {{count}}', en: 'Delivered to {{count}}', fr: 'Distribué à {{count}}' },
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
- if (conversation.kind === 'managed' && resolveManagedLabel) {
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 { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
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
- /** Renders only the aggregate receipt unless the server explicitly supplies permitted detail. */
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 renderedStatus = Object.assign(Object.assign({}, status), { recipient_detail: recipientDetail, all_read: allRead });
30
- const label = renderedStatus.all_read ? t('MessagingReadTicks.ALL_READ') : t('MessagingReadTicks.DELIVERED', { count: renderedStatus.delivered_count || 0 });
31
- const StatusIcon = renderedStatus.all_read ? DoneAllOutlinedIcon : DoneOutlinedIcon;
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 : renderedStatus.recipient_detail;
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 (!recipients.length)
36
- return _jsx(StatusIcon, { role: "img", "aria-label": label, color: renderedStatus.all_read ? 'primary' : 'inherit' });
37
- 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', color: renderedStatus.all_read ? 'primary.main' : 'inherit' }, children: _jsx(StatusIcon, { "aria-hidden": "true" }) }), _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)) }) })] });
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
  }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "2.21.3",
3
+ "version": "2.22.0",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "repository": {
@@ -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.DELIVERED': { de: 'Zugestellt an {{count}}', en: 'Delivered to {{count}}', fr: 'Distribué à {{count}}' },
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
- if (conversation.kind === 'managed' && resolveManagedLabel) {
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
- /** Renders only the aggregate receipt unless the server explicitly supplies permitted detail. */
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 renderedStatus = { ...status, recipient_detail: recipientDetail, all_read: allRead };
27
- const label = renderedStatus.all_read ? t('MessagingReadTicks.ALL_READ') : t('MessagingReadTicks.DELIVERED', { count: renderedStatus.delivered_count || 0 });
28
- const StatusIcon = renderedStatus.all_read ? DoneAllOutlinedIcon : DoneOutlinedIcon;
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 : renderedStatus.recipient_detail;
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
- if (!recipients.length) return <StatusIcon role="img" aria-label={label} color={renderedStatus.all_read ? 'primary' : 'inherit'} />;
33
- return <><ButtonBase aria-label={label} aria-haspopup="dialog" aria-expanded={Boolean(anchorEl)} onClick={(event) => setAnchorEl(event.currentTarget)} sx={{ display: 'inline-flex', color: renderedStatus.all_read ? 'primary.main' : 'inherit' }}><StatusIcon aria-hidden="true" /></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></>;
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
  }
@@ -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');
@@ -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, delivered_count: 2 }),
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 aggregate read state and never exposes supplied recipient details for a direct conversation', async () => {
60
- api.getReadStatus.mockResolvedValue({ all_read: false, delivered_count: 2, recipient_detail: [{ display_name: 'Private recipient' }] });
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.DELIVERED:2');
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, delivered_count: 2, recipient_detail: [{ display_name: 'Visible recipient' }] });
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.DELIVERED:2');
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.DELIVERED:2')).toBeTruthy());
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
- let frameHandler;
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) => { frameHandler = handler; return () => {}; }, onReconnect: () => () => {} }) }));
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, delivered_count: 2 }),
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
- afterEach(() => { cleanup(); vi.useRealTimers(); vi.restoreAllMocks(); frameHandler = undefined; });
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, delivered_count: 2, recipient_detail: [{ display_name: 'Ava' }] }) });
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.DELIVERED:2' });
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.DELIVERED:2');
96
- expect(screen.queryByRole('button', { name: 'MessagingReadTicks.DELIVERED:2' })).toBeNull();
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, delivered_count: 2 }),
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: 'group' }} /> });
28
- const tick = await screen.findByLabelText('MessagingReadTicks.DELIVERED:2');
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
- let frameHandler;
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) => { frameHandler = handler; return () => {}; }, onReconnect: () => () => {} }) }));
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, delivered_count: 1, recipient_detail: [{ user_id: 'u2', display_name: 'Ava' }] }), markConversationRead: vi.fn().mockResolvedValue({}), ...overrides });
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 and thread-read frames to cache and lets ReadTicks change without another REST call', async () => {
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.DELIVERED:1');
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
- await screen.findByLabelText('MessagingReadTicks.ALL_READ');
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.DELIVERED:1');
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) => { realtimeSubscribers.set(envelope, handler); return () => realtimeSubscribers.delete(envelope); },
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, delivered_count: 0 }),
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>`.