@micha.bigler/ui-core-micha 2.17.0 → 2.19.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
@@ -30,4 +30,6 @@ prefixed ID; never reuse a bare flat number across workstreams.
30
30
  | NOTIF-12 | Popup channel via the wizard renderer | Make `popup` a real delivery channel: extract the presentational dialog shell out of `OnboardingWizard`, render popup-eligible notifications through it, and record seen/dismissed on `NotificationRecipient` (design D-F7: renderer reuse only, NOT the onboarding progress store); dcm `PopupDispatcher` stops being a logging stub. Full spec: [work-orders/NOTIF-12.md](work-orders/NOTIF-12.md). | 2026-07-29 | done | ucm: c8e222f, d7bdcbe; dcm: django-core-micha 1612429, 82fd89f | Tier 2 (extracts a renderer cockpit+jg run in production; the roadmap label "uncritical" is about urgency, not blast radius). **HARD GATE: starts only after NOTIF-13 landed + published** — consumes its envelope contract and `subscribe()`, and edits the same ucm files (AGENTS.md overlap rule). **SHIPS WITH ZERO PRODUCERS, deliberately** (operator decision 2026-07-29, made with the evidence): no notification type declares `popup` in `eligible_channels` anywhere in dcm/jg/cockpit/hram/spesix/survey_app, so the channel is wired but inactive until an app opts a type in. WO therefore requires (a) CHANGELOG wording that does not overclaim (NOTIF-4 R1 precedent) and (b) a test-local producer type proving the path end-to-end, so it is not untested dead code. Was NOTIF-11 (renumbered 2026-07-29 for the jg cutover/drop split). **Gate satisfied 2026-07-30** (ucm 2.13.0 / dcm 2.33.0); WO re-verified against the landed NOTIF-13 code and corrected in `345631d`: NOTIF-13 shipped the envelope as a **domain**-level discriminator, so a popup payload built the same way as a chip payload would be indistinguishable from it - the WO now mandates a `channel` field inside the envelope (never a second envelope value) plus feed de-duplication by `notification_id`, without which chip+popup double-counts the unread badge. **Codex failed twice with the same genuine CLI/model-version error as NOTIF-13** (`gpt-5.6-terra requires a newer version of Codex`) — orchestrator implemented directly in Claude per the Codex→Claude fallback rule, both scopes. Independent `reviewer` (background agent, `fork_context=false`, full diff of both repos): found R1 (P1, real bug — the WS payload only carried `notification_id`, but `feed/mark/` resolves ids against `NotificationRecipient.pk`; every `markSeen`/`markDismissed` from a session-live-only popup would either no-op or corrupt an unrelated recipient row once a producer opts in — fixed by adding `recipient_id` to both `ChipDispatcher` and `PopupDispatcher` payloads, dcm-side, with regression tests), R2 (P2 — the reused dialog shell always showed the onboarding "Setup"/step-counter header even for a popup, a real mislabeling once any producer exists — fixed via an optional `title` override on `WizardDialogShell`, defaulting to the prior exact text so `OnboardingWizard` stays bit-for-bit unchanged; popup now shows its own notification title), R3 (P2 — coexistence only gated on a *blocking* onboarding step, so a non-blocking step (e.g. `browser_push`) and a popup could stack two simultaneous MUI dialogs — fixed by broadening the gate to any active onboarding step, with a new regression test). All three fixed and re-verified: ucm 88/88 vitest + `pnpm`/tsc build clean, dcm 95/95 pytest (notifications package). Published (once pushed): ucm 2.14.0 (npm, CI-gated on push to `main`), dcm 2.34.0 (PyPI, same). No app pin bumps in this WO per spec. Own orchestrator security/exposure pass in parallel with the reviewer: no findings (content rendering follows the existing `NotificationBell` i18n-interpolation pattern, no new WS consumer, `prefs.py`/`router.py`/`resolve_channels` untouched). |
31
31
  | DX-1 | Minimal Vite dev harness for ucm components | Give ucm a way to **render** its components during development: a `dev` script starting a Vite dev page that mounts surfaces with the repo's MUI theme, i18n and router context, driven by fixtures through an injectable mock API/realtime adapter, with light/dark and viewport switching. Harness code stays out of the published package (not exported, excluded from the build, zero runtime deps). | 2026-07-31 | done | 7ea7b26 | New `DX-*` prefix (ucm-local). Envelope: [work-orders/DX-1.md](work-orders/DX-1.md). Tier 1, but **adds devDependencies → approval-gated**; operator approval granted 2026-07-31 as part of the MSG-3 scoping decision. Gap found while scoping MSG-3: `package.json` had only `build` (tsc) and `test` (vitest) — no Storybook, no demo, no dev page, so every ucm surface ever shipped (auth, onboarding, notifications, charts) was verified only in jsdom or after a consuming app pinned it. Storybook deliberately not chosen (config surface vs. a repo with no story-writing habit). **Runs before MSG-3**, which is ~3700 LOC of UI with no consumer until MSG-5 and an operator-granted redesign licence that is only iterable if the result can be looked at. No version bump, no publish (dev-only). **Implemented (Codex, one pass):** `pnpm dev` (`vite`) boots a harness (`dev/main.jsx`) with MUI theme/i18n/router, light/dark + mobile/tablet/desktop switching, entries for a notifications surface (`NotificationBell`) and a standalone chart (`BarChart`, proving single-component mounting), plus a dedicated realtime-adapter demo entry. Mock transport (`dev/mockTransport.jsx`) overrides `apiClient`'s axios adapter for REST and provides a `RealtimeContext.Provider` for context-consuming components. Independent `reviewer`: one P2 — `NotificationsProvider` opens its own WebSocket internally rather than reading one from context, so the notifications entry opened a real, always-failing socket; fixed by the orchestrator with a `MockWebSocket` class registered as `window.WebSocket` while the harness is mounted, covering both consumption paths. Verified in the browser (both entries, dispatch button, no console errors/WS connection attempts). Build clean, `dist/` unaffected, `dependencies`/`peerDependencies` untouched (only `devDependencies` + `scripts.dev`), 104/104 tests green. |
32
32
  | MSG-3 | ucm messaging surfaces (full parity) | `MessagingProvider` + API adapter + normalized cache, `ConversationList`/`Thread`/`Composer`/`ReadTicks`/`ReactionBar`/`PollCard`/`AttachmentList` + config/preferences and launchers, on the Layer-1 transport (`subscribe('messaging', …)`, `event_id` dedup, reconnect refetch); opaque-cursor pagination with reverse infinite scroll and optimistic send from day one; de/en/fr. Host apps supply routing/display/scope pickers, not a forked state machine. | 2026-07-31 | done | affa5ac, 5e87315, a12c9c6, c7100b1, 95b69d4, 974e24c, 4369400 | ucm-side mirror of the canonical dcm `MSG-3` row. Envelope: [work-orders/MSG-3.md](work-orders/MSG-3.md). Tier 2, 5 proposed chunks (register's "3-5" treated as a floor — jg's parity target is ~3700 LOC excluding tests, `Thread.jsx` alone 2470). Two operator decisions 2026-07-31: (1) **redesign permitted** for layout/composition/interaction, but the design's "No jg feature is lost" stays binding and the WO must ship a written **deviation list** — consequence: MSG-5 becomes a visible change for jg users and needs its own UX review; (2) validation runs through the **DX-1** harness, which is therefore a precondition. Carries `ui_reviewer` at WO end in addition to the per-chunk independent `reviewer`. **Decomposition is a binding requirement, not a preference:** jg's UI is one 2470-LOC component with a 2173-LOC test file, and reproducing that shape is a scope violation even with every feature present — each named component is separately exported, independently mountable and independently testable, state lives in the provider rather than being prop-drilled through `Thread`, every component gets its own DX-1 harness entry (the forcing function — a monolith cannot be mounted piecewise), and any component file above ~400 LOC must be justified in that chunk's review. Depends on dcm 2.36.1 (MSG-2 + MSG-2b). **All 5 chunks implemented, independently reviewed and committed** (chunk 1: additive Layer-1 `onReconnect` extension per operator decision, provider/cache/transport; chunk 2: conversation list + launchers, R1 menu-anchor fix, R2 per-kind icon, R3 broadcast-launcher visibility; chunk 3: Thread/MessageBubble/ReadTicks, R1 N+1 ReadTicks gating fix; chunk 4: Composer/attachments, R1 `extractApiErrorMessage` for dcm's `{files:[...]}` shape, R2 optimistic sender, R3 own-message pending-ReadTicks fix, R4 i18n diacritics; chunk 5: reactions/polls/scope-config/announcements, R1-R4 + a follow-up verification pass V1-V3 including building jg's previously-missing `AnnouncementDialog` parity per explicit operator decision). **WO-end `ui_reviewer`** (independent, full assembled feature not one chunk's diff): found U1-U5, all P2/P3, none blocking — raw-`<button>` reply action instead of MUI `Button` (U1), PollCard packed into the same wrapping flex row as reaction chips instead of its own full-width row (U2), MessagingScopeConfig's text-only loading state instead of the package's `CircularProgress` convention (U3), a raw inline-style attachment thumbnail instead of theme spacing (U4), and `MessageBubble`/`ConversationLaunchers` missing their required DX-1 harness entries (U5). All five fixed by the orchestrator (`974e24c`), re-verified 139/139 tests + clean build. Translation coverage, dark-mode tokens, deviation-doc accuracy and primitive reuse checked clean, no findings. Published: **ucm 2.16.0** (npm, CI-gated on push to `main`, `4369400`). No CHANGELOG.md in this repo (same as noted in NOTIF-13/NOTIF-12) — documented via commit messages + this register entry only. No app pin bumps in this WO. |
33
- | MSG-3b | Close the MSG-3 parity and contract gaps | Capability-checklist WO closing what MSG-3 missed: the entire unread lifecycle (mark-read never called — badges once raised never clear), message edit/delete + action menu, the DM launcher (`createDirectConversation` had no caller — the case MSG-2b unblocked in the backend), broken reply grouping (`serialize_message` emits `reply_to_id`, `Thread.jsx:40,62` reads `reply_to`, so every server-loaded reply renders as a root), quoted reply previews + jump-to-original, copy, client-side image compression, staged-image previews, upload progress, composer emoji insertion, relative timestamps, active-row highlight, announcement deep-link rendering. Plus a contract-conformance test and a corrected deviation list. | 2026-07-31 | in-review | c164db3, db8cd92, 61ed3b8, 7253172, fadb60a, 646fd01 | Extends the landed MSG-3 (2.16.0), convention as MSG-2b/NOTIF-8b. Envelope: [work-orders/MSG-3b.md](work-orders/MSG-3b.md). Tier 2. **Origin: a three-way audit** (jg capability baseline / ucm wired-vs-adapter-only inventory / dcm contract-coverage sweep) run after MSG-3 was marked done. MSG-3 had 139/139 green, five per-chunk reviews and a clean `ui_reviewer`, and still missed all of it — **because both sides were tested against themselves**: dcm against its services, ucm against a mock whose surface mirrors the provider rather than the contract, so an endpoint with no caller is structurally invisible. Second cause: "no jg feature is lost" was made binding without anyone writing the feature list first, and the deviation list meant to prove parity was authored by the party doing the work; its "Final parity confirmation" section claims completeness and is demonstrably false. This WO's checklist IS the spec (59 rows, each OK/DEV/MISSING/BROKEN/PARTIAL/BLOCKED; the review corrected one false OK — row 5, unread never live-updates at all); the contract-conformance test is the systemic fix. **Rows 38, 51-53, 56-58 are BLOCKED on dcm** (`MSG-2c`, not yet cut): no `serialize_poll` exists at all so `PollCard` reads fields no endpoint returns — and the design specifies no poll read path either, so `messaging-platform.md` must be amended first; `services.py` has 3 `_publish` call sites for 12 designed frames, so reaction/poll live sync cannot work; `serialize_conversation` omits `last_message`, so the list preview is permanently empty. Carries `ui_reviewer` at WO end, which must check the corrected deviation list against the checklist row by row. **All 6 chunks implemented, independently reviewed and committed** (chunk 1: unread lifecycle end-to-end + contract-conformance test, own-fix for a markThreadRead failure silently blocking thread-open; chunk 2: `reply_to`/`reply_to_id` fix + quoting/jump-to-original, row 27 found and recorded BLOCKED (no dcm thread-receipt read path) after an independent-review P1 caught it being silently dropped first time; chunk 3: message action menu/edit/delete/copy with a host-supplied moderator capability prop (dcm exposes no server-side capability field), fixed an undefined-vs-undefined author-match bug; chunk 4: `DirectMessageLauncher` wiring `createDirectConversation`, fixed jest-dom-matcher and MUI-dialog-transition-timing test issues after Codex's own verification pass died without a `RESULT:` line; chunk 5: composer shortcuts/previews/compression/emoji, fixed Codex settling for indeterminate-only upload progress by threading real `onUploadProgress` through the adapter as the WO asked, fixed missing diacritics in new translations (same bug class caught once before in MSG-3), corrected an unverified poll/attachment-mutual-exclusivity claim before it could land in the deviation doc; chunk 6: list/timeline polish (relative timestamps, auto-scroll, announcement links, DM sender-suppression, read-receipt popover) plus the full deviation-doc rewrite against all 59 checklist rows — fixed a repeat of an already-once-fixed raw-interactive-element pattern, corrected an overclaiming doc line, fixed a stale-ref edge case in auto-scroll). Row 42 (managed all-vs-team) also verified genuinely BLOCKED — dcm has `external_key` but never serializes it. Status `in-review`, not yet `done`: WO-end `ui_reviewer` pass and the version-bump/publish step are still outstanding. |
33
+ | MSG-3b | Close the MSG-3 parity and contract gaps | Capability-checklist WO closing what MSG-3 missed: the entire unread lifecycle (mark-read never called — badges once raised never clear), message edit/delete + action menu, the DM launcher (`createDirectConversation` had no caller — the case MSG-2b unblocked in the backend), broken reply grouping (`serialize_message` emits `reply_to_id`, `Thread.jsx:40,62` reads `reply_to`, so every server-loaded reply renders as a root), quoted reply previews + jump-to-original, copy, client-side image compression, staged-image previews, upload progress, composer emoji insertion, relative timestamps, active-row highlight, announcement deep-link rendering. Plus a contract-conformance test and a corrected deviation list. | 2026-07-31 | done | c164db3, db8cd92, 61ed3b8, 7253172, fadb60a, 646fd01, 9748aca | Extends the landed MSG-3 (2.16.0), convention as MSG-2b/NOTIF-8b. Envelope: [work-orders/MSG-3b.md](work-orders/MSG-3b.md). Tier 2. **Origin: a three-way audit** (jg capability baseline / ucm wired-vs-adapter-only inventory / dcm contract-coverage sweep) run after MSG-3 was marked done. MSG-3 had 139/139 green, five per-chunk reviews and a clean `ui_reviewer`, and still missed all of it — **because both sides were tested against themselves**: dcm against its services, ucm against a mock whose surface mirrors the provider rather than the contract, so an endpoint with no caller is structurally invisible. Second cause: "no jg feature is lost" was made binding without anyone writing the feature list first, and the deviation list meant to prove parity was authored by the party doing the work; its "Final parity confirmation" section claims completeness and is demonstrably false. This WO's checklist IS the spec (59 rows, each OK/DEV/MISSING/BROKEN/PARTIAL/BLOCKED; the review corrected one false OK — row 5, unread never live-updates at all); the contract-conformance test is the systemic fix. **Rows 38, 51-53, 56-58 are BLOCKED on dcm** (`MSG-2c`, not yet cut): no `serialize_poll` exists at all so `PollCard` reads fields no endpoint returns — and the design specifies no poll read path either, so `messaging-platform.md` must be amended first; `services.py` has 3 `_publish` call sites for 12 designed frames, so reaction/poll live sync cannot work; `serialize_conversation` omits `last_message`, so the list preview is permanently empty. Carries `ui_reviewer` at WO end, which must check the corrected deviation list against the checklist row by row. **All 6 chunks implemented, independently reviewed and committed** (chunk 1: unread lifecycle end-to-end + contract-conformance test, own-fix for a markThreadRead failure silently blocking thread-open; chunk 2: `reply_to`/`reply_to_id` fix + quoting/jump-to-original, row 27 found and recorded BLOCKED (no dcm thread-receipt read path) after an independent-review P1 caught it being silently dropped first time; chunk 3: message action menu/edit/delete/copy with a host-supplied moderator capability prop (dcm exposes no server-side capability field), fixed an undefined-vs-undefined author-match bug; chunk 4: `DirectMessageLauncher` wiring `createDirectConversation`, fixed jest-dom-matcher and MUI-dialog-transition-timing test issues after Codex's own verification pass died without a `RESULT:` line; chunk 5: composer shortcuts/previews/compression/emoji, fixed Codex settling for indeterminate-only upload progress by threading real `onUploadProgress` through the adapter as the WO asked, fixed missing diacritics in new translations (same bug class caught once before in MSG-3), corrected an unverified poll/attachment-mutual-exclusivity claim before it could land in the deviation doc; chunk 6: list/timeline polish (relative timestamps, auto-scroll, announcement links, DM sender-suppression, read-receipt popover) plus the full deviation-doc rewrite against all 59 checklist rows — fixed a repeat of an already-once-fixed raw-interactive-element pattern, corrected an overclaiming doc line, fixed a stale-ref edge case in auto-scroll). Row 42 (managed all-vs-team) also verified genuinely BLOCKED — dcm has `external_key` but never serializes it. **WO-end `ui_reviewer`** (independent, row-by-row cross-check of `docs/messaging-deviations.md` against landed code for all 59 checklist rows, per the WO's binding "the review that signed off the false completeness claim is the one this WO exists to correct" instruction): no findings — every OK/DEV/BLOCKED row verified against actual code, not against the doc's own claims; translation coverage, DX-1 harness coverage, design-system/dark-mode/reuse all checked clean. Published: **ucm 2.17.0** (npm, CI-gated on push to `main`, `9748aca`). No CHANGELOG.md in this repo (same as NOTIF-12/NOTIF-13/MSG-3) — documented via commit messages + this register entry only. No app pin bumps in this WO. |
34
+ | MSG-3c | Deliver the rows MSG-3b left blocked against a stale dcm version | Seven checklist rows (38, 51-53, 56-58) that MSG-3b correctly refused to fake against dcm 2.36.1, and which dcm 2.37.0 made buildable **while MSG-3b was still running**: real last-message preview + list reorder/preview refresh, full `PollCard` rendering (question / options / `vote_count` / `voters` / closed state), and the `reaction` / `poll_updated` frame handlers that stop being dead code. Plus the deviation-doc correction and a conformance-test extension requiring every BLOCKED entry to carry a dependency version pin. | 2026-07-31 | done | 84cc3c2, 5dd4d3a, 733588a, 170ce1e | Envelope: [work-orders/MSG-3c.md](work-orders/MSG-3c.md); canonical row in dcm's register. Extends the landed MSG-3b (2.17.0). **Root cause — third occurrence of one pattern in this workstream:** the WO-end `ui_reviewer` cross-checked all 59 rows against *ucm's* landed code and took the dependency's state from the WO text instead of reading dcm, so seven rows closed as "blocked on a future dcm work order" that had already shipped. New rule this WO adds: a BLOCKED row is re-verified against the live dependency **at WO close, not at WO start**, and every BLOCKED entry names the version it was blocked at. MSG-3b already did the version-pinning unprompted — that pin is the only reason the staleness was a two-minute check rather than surfacing at MSG-5. Rows 27 and 42 stay correctly BLOCKED, on dcm `MSG-2d`. Highest latent risk: rows 56/57 are handler paths that have **never** run against a real frame, so treat them as new code under test rather than as working code being unblocked; and poll tests must use server-shaped payloads (`serialize_poll` output), since client-shaped fixtures are what let the `reply_to_id` bug survive MSG-3. **Codex was out of credits for this entire WO** (verified: every invocation returned only a workspace-credits error, no work done) — all three chunks implemented directly by the orchestrator per the Codex→Claude fallback rule, with mandatory independent review on every chunk (author ≠ reviewer). **Chunk 1** (rows 38/58): fixed a real bug found while scoping — `ConversationList` read `last_message.body`, but dcm's `serialize_last_message` only ever returns `.excerpt` — plus a defensive fix to `applyFrame`'s `message_edited` keying (reviewer found my initial bug analysis incomplete: dcm always attaches a full `frame.message`, so the fix is a no-op against the real wire contract but kept as genuine defensive robustness; the regression test was rewritten to use the real enriched frame shape instead of a fabricated minimal one). **Chunk 2** (rows 51-53): fixed `PollCard` reading non-existent `created_by`/`selected`/`voted` fields (real fields: `created_by_id`, `voted_option_ids` — the latter present only on the vote/create/close REST responses, never in `serialize_poll` or the `poll_updated` frame); found and fixed the same "frame wholesale-replaces viewer-specific state" defect class in `poll_updated`'s own handler ahead of chunk 3's explicit scope. **Chunk 3** (rows 56/57 + WO deliverables): found and fixed the identical defect in the `reaction` frame handler and `toggleReaction`'s REST-confirm path (dcm's `serialize_reactions` never returns a per-viewer `reacted` flag; both call sites were wholesale-replacing and silently wiping known reacted state); extended the contract-conformance test with the WO's new BLOCKED-version-pin rule plus a required negative-case test; rewrote `docs/messaging-deviations.md` (rows 38/51/53/56-58 → OK, row 52 → DEV, rows 27/42 stay BLOCKED with the new version pin, substance untouched). **WO-end `ui_reviewer`** (independent, re-derived rows 27/42 against dcm's actual committed `HEAD`, not the WO text or the doc's own claims, per this WO's own corrective rule): no findings — dcm's committed state is still 2.37.0 (a naive check of dcm's working tree showed 2.38.0, but that's uncommitted `MSG-2d` WIP from a concurrent session, not the published state); rows 27/42 confirmed genuinely still BLOCKED at the real committed version; all seven newly-delivered rows spot-checked against dcm's committed serializers/services directly, all match. Flagged for awareness, not actionable now: dcm's uncommitted `MSG-2d` WIP (external_key, thread-receipt read path) will very likely unblock rows 27/42 once committed and published — a future `MSG-3d` candidate. Published: **ucm 2.18.0** (npm, CI-gated on push to `main`, `170ce1e`). No CHANGELOG.md in this repo (same as MSG-3/MSG-3b/NOTIF-12/NOTIF-13) — documented via commit messages + this register entry only. No app pin bumps in this WO. |
35
+ | MSG-3d | The last two blocked rows: unread-reply marker and managed-conversation identity | Deliver checklist rows **27** (unread-reply marker on the thread toggle, from `reply_count`/`last_reply_at` plus the REST-only `thread_last_read_at`) and **42** (surface `external_key` so a host can distinguish managed conversations — ucm must NOT know that `event_all`/`event_team` mean anything, that is jg vocabulary). Deviation doc reaches **zero** BLOCKED entries. | 2026-08-01 | in-review | 6708ec8 | Envelope: [work-orders/MSG-3d.md](work-orders/MSG-3d.md); canonical row in dcm's register. Extends MSG-3c (2.18.0). Unblocked by dcm 2.38.0 (MSG-2d), verified in `serializers.py` before authoring rather than assumed. **Carries a named trap:** `thread_last_read_at` is viewer-specific and REST-only while `reply_count`/`last_reply_at` ride `serialize_message` and therefore the `message`/`message_edited` frames — so every such frame carries the projection *without* the receipt. That is the **fourth** field of the exact shape whose merge behaviour caused three live bugs in MSG-3c; the field-wise-merge obligation is now in design §Realtime and has a mandatory test here. Second named risk: jg vocabulary leaking into a consumer-agnostic package via row 42, with its own assertion. **Tier 2** (raised from Tier 1 on review — the change lands in the frame-merge code where MSG-3c found three shipped bugs; size does not lower review sensitivity), independent `reviewer` + `ui_reviewer` at WO end. Review also surfaced a plan gap now closed in the envelope: a new reply arrives as its own `message` frame and does NOT update the root's counters, so the root must be incremented client-side or the marker only appears after a refetch (jg does this live). **Closes the post-audit remediation** — after this every MSG-3b checklist row is implemented or a recorded deliberate deviation, and MSG-5 is unblocked. **Codex out of credits again** (fourth consecutive WO in this workstream) — implemented directly, independent review mandatory and run. Fixed a real bug while implementing: `markReplyThreadRead` fired the REST read call and discarded the response entirely, so the receipt was never actually cached client-side before this WO. Own review caught a TDZ crash (a `useCallback` dependency array referencing `patchMessage` before its declaration — dependency arrays evaluate eagerly, unlike the deferred callback body) before the independent reviewer ever saw it. Independent `reviewer`: R1 (P2) — the live-root-update test only proved the marker's visibility, which can't distinguish "still read" from "receipt silently wiped"; added a dedicated assertion on the receipt's own value across the bump, the WO's named highest-risk path. R2 (P2) — the marker's `aria-label` sat on MUI's `Badge` wrapper, which spreads unrecognized props onto a non-interactive `span` a screen reader wouldn't focus; moved onto the inner `Button`. R3 (P3) — a dead client-shaped `reply_to` fallback had crept into the new root-bump code despite the WO's explicit warning against it; removed. R4 (P3) — documented in code that the deliberate absence of self-send-marker suppression relies on dcm excluding the sender from live WS fan-out, an unstated cross-repo invariant. Status `in-review`, not yet `done`: WO-end `ui_reviewer` pass and the version-bump/publish step are still outstanding. |
package/dev/entries.jsx CHANGED
@@ -61,15 +61,29 @@ function RealtimeAdapterEntry() {
61
61
  const messagingHarnessApi = {
62
62
  listConversations: async ({ cursor } = {}) => cursor
63
63
  ? ({ results: [{ id: 13, title: 'Archived planning', last_message_at: '2026-07-30T09:00:00Z' }], next_cursor: null })
64
- : ({ results: [{ id: 12, title: 'Support group', unread_count: 1, last_message_at: '2026-07-31T09:00:00Z', last_message: { body: 'Can someone help?' } }], next_cursor: 'signed-next-page' }),
64
+ : ({ results: [
65
+ { id: 12, title: 'Support group', unread_count: 1, last_message_at: '2026-07-31T09:00:00Z', last_message: { excerpt: 'Can someone help?' } },
66
+ // Row 42 (MSG-3d): two managed conversations, distinguishable only via
67
+ // external_key — ucm renders whatever ConversationListEntry's
68
+ // resolveManagedLabel resolver below returns, never the raw value.
69
+ { id: 30, kind: 'managed', external_key: 'all', last_message_at: '2026-07-29T09:00:00Z' },
70
+ { id: 31, kind: 'managed', external_key: 'team', last_message_at: '2026-07-28T09:00:00Z' },
71
+ ], next_cursor: 'signed-next-page' }),
65
72
  // GET conversations/{id}/messages/ returns roots only (design §REST); replies
66
73
  // come from GET messages/{root_id}/thread/ (listThread below), never mixed in here.
74
+ // reply_count with no thread_last_read_at (MSG-3d row 27): the unread-reply
75
+ // marker on the thread toggle should render visibly by default here.
67
76
  listMessages: async (conversationId, { cursor } = {}) => cursor
68
77
  ? ({ results: [{ id: 42, conversation_id: conversationId, body: 'An older message', created_at: '2026-07-30T09:00:00Z' }], next_cursor: null })
69
- : ({ results: [{ id: 44, conversation_id: conversationId, body: 'Initial message', created_at: '2026-07-31T09:00:00Z', sender: { display_name: 'Alex' }, reply_count: 1 }], next_cursor: 'signed-older-page' }),
78
+ : ({ results: [{ id: 44, conversation_id: conversationId, body: 'Initial message', created_at: '2026-07-31T09:00:00Z', sender: { display_name: 'Alex' }, reply_count: 1, last_reply_at: '2026-07-31T09:05:00Z', thread_last_read_at: null }], next_cursor: 'signed-older-page' }),
70
79
  listThread: async (rootId) => ({ results: [{ id: 45, reply_to: rootId, body: 'A reply', created_at: '2026-07-31T09:05:00Z', sender: { display_name: 'Sam' } }], next_cursor: null }),
71
80
  getReadStatus: async () => ({ all_read: false, delivered_count: 2 }),
72
81
  getUnreadCount: async () => ({ unread_count: 1, by_conversation: { 12: 1 } }),
82
+ markConversationRead: async () => ({}),
83
+ // The response's last_read_at (MSG-3d) maps onto the root's own
84
+ // thread_last_read_at cache field — clicking "Show replies" in this harness
85
+ // should make the unread-reply marker disappear.
86
+ markThreadRead: async () => ({ last_read_at: new Date().toISOString() }),
73
87
  archiveConversation: async (id, archived) => ({ id, archived }),
74
88
  patchConversationPreferences: async (id, patch) => ({ id, ...patch }),
75
89
  createGroupConversation: async (payload) => ({ id: 20, title: payload.title || 'Opened group', kind: 'group' }),
@@ -115,6 +129,7 @@ function ConversationListEntry() {
115
129
  groupLaunchers={[{ id: 'volunteers', label: 'Open volunteers group', payload: { title: 'Volunteers', participant_ids: [1, 2] } }]}
116
130
  broadcastLauncher={{ label: 'Open announcements', payload: { scope: { kind: 'global' } } }}
117
131
  onOpen={(conversation) => setOpened(conversation)}
132
+ resolveManagedLabel={(conversation) => (conversation.external_key === 'all' ? 'Everyone' : conversation.external_key === 'team' ? 'My team' : null)}
118
133
  />
119
134
  {opened && <Typography sx={{ mt: 2 }}>Opened: {opened.title}</Typography>}
120
135
  </MessagingProvider>
@@ -31,6 +31,7 @@ export const messagingTranslations = {
31
31
  'MessagingThread.REPLYING_TO': { de: 'Antwort an {{sender}}', en: 'Replying to {{sender}}', fr: 'Réponse à {{sender}}' },
32
32
  'MessagingThread.SHOW_REPLIES': { de: '{{count}} Antworten anzeigen', en: 'Show {{count}} replies', fr: 'Afficher {{count}} réponses' },
33
33
  'MessagingThread.HIDE_REPLIES': { de: 'Antworten ausblenden', en: 'Hide replies', fr: 'Masquer les réponses' },
34
+ 'MessagingThread.UNREAD_REPLIES': { de: 'Ungelesene Antworten', en: 'Unread replies', fr: 'Réponses non lues' },
34
35
  'MessagingThread.UNKNOWN_SENDER': { de: 'Unbekannte Person', en: 'Unknown person', fr: 'Personne inconnue' },
35
36
  'MessagingThread.QUOTE_ATTACHMENT': { de: 'Anhang', en: 'Attachment', fr: 'Pièce jointe' },
36
37
  'MessagingActions.MENU': { de: 'Nachrichtenaktionen', en: 'Message actions', fr: 'Actions du message' },
@@ -85,6 +86,7 @@ export const messagingTranslations = {
85
86
  'MessagingPoll.ADD_OPTION': { de: 'Option hinzufügen', en: 'Add option', fr: 'Ajouter une option' },
86
87
  'MessagingPoll.ALLOW_MULTIPLE': { de: 'Mehrfachauswahl erlauben', en: 'Allow multiple choices', fr: 'Autoriser plusieurs choix' },
87
88
  'MessagingPoll.CANCEL': { de: 'Abbrechen', en: 'Cancel', fr: 'Annuler' },
89
+ 'MessagingPoll.YOU_VOTED': { de: 'von dir gewählt', en: 'voted by you', fr: 'voté par vous' },
88
90
  'MessagingConfig.LABEL': { de: 'Nachrichten-Einstellungen', en: 'Messaging settings', fr: 'Paramètres de messagerie' },
89
91
  'MessagingConfig.TITLE': { de: 'Nachrichten-Einstellungen', en: 'Messaging settings', fr: 'Paramètres de messagerie' },
90
92
  'MessagingConfig.LOADING': { de: 'Einstellungen werden geladen…', en: 'Loading settings…', fr: 'Chargement des paramètres…' },
@@ -5,7 +5,19 @@ import { Badge, Box, Button, CircularProgress, IconButton, List, ListItemButton,
5
5
  import { useTranslation } from 'react-i18next';
6
6
  import { useMessaging } from './MessagingProvider';
7
7
  import { ConversationLaunchers } from './ConversationLaunchers';
8
- function titleOf(conversation, t) { var _a, _b; return conversation.title || ((_a = conversation.other_user) === null || _a === void 0 ? void 0 : _a.display_name) || ((_b = conversation.other_user) === null || _b === void 0 ? void 0 : _b.username) || t('MessagingList.UNTITLED'); }
8
+ // external_key (dcm 2.38.0+) is the consuming app's own identity for a
9
+ // managed conversation — ucm must not know what any particular app's values
10
+ // mean, so a label for it is host-supplied only; resolveManagedLabel receives
11
+ // the raw conversation (including external_key) and owns all interpretation.
12
+ function titleOf(conversation, t, resolveManagedLabel) {
13
+ var _a, _b;
14
+ if (conversation.kind === 'managed' && resolveManagedLabel) {
15
+ const label = resolveManagedLabel(conversation);
16
+ if (label)
17
+ return label;
18
+ }
19
+ return conversation.title || ((_a = conversation.other_user) === null || _a === void 0 ? void 0 : _a.display_name) || ((_b = conversation.other_user) === null || _b === void 0 ? void 0 : _b.username) || t('MessagingList.UNTITLED');
20
+ }
9
21
  function ordered(conversations) {
10
22
  return [...conversations].sort((left, right) => new Date(right.last_message_at || 0) - new Date(left.last_message_at || 0));
11
23
  }
@@ -28,7 +40,7 @@ function KindIcon({ conversation }) {
28
40
  return _jsx(Person, { fontSize: "small" });
29
41
  }
30
42
  /** A standalone, provider-backed list; scope picker metadata is supplied as launcher props. */
31
- export function ConversationList({ onOpen, groupLaunchers, broadcastLauncher, autoOpenBroadcast = false, includeArchived = false }) {
43
+ export function ConversationList({ onOpen, groupLaunchers, broadcastLauncher, autoOpenBroadcast = false, includeArchived = false, resolveManagedLabel }) {
32
44
  const { t, i18n } = useTranslation();
33
45
  const { cache, activeConversationId, loadMoreConversations, setConversationArchived, setConversationPreferences, markConversationRead } = useMessaging();
34
46
  const [menuConversation, setMenuConversation] = useState(null);
@@ -77,6 +89,6 @@ export function ConversationList({ onOpen, groupLaunchers, broadcastLauncher, au
77
89
  var _a;
78
90
  const unread = unreadFor(conversation);
79
91
  const archived = Boolean(conversation.archived || conversation.archived_at);
80
- return _jsxs(ListItemButton, { selected: conversation.id === activeConversationId, onClick: () => { markConversationRead(conversation.id).catch(() => { }); onOpen === null || onOpen === void 0 ? void 0 : onOpen(conversation); }, sx: { minHeight: 64, opacity: archived ? 0.6 : 1 }, children: [_jsx(Badge, { color: "error", badgeContent: unread || null, max: 99, sx: { mr: 2 }, children: archived ? _jsx(Archive, { fontSize: "small", color: "disabled" }) : _jsx(KindIcon, { conversation: conversation }) }), _jsx(ListItemText, { primary: _jsxs(Stack, { direction: "row", spacing: 1, justifyContent: "space-between", children: [_jsx(Typography, { component: "span", fontWeight: unread ? 700 : 400, noWrap: true, children: titleOf(conversation, t) }), relativeTime(conversation.last_message_at, i18n === null || i18n === void 0 ? void 0 : i18n.language) && _jsx(Typography, { component: "span", variant: "caption", color: "text.secondary", noWrap: true, children: relativeTime(conversation.last_message_at, i18n === null || i18n === void 0 ? void 0 : i18n.language) })] }), secondary: ((_a = conversation.last_message) === null || _a === void 0 ? void 0 : _a.body) || t('MessagingList.NO_MESSAGES'), secondaryTypographyProps: { noWrap: true } }), _jsx(IconButton, { "aria-label": t('MessagingList.ACTIONS'), onClick: (event) => { event.stopPropagation(); setMenuAnchor(event.currentTarget); setMenuConversation(conversation); }, children: _jsx(MoreVert, {}) })] }, conversation.id);
92
+ return _jsxs(ListItemButton, { selected: conversation.id === activeConversationId, onClick: () => { markConversationRead(conversation.id).catch(() => { }); onOpen === null || onOpen === void 0 ? void 0 : onOpen(conversation); }, sx: { minHeight: 64, opacity: archived ? 0.6 : 1 }, children: [_jsx(Badge, { color: "error", badgeContent: unread || null, max: 99, sx: { mr: 2 }, children: archived ? _jsx(Archive, { fontSize: "small", color: "disabled" }) : _jsx(KindIcon, { conversation: conversation }) }), _jsx(ListItemText, { primary: _jsxs(Stack, { direction: "row", spacing: 1, justifyContent: "space-between", children: [_jsx(Typography, { component: "span", fontWeight: unread ? 700 : 400, noWrap: true, children: titleOf(conversation, t, resolveManagedLabel) }), relativeTime(conversation.last_message_at, i18n === null || i18n === void 0 ? void 0 : i18n.language) && _jsx(Typography, { component: "span", variant: "caption", color: "text.secondary", noWrap: true, children: relativeTime(conversation.last_message_at, i18n === null || i18n === void 0 ? void 0 : i18n.language) })] }), secondary: ((_a = conversation.last_message) === null || _a === void 0 ? void 0 : _a.excerpt) || t('MessagingList.NO_MESSAGES'), secondaryTypographyProps: { noWrap: true } }), _jsx(IconButton, { "aria-label": t('MessagingList.ACTIONS'), onClick: (event) => { event.stopPropagation(); setMenuAnchor(event.currentTarget); setMenuConversation(conversation); }, children: _jsx(MoreVert, {}) })] }, conversation.id);
81
93
  }) })), cache.cursors.conversations && _jsx(Button, { onClick: loadMore, disabled: loadingMore, children: loadingMore ? _jsx(CircularProgress, { size: 18 }) : t('MessagingList.LOAD_MORE') }), _jsxs(Menu, { anchorEl: menuAnchor, open: Boolean(menuConversation), onClose: closeMenu, children: [_jsxs(MenuItem, { onClick: toggleMute, children: [_jsx(NotificationsOff, { fontSize: "small", sx: { mr: 1 } }), (menuConversation === null || menuConversation === void 0 ? void 0 : menuConversation.muted) ? t('MessagingList.UNMUTE') : t('MessagingList.MUTE')] }), _jsxs(MenuItem, { onClick: archive, children: [_jsx(Archive, { fontSize: "small", sx: { mr: 1 } }), t('MessagingList.ARCHIVE')] })] })] }));
82
94
  }
@@ -92,31 +92,84 @@ export function messagingReducer(state, action) {
92
92
  }
93
93
  }
94
94
  export function applyFrame(state, frame, activeConversationId = null) {
95
- var _a, _b, _c, _d;
95
+ var _a, _b, _c, _d, _e, _f;
96
96
  const payload = frame.message || frame.conversation || frame.participant || frame;
97
97
  const conversationId = (_a = frame.conversation_id) !== null && _a !== void 0 ? _a : payload.conversation_id;
98
98
  if (frame.type === 'message') {
99
99
  const message = Object.assign(Object.assign({}, payload), { conversation_id: conversationId });
100
100
  const requestId = clientRequestIdOf(message);
101
101
  const reconcilesOptimisticMessage = Boolean(requestId && Object.values(state.messages).some((item) => clientRequestIdOf(item) === requestId));
102
- const messages = reconcileMessage(state.messages, message);
102
+ let messages = reconcileMessage(state.messages, message);
103
+ // reply_count/last_reply_at are viewer-independent facts (serialize_message
104
+ // always includes them, live-recomputed when not annotated) — bump the
105
+ // cached root unconditionally, regardless of who sent the reply. Spread
106
+ // the existing root first and only override these two fields, never
107
+ // rebuild the object from frame data, so thread_last_read_at (REST-only,
108
+ // never present on this frame) is never wiped — the merge-trap discipline
109
+ // MSG-3c's fixes established for exactly this failure shape.
110
+ //
111
+ // No self-send suppression here: a viewer never receives a "message"
112
+ // frame for their own reply in the first place — dcm's
113
+ // resolve_live_recipients(sender=actor) excludes the sender from the
114
+ // live WS fan-out (django_core_micha/messaging/services.py), so this
115
+ // branch structurally cannot fire for the viewer's own send. That is a
116
+ // dcm-side guarantee this code relies on, not something ucm re-derives —
117
+ // if dcm's recipient exclusion ever changes, this would need revisiting.
118
+ //
119
+ // reply_to_id only, never a client-shaped reply_to fallback — a live
120
+ // "message" frame is always server-shaped (serialize_message), so a
121
+ // reply_to fallback here would be dead-code-guessing at a payload shape
122
+ // no server ever sends, exactly the kind of unverified compatibility
123
+ // guess this workstream has twice shipped as a real bug.
124
+ const rootId = message.reply_to_id;
125
+ const root = rootId != null ? messages[rootId] : null;
126
+ if (root)
127
+ messages = Object.assign(Object.assign({}, messages), { [rootId]: Object.assign(Object.assign({}, root), { reply_count: (root.reply_count || 0) + 1, last_reply_at: message.created_at || root.last_reply_at }) });
103
128
  if (conversationId == null || String(conversationId) === String(activeConversationId) || reconcilesOptimisticMessage)
104
129
  return Object.assign(Object.assign({}, state), { messages });
105
130
  const unread = state.unread || EMPTY_CACHE.unread;
106
131
  const previousCount = ((_b = unread.by_conversation) === null || _b === void 0 ? void 0 : _b[conversationId]) || 0;
107
132
  return Object.assign(Object.assign({}, state), { messages, unread: Object.assign(Object.assign({}, unread), { unread_count: (unread.unread_count || 0) + 1, by_conversation: Object.assign(Object.assign({}, unread.by_conversation), { [conversationId]: previousCount + 1 }) }) });
108
133
  }
109
- if (frame.type === 'message_edited')
110
- return Object.assign(Object.assign({}, state), { messages: mergeById(state.messages, payload) });
111
- if (frame.type === 'reaction') {
134
+ if (frame.type === 'message_edited') {
135
+ // dcm's publish_messaging_event always attaches a full frame.message
136
+ // (serialize_message) for this event type, so `payload` already carries
137
+ // the message's own `.id` in practice. The explicit `id:` override below
138
+ // is defensive only — it keeps this branch correct even if a future
139
+ // producer ever omits `.message` and falls through to the raw frame,
140
+ // whose `conversation_id` would otherwise win idOf()'s fallback order.
112
141
  const messageId = (_c = frame.message_id) !== null && _c !== void 0 ? _c : payload.message_id;
142
+ return Object.assign(Object.assign({}, state), { messages: mergeById(state.messages, Object.assign(Object.assign({}, payload), { id: messageId })) });
143
+ }
144
+ if (frame.type === 'reaction') {
145
+ const messageId = (_d = frame.message_id) !== null && _d !== void 0 ? _d : payload.message_id;
113
146
  const previous = state.messages[messageId] || { id: messageId };
114
- return Object.assign(Object.assign({}, state), { messages: mergeById(state.messages, Object.assign(Object.assign({}, previous), { reactions: payload.reactions || frame.reactions || previous.reactions || [] })) });
147
+ // serialize_reactions (dcm) returns [{emoji, count}] only, never a
148
+ // per-viewer `reacted` flag, in any context — not REST, not frames, by
149
+ // design (aggregate-only). Wholesale-replacing `reactions` with the
150
+ // incoming server list would silently wipe every previously-known
151
+ // `reacted` flag on this message, not just whichever emoji changed.
152
+ // Merge by emoji instead: carry forward a previously-known `reacted`
153
+ // flag for any emoji still present in the incoming aggregate.
154
+ const incoming = payload.reactions || frame.reactions || previous.reactions || [];
155
+ const previousByEmoji = new Map((previous.reactions || []).map((reaction) => [reaction.emoji, reaction]));
156
+ const reactions = incoming.map((reaction) => { var _a, _b, _c; return (Object.assign(Object.assign({}, reaction), { reacted: (_c = (_a = reaction.reacted) !== null && _a !== void 0 ? _a : (_b = previousByEmoji.get(reaction.emoji)) === null || _b === void 0 ? void 0 : _b.reacted) !== null && _c !== void 0 ? _c : false })); });
157
+ return Object.assign(Object.assign({}, state), { messages: mergeById(state.messages, Object.assign(Object.assign({}, previous), { reactions })) });
115
158
  }
116
159
  if (frame.type === 'poll_updated') {
117
- const messageId = (_d = frame.message_id) !== null && _d !== void 0 ? _d : payload.message_id;
160
+ const messageId = (_e = frame.message_id) !== null && _e !== void 0 ? _e : payload.message_id;
118
161
  const previous = state.messages[messageId] || { id: messageId };
119
- const poll = payload.poll || frame.poll || payload;
162
+ const incomingPoll = payload.poll || frame.poll || payload;
163
+ // dcm's poll_updated frame is a viewer-independent projection
164
+ // (serialize_poll) — it never carries voted_option_ids, which dcm only
165
+ // ever adds to this viewer's own vote/create/close REST response. A
166
+ // frame arriving after a REST-derived vote is known must not wipe that
167
+ // locally-known selection; carry it forward when the frame is silent
168
+ // about it, never invert it from frame data that structurally can't
169
+ // contain it.
170
+ const previousVotedOptionIds = (_f = previous.poll) === null || _f === void 0 ? void 0 : _f.voted_option_ids;
171
+ const poll = incomingPoll.voted_option_ids === undefined && previousVotedOptionIds
172
+ ? Object.assign(Object.assign({}, incomingPoll), { voted_option_ids: previousVotedOptionIds }) : incomingPoll;
120
173
  return Object.assign(Object.assign({}, state), { messages: mergeById(state.messages, Object.assign(Object.assign({}, previous), { poll })), polls: mergeById(state.polls, poll) });
121
174
  }
122
175
  if (frame.type === 'message_deleted') {
@@ -200,7 +253,6 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
200
253
  dispatch({ type: 'conversationRead', conversationId });
201
254
  return result;
202
255
  }, [api]);
203
- const markReplyThreadRead = useCallback((rootId, readAt) => api.markThreadRead(rootId, readAt), [api]);
204
256
  const openGroupConversation = useCallback(async (payload) => {
205
257
  const conversation = await api.createGroupConversation(payload);
206
258
  return updateConversation(conversation);
@@ -254,6 +306,17 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
254
306
  }
255
307
  }, [api, user]);
256
308
  const patchMessage = useCallback((message) => dispatch({ type: 'messagePatched', message }), []);
309
+ const markReplyThreadRead = useCallback(async (rootId, readAt) => {
310
+ const result = await api.markThreadRead(rootId, readAt);
311
+ // The mark-read response's `last_read_at` maps onto the cached root
312
+ // message's `thread_last_read_at` field — different key names, same
313
+ // value; this is the only place the client ever learns this viewer's own
314
+ // thread receipt, since it's REST-only and never rides a realtime frame.
315
+ const previous = cache.messages[rootId];
316
+ if (previous && (result === null || result === void 0 ? void 0 : result.last_read_at) !== undefined)
317
+ patchMessage(Object.assign(Object.assign({}, previous), { thread_last_read_at: result.last_read_at }));
318
+ return result;
319
+ }, [api, cache.messages, patchMessage]);
257
320
  // Keep this API-backed action deliberately distinct from the cache-merge
258
321
  // helper above. The server remains the authority for author/moderator rights.
259
322
  const editMessage = useCallback(async (messageId, patch) => {
@@ -284,7 +347,14 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
284
347
  patchMessage(Object.assign(Object.assign({}, message), { reactions: nextReactions }));
285
348
  try {
286
349
  const result = active ? await api.removeReaction(messageId, emoji) : await api.addReaction(messageId, emoji);
287
- patchMessage(Object.assign(Object.assign(Object.assign({}, message), ((result === null || result === void 0 ? void 0 : result.message) || result)), { reactions: (result === null || result === void 0 ? void 0 : result.reactions) || ((_a = result === null || result === void 0 ? void 0 : result.message) === null || _a === void 0 ? void 0 : _a.reactions) || nextReactions }));
350
+ // Same aggregate-only shape as the realtime `reaction` frame (serialize_reactions
351
+ // never returns a per-viewer `reacted` flag) — merge the server's confirmed
352
+ // counts with the reacted flags nextReactions already computed correctly,
353
+ // rather than letting the reacted-less server array wipe them.
354
+ const confirmedCounts = (result === null || result === void 0 ? void 0 : result.reactions) || ((_a = result === null || result === void 0 ? void 0 : result.message) === null || _a === void 0 ? void 0 : _a.reactions);
355
+ const nextByEmoji = new Map(nextReactions.map((reaction) => [reaction.emoji, reaction]));
356
+ const reactions = confirmedCounts ? confirmedCounts.map((reaction) => { var _a, _b, _c; return (Object.assign(Object.assign({}, reaction), { reacted: (_c = (_a = reaction.reacted) !== null && _a !== void 0 ? _a : (_b = nextByEmoji.get(reaction.emoji)) === null || _b === void 0 ? void 0 : _b.reacted) !== null && _c !== void 0 ? _c : false })); }) : nextReactions;
357
+ patchMessage(Object.assign(Object.assign(Object.assign({}, message), ((result === null || result === void 0 ? void 0 : result.message) || result)), { reactions }));
288
358
  return result;
289
359
  }
290
360
  catch (error) {
@@ -5,19 +5,27 @@ import { useTranslation } from 'react-i18next';
5
5
  import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
6
6
  /** Independently mountable live poll view. Server authorization remains authoritative. */
7
7
  export function PollCard({ message, canClose: canCloseProp }) {
8
- var _a, _b, _c, _d, _e, _f;
8
+ var _a, _b, _c, _d, _e, _f, _g;
9
9
  const { t } = useTranslation();
10
10
  const { castPollVote, closeConversationPoll, currentUser } = useMessaging();
11
11
  const poll = message === null || message === void 0 ? void 0 : message.poll;
12
12
  const [selected, setSelected] = useState([]);
13
13
  const [error, setError] = useState(null);
14
14
  const [busy, setBusy] = useState(false);
15
- useEffect(() => setSelected(((poll === null || poll === void 0 ? void 0 : poll.options) || []).filter((option) => option.selected || option.voted).map((option) => option.id)), [poll]);
15
+ // voted_option_ids is a view-level enrichment dcm adds ONLY to the
16
+ // create/vote/close poll REST responses (serialize_poll itself, every
17
+ // message-list/thread load, and every poll_updated realtime frame never
18
+ // carry it) — so it's only ever present right after this viewer's own
19
+ // vote/create/close action. When it's absent (a freshly loaded or
20
+ // frame-updated poll), there's no way to know this viewer's vote from the
21
+ // payload alone; default to no selection rather than guessing or carrying
22
+ // over stale local state.
23
+ useEffect(() => setSelected((poll === null || poll === void 0 ? void 0 : poll.voted_option_ids) ? [...poll.voted_option_ids] : []), [poll]);
16
24
  if (!poll)
17
25
  return null;
18
26
  const closed = Boolean(poll.closed_at);
19
- const creatorId = (_b = (_a = poll.created_by) === null || _a === void 0 ? void 0 : _a.id) !== null && _b !== void 0 ? _b : poll.created_by;
20
- const canClose = (_f = (_d = (_c = canCloseProp !== null && canCloseProp !== void 0 ? canCloseProp : poll.can_close) !== null && _c !== void 0 ? _c : message === null || message === void 0 ? void 0 : message.can_close) !== null && _d !== void 0 ? _d : (_e = message === null || message === void 0 ? void 0 : message.permissions) === null || _e === void 0 ? void 0 : _e.can_close) !== null && _f !== void 0 ? _f : (creatorId != null && creatorId === (currentUser === null || currentUser === void 0 ? void 0 : currentUser.id));
27
+ const creatorId = (_c = (_a = poll.created_by_id) !== null && _a !== void 0 ? _a : (_b = poll.created_by) === null || _b === void 0 ? void 0 : _b.id) !== null && _c !== void 0 ? _c : poll.created_by;
28
+ const canClose = (_g = (_e = (_d = canCloseProp !== null && canCloseProp !== void 0 ? canCloseProp : poll.can_close) !== null && _d !== void 0 ? _d : message === null || message === void 0 ? void 0 : message.can_close) !== null && _e !== void 0 ? _e : (_f = message === null || message === void 0 ? void 0 : message.permissions) === null || _f === void 0 ? void 0 : _f.can_close) !== null && _g !== void 0 ? _g : (creatorId != null && creatorId === (currentUser === null || currentUser === void 0 ? void 0 : currentUser.id));
21
29
  const changeSelection = (id) => setSelected((current) => poll.allow_multiple ? (current.includes(id) ? current.filter((value) => value !== id) : [...current, id]) : (current.includes(id) ? [] : [id]));
22
30
  const vote = async () => {
23
31
  if (!selected.length) {
@@ -49,5 +57,14 @@ export function PollCard({ message, canClose: canCloseProp }) {
49
57
  setBusy(false);
50
58
  }
51
59
  };
52
- return _jsx(Paper, { variant: "outlined", sx: { p: 1 }, "aria-label": t('MessagingPoll.LABEL'), children: _jsxs(Stack, { spacing: 0.5, children: [_jsx(Typography, { variant: "subtitle2", children: poll.question }), (poll.options || []).map((option) => { var _a, _b; return _jsx(FormControlLabel, { disabled: closed || busy, control: _jsx(Checkbox, { checked: selected.includes(option.id), onChange: () => changeSelection(option.id) }), label: `${option.text || option.label} (${(_b = (_a = option.vote_count) !== null && _a !== void 0 ? _a : option.votes_count) !== null && _b !== void 0 ? _b : 0})` }, option.id); }), error && _jsx(Alert, { severity: "error", role: "alert", children: error }), _jsxs(Stack, { direction: "row", spacing: 1, children: [_jsx(Button, { size: "small", onClick: vote, disabled: closed || busy, children: t('MessagingPoll.VOTE') }), canClose && !closed && _jsx(Button, { size: "small", color: "warning", onClick: close, disabled: busy, children: t('MessagingPoll.CLOSE') }), closed && _jsx(Typography, { variant: "caption", children: t('MessagingPoll.CLOSED') })] })] }) });
60
+ return _jsx(Paper, { variant: "outlined", sx: { p: 1 }, "aria-label": t('MessagingPoll.LABEL'), children: _jsxs(Stack, { spacing: 0.5, children: [_jsx(Typography, { variant: "subtitle2", children: poll.question }), (poll.options || []).map((option) => {
61
+ var _a, _b, _c, _d, _e, _f, _g;
62
+ // dcm's poll projection gives voter user ids only, never names — there
63
+ // is no user-directory endpoint (same structural limit already
64
+ // established for the DM candidate list). A count is always correct;
65
+ // marking the viewer's own vote is derivable from voted_option_ids.
66
+ const votedByMe = Boolean((_a = poll.voted_option_ids) === null || _a === void 0 ? void 0 : _a.includes(option.id));
67
+ const voterCount = (_e = (_d = (_c = (_b = option.voters) === null || _b === void 0 ? void 0 : _b.length) !== null && _c !== void 0 ? _c : option.vote_count) !== null && _d !== void 0 ? _d : option.votes_count) !== null && _e !== void 0 ? _e : 0;
68
+ return _jsx(FormControlLabel, { disabled: closed || busy, control: _jsx(Checkbox, { checked: selected.includes(option.id), onChange: () => changeSelection(option.id) }), label: `${option.text || option.label} (${(_g = (_f = option.vote_count) !== null && _f !== void 0 ? _f : voterCount) !== null && _g !== void 0 ? _g : 0})${votedByMe ? ` — ${t('MessagingPoll.YOU_VOTED')}` : ''}` }, option.id);
69
+ }), error && _jsx(Alert, { severity: "error", role: "alert", children: error }), _jsxs(Stack, { direction: "row", spacing: 1, children: [_jsx(Button, { size: "small", onClick: vote, disabled: closed || busy, children: t('MessagingPoll.VOTE') }), canClose && !closed && _jsx(Button, { size: "small", color: "warning", onClick: close, disabled: busy, children: t('MessagingPoll.CLOSE') }), closed && _jsx(Typography, { variant: "caption", children: t('MessagingPoll.CLOSED') })] })] }) });
53
70
  }
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Alert, Box, Button, CircularProgress, Divider, Stack, Typography } from '@mui/material';
2
+ import { Alert, Badge, Box, Button, CircularProgress, Divider, Stack, Typography } from '@mui/material';
3
3
  import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
4
4
  import { useTranslation } from 'react-i18next';
5
5
  import { AuthContext } from '../auth/AuthContext';
@@ -8,6 +8,17 @@ import { useMessaging } from './MessagingProvider';
8
8
  import { ReadTicks } from './ReadTicks';
9
9
  function chronological(messages) { return [...messages].sort((left, right) => new Date(left.created_at || 0) - new Date(right.created_at || 0)); }
10
10
  function replyToId(message) { var _a; return (_a = message.reply_to_id) !== null && _a !== void 0 ? _a : message.reply_to; }
11
+ // last_reply_at/reply_count are viewer-independent (always on serialize_message);
12
+ // thread_last_read_at is viewer-specific and REST-only (never on a frame — see
13
+ // MessagingProvider.jsx's applyFrame merge-trap handling). A null receipt with
14
+ // at least one reply counts as unread; no replies means no marker at all.
15
+ function hasUnreadReplies(message) {
16
+ if (!message.reply_count)
17
+ return false;
18
+ if (message.thread_last_read_at == null)
19
+ return true;
20
+ return Boolean(message.last_reply_at) && new Date(message.last_reply_at) > new Date(message.thread_last_read_at);
21
+ }
11
22
  // A still-pending optimistic row (chunk 4) has a fake `local-<requestId>` id
12
23
  // and no durable server row yet — mounting ReadTicks against it would fire a
13
24
  // getMessageReadStatus REST call for an id the server has never heard of.
@@ -105,6 +116,14 @@ export function Thread({ conversationId, onReplyTargetChange, canModerateMessage
105
116
  };
106
117
  return (_jsxs(Stack, { spacing: 1, sx: { minWidth: 0 }, children: [error && _jsx(Alert, { severity: "error", role: "alert", children: error }), replyingTo && _jsx(Alert, { severity: "info", onClose: () => { setReplyingTo(null); onReplyTargetChange === null || onReplyTargetChange === void 0 ? void 0 : onReplyTargetChange(null); }, children: t('MessagingThread.REPLYING_TO', { sender: ((_a = replyingTo.sender) === null || _a === void 0 ? void 0 : _a.display_name) || t('MessagingThread.UNKNOWN_SENDER') }) }), _jsxs(Box, { ref: scrollRef, onScroll: onScroll, sx: { maxHeight: 560, overflowY: 'auto', px: 0.5 }, "aria-label": t('MessagingThread.TIMELINE'), children: [cache.cursors.messages[conversationId] && _jsx(Box, { textAlign: "center", py: 1, children: _jsx(Button, { onClick: loadOlder, disabled: loadingOlder, children: loadingOlder ? _jsx(CircularProgress, { size: 18 }) : t('MessagingThread.LOAD_OLDER') }) }), !roots.length && _jsx(Typography, { color: "text.secondary", textAlign: "center", py: 4, children: t('MessagingThread.EMPTY') }), _jsx(Stack, { spacing: 1, children: roots.map((message) => {
107
118
  const replies = chronological(Object.values(cache.messages).filter((item) => String(replyToId(item)) === String(message.id)));
108
- return _jsxs(Stack, { spacing: 0.75, children: [_jsx(MessageBubble, { message: message, conversation: conversation, onReply: reply, onJumpToMessage: jumpToMessage, onAnnouncementLink: onAnnouncementLink, canModerateMessages: canModerateMessages, children: canShowReadTicks(message, user) && _jsx(ReadTicks, { messageId: message.id, conversation: conversation }) }), (message.reply_count || replies.length) > 0 && _jsx(Button, { size: "small", onClick: () => toggleReplies(message), children: openThreads[message.id] ? t('MessagingThread.HIDE_REPLIES') : t('MessagingThread.SHOW_REPLIES', { count: message.reply_count || replies.length }) }), openThreads[message.id] && _jsx(Stack, { spacing: 0.75, sx: { pl: 3, borderLeft: 2, borderColor: 'divider' }, children: replies.map((item) => _jsx(MessageBubble, { message: item, replyTo: message, conversation: conversation, onReply: reply, onJumpToMessage: jumpToMessage, onAnnouncementLink: onAnnouncementLink, canModerateMessages: canModerateMessages, children: canShowReadTicks(item, user) && _jsx(ReadTicks, { messageId: item.id, conversation: conversation }) }, item.id)) }), _jsx(Divider, {})] }, message.id);
119
+ return _jsxs(Stack, { spacing: 0.75, children: [_jsx(MessageBubble, { message: message, conversation: conversation, onReply: reply, onJumpToMessage: jumpToMessage, onAnnouncementLink: onAnnouncementLink, canModerateMessages: canModerateMessages, children: canShowReadTicks(message, user) && _jsx(ReadTicks, { messageId: message.id, conversation: conversation }) }), (message.reply_count || replies.length) > 0 && (() => {
120
+ const unread = hasUnreadReplies(message);
121
+ const label = openThreads[message.id] ? t('MessagingThread.HIDE_REPLIES') : t('MessagingThread.SHOW_REPLIES', { count: message.reply_count || replies.length });
122
+ // MUI's Badge spreads unrecognized props (incl. aria-label) onto
123
+ // its own non-interactive wrapping span, not the inner control —
124
+ // a screen reader focuses the Button, so the accessible name
125
+ // must live there, not on the purely-visual dot.
126
+ return _jsx(Badge, { color: "error", variant: "dot", invisible: !unread, children: _jsx(Button, { size: "small", "aria-label": unread ? `${label} — ${t('MessagingThread.UNREAD_REPLIES')}` : undefined, onClick: () => toggleReplies(message), children: label }) });
127
+ })(), openThreads[message.id] && _jsx(Stack, { spacing: 0.75, sx: { pl: 3, borderLeft: 2, borderColor: 'divider' }, children: replies.map((item) => _jsx(MessageBubble, { message: item, replyTo: message, conversation: conversation, onReply: reply, onJumpToMessage: jumpToMessage, onAnnouncementLink: onAnnouncementLink, canModerateMessages: canModerateMessages, children: canShowReadTicks(item, user) && _jsx(ReadTicks, { messageId: item.id, conversation: conversation }) }, item.id)) }), _jsx(Divider, {})] }, message.id);
109
128
  }) })] })] }));
110
129
  }
@@ -1,11 +1,22 @@
1
- # Messaging parity outcomes (MSG-3b)
2
-
3
- This is the authoritative MSG-3b parity record. It replaces the former
4
- file-level “final parity confirmation,” which was not sufficient evidence of
5
- feature parity. Each entry maps directly to the numbered checklist in Part A
6
- of `work-orders/MSG-3b.md`. `OK` means the capability is present; `DEV` means
7
- an intentional, documented redesign or non-parity; `BLOCKED` means dcm 2.36.1
8
- does not expose the data or realtime contract needed to implement it truthfully.
1
+ # Messaging parity outcomes (MSG-3b, extended by MSG-3c and MSG-3d)
2
+
3
+ This is the authoritative parity record for the ucm messaging surfaces. It
4
+ replaces the former file-level “final parity confirmation,” which was not
5
+ sufficient evidence of feature parity. Each entry maps directly to the
6
+ numbered checklist in Part A of `work-orders/MSG-3b.md`. `OK` means the
7
+ capability is present; `DEV` means an intentional, documented redesign or
8
+ non-parity; `BLOCKED` means the dependency (dcm) does not expose the data or
9
+ realtime contract needed to implement it truthfully.
10
+
11
+ **Binding rule, added by MSG-3c:** every `BLOCKED` entry below must name the
12
+ dcm version it was verified blocked against. MSG-3b was itself blocked
13
+ against dcm 2.36.1 and recorded seven rows as `BLOCKED` on "a future dcm work
14
+ order" — dcm 2.37.0 shipped that work mid-flight, and MSG-3b's own WO-end
15
+ review closed without noticing, because it cross-checked the checklist
16
+ against ucm's landed code and took the dependency's state from the WO text
17
+ instead of the live dependency. A version pin turns that re-check into a
18
+ two-minute job instead of an archaeology exercise; `tests/messagingContractConformance.test.js`
19
+ now fails the build if a `BLOCKED` line omits one.
9
20
 
10
21
  ## Unread lifecycle
11
22
 
@@ -44,7 +55,7 @@ does not expose the data or realtime contract needed to implement it truthfully.
44
55
  24. **OK** — Replies show a sender and short quoted snippet.
45
56
  25. **OK** — Clicking a quote scrolls its source message into view.
46
57
  26. **OK** — Quotes of deleted messages render the deleted placeholder rather than stale content.
47
- 27. **BLOCKED** — dcm writes a thread receipt but exposes no prior thread receipt state, unread flag, or unread count to read. A truthful cross-device unread-reply dot cannot be rendered without a dcm contract addition.
58
+ 27. **OK** (delivered in MSG-3d, was BLOCKED as of dcm 2.37.0) — dcm 2.38.0's `serialize_message` gains viewer-independent `reply_count`/`last_reply_at`, and `thread_last_read_at` is attached REST-only from `MessageThreadReceipt`. `Thread` shows an unread-reply marker on the thread toggle when `last_reply_at > thread_last_read_at`, treats a `null` receipt with replies as unread, and shows no marker (and no toggle) when there are no replies. Opening a thread updates the receipt from the REST response, not a frame (the field is never present on one, by design). An incoming reply's `message` frame also live-advances the cached root's `reply_count`/`last_reply_at`, merged field-wise so a previously-fetched `thread_last_read_at` is never wiped — the same merge-trap discipline MSG-3c's three fixes established, now with its own mandatory regression test.
48
59
  28. **OK** — Reply threads load lazily on expand and collapse locally.
49
60
  29. **DEV** — Reply composition is supplied through the independently mountable `Composer` target prop rather than jg’s per-thread embedded composer.
50
61
 
@@ -61,11 +72,11 @@ does not expose the data or realtime contract needed to implement it truthfully.
61
72
 
62
73
  ## Conversation list
63
74
 
64
- 38. **BLOCKED** — dcm does not serialize `last_message`, so a last-message preview cannot be made correct against the real backend.
75
+ 38. **OK** (delivered in MSG-3c, was BLOCKED as of dcm 2.36.1) — dcm 2.37.0 serializes `last_message` (`excerpt`/`created_at`) on the conversation payload and emits it live via `conversation_upsert` on new-message send. `ConversationList` was found reading the wrong field name (`last_message.body` instead of the real `excerpt`) — fixed, so the preview now actually renders against the real contract instead of always falling back to the empty state. Reorder and live preview refresh work for new messages; dcm does not publish `conversation_upsert` on message edit/delete, so those cases update on the next REST refresh rather than live (see row 58).
65
76
  39. **OK** — `last_message_at` renders through browser-native, locale-aware `Intl.RelativeTimeFormat` labels.
66
77
  40. **OK** — The existing MUI `selected={conversation.id === activeConversationId}` wiring produces the `Mui-selected` active-row highlight; it was verified and needs no redundant styling.
67
78
  41. **OK** — Unread badges and bold unread titles are present.
68
- 42. **BLOCKED** — dcm stores the managed all/team distinction in `external_key` but omits it from conversation serialization. The UI must not guess from titles or other unrelated fields.
79
+ 42. **OK** (delivered in MSG-3d, was BLOCKED as of dcm 2.37.0) — dcm 2.38.0's `serialize_conversation_core` includes `external_key`. ucm does not interpret it — jg's `event_all`/`event_team` vocabulary (or any other app's) stays entirely host-side. `ConversationList` accepts an optional `resolveManagedLabel(conversation)` host resolver, called only for `kind: 'managed'` conversations, falling back to the existing title behavior when unset; no app-specific string appears anywhere in `src/` (asserted directly by test).
69
80
  43. **DEV** — Generic MUI kind icons replace jg’s richer per-kind/person avatar treatment while retaining type identification.
70
81
  44. **OK** — Mute and archive actions are present.
71
82
 
@@ -83,19 +94,25 @@ does not expose the data or realtime contract needed to implement it truthfully.
83
94
 
84
95
  ## Polls
85
96
 
86
- 51. **BLOCKED** — dcm has no poll read serialization for question, option, or count data; client-side fabrication would hide the backend contract gap.
87
- 52. **BLOCKED** — Per-option voter names are likewise absent from dcm’s readable poll contract.
88
- 53. **BLOCKED** — Closed state and disabled-voting rendering depend on that absent poll read contract.
97
+ 51. **OK** (delivered in MSG-3c, was BLOCKED as of dcm 2.36.1) — dcm 2.37.0's `serialize_poll` renders question, options and per-option `vote_count` through `PollCard`. Fixed against the real shape: the initial-selection effect previously read non-existent per-option `selected`/`voted` flags — it now derives from `voted_option_ids`, which dcm adds only to the create/vote/close REST responses (never to `serialize_poll` itself or the `poll_updated` frame), and correctly shows no known selection when that field is absent rather than guessing.
98
+ 52. **DEV** (delivered in MSG-3c, was BLOCKED as of dcm 2.36.1) — dcm 2.37.0's poll options carry `voters` as user ids only, never names, and there is no user-directory endpoint (the same structural limit already established for the DM candidate list, row 19). `PollCard` renders the vote count and marks the viewer's own vote (derived from `voted_option_ids`) rather than a per-voter name list, which the dependency cannot supply.
99
+ 53. **OK** (delivered in MSG-3c, was BLOCKED as of dcm 2.36.1) — `closed_at` is in the real poll projection; voting is disabled once set. The poll creator's close permission was found broken against the real contract (`created_by_id` is the real field; the prior code read a non-existent `created_by.id`) — fixed.
89
100
  54. **OK** — Composer starts at two options and disables adding options once it reaches ten; the server’s two-option minimum remains enforced.
90
- 55. **OK** — Vote and close write paths are wired; their full read rendering remains subject to rows 51–53.
101
+ 55. **OK** — Vote and close write paths are wired; full read rendering is now delivered per rows 51-53.
91
102
 
92
103
  ## Live synchronization
93
104
 
94
- 56. **BLOCKED** — Reaction frame handling remains intentionally ready, but dcm 2.36.1 does not emit `reaction` frames.
95
- 57. **BLOCKED** — Poll-update frame handling remains intentionally ready, but dcm 2.36.1 does not emit `poll_updated` frames.
96
- 58. **BLOCKED** — Conversation reorder and preview refresh require dcm’s missing `last_message` projection.
105
+ 56. **OK** (delivered in MSG-3c, was BLOCKED as of dcm 2.36.1) — dcm 2.37.0 emits `reaction` frames. The handler had never run against a real frame; verification found it wholesale-replacing the message's reaction list with the server's aggregate-only projection (`{emoji, count}`, never a per-viewer `reacted` flag, by design) on every arrival — silently wiping every previously-known `reacted` flag on that message, not just whichever emoji changed. The same defect existed in the REST-confirm path for the viewer's own toggle. Both fixed to merge by emoji, preserving previously-known `reacted` state instead of discarding it.
106
+ 57. **OK** (delivered in MSG-3c, was BLOCKED as of dcm 2.36.1) — dcm 2.37.0 emits `poll_updated` frames. Same defect class as row 56, found while implementing row 51-53: the frame handler wholesale-replaced a message's `poll` object, silently dropping a REST-known `voted_option_ids` the moment any `poll_updated` frame arrived (e.g. a vote-count bump from another viewer). Fixed to carry the previous value forward when the incoming frame is silent about it, per the WO's explicit "must not clear or invert the viewer's own vote state" requirement.
107
+ 58. **OK** (delivered in MSG-3c, was BLOCKED as of dcm 2.36.1) — new messages live-reorder the conversation list and refresh its preview via `conversation_upsert` (row 38). dcm does not publish `conversation_upsert` on message edit or delete (only on new-message send — verified against every `_publish(..., "conversation_upsert", ...)` call site in `services.py`), so an edit/delete of a conversation's last message updates the preview on the next REST refresh rather than live; this is a dcm-side gap outside this WO's scope, not a ucm limitation.
97
108
  59. **OK** — dcm’s emitted message, message-edited, and message-deleted frames are handled live.
98
109
 
99
110
  ## Required backend follow-up
100
111
 
101
- Rows 27, 38, 42, 51–53, and 56–58 require a future dcm work order: expose readable thread receipt state, serialize `external_key` where managed all/team labeling is required, define and serialize the poll read contract, add `last_message`, and emit the designed realtime frames. Until then, these are explicit BLOCKED outcomes rather than simulated client behavior.
112
+ None outstanding. This checklist has **zero `BLOCKED` entries** as of MSG-3d. MSG-2c (dcm 2.37.0)
113
+ delivered what MSG-3b's own audit had recorded as blocking rows 38, 51–53 and 56–58 — MSG-3c
114
+ re-verified each against the live dependency and delivered them. MSG-2d (dcm 2.38.0) delivered what
115
+ remained blocking rows 27 and 42 — MSG-3d re-verified and delivered them the same way. Every row of
116
+ the original MSG-3b checklist is now either implemented or a recorded deliberate deviation; see the
117
+ rows above for what was actually built, including the client-side bugs found and fixed only once real
118
+ server data was checked against, not assumed from the WO text or the doc's own prior claims.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "2.17.0",
3
+ "version": "2.19.0",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "repository": {
@@ -31,6 +31,7 @@ export const messagingTranslations = {
31
31
  'MessagingThread.REPLYING_TO': { de: 'Antwort an {{sender}}', en: 'Replying to {{sender}}', fr: 'Réponse à {{sender}}' },
32
32
  'MessagingThread.SHOW_REPLIES': { de: '{{count}} Antworten anzeigen', en: 'Show {{count}} replies', fr: 'Afficher {{count}} réponses' },
33
33
  'MessagingThread.HIDE_REPLIES': { de: 'Antworten ausblenden', en: 'Hide replies', fr: 'Masquer les réponses' },
34
+ 'MessagingThread.UNREAD_REPLIES': { de: 'Ungelesene Antworten', en: 'Unread replies', fr: 'Réponses non lues' },
34
35
  'MessagingThread.UNKNOWN_SENDER': { de: 'Unbekannte Person', en: 'Unknown person', fr: 'Personne inconnue' },
35
36
  'MessagingThread.QUOTE_ATTACHMENT': { de: 'Anhang', en: 'Attachment', fr: 'Pièce jointe' },
36
37
  'MessagingActions.MENU': { de: 'Nachrichtenaktionen', en: 'Message actions', fr: 'Actions du message' },
@@ -85,6 +86,7 @@ export const messagingTranslations = {
85
86
  'MessagingPoll.ADD_OPTION': { de: 'Option hinzufügen', en: 'Add option', fr: 'Ajouter une option' },
86
87
  'MessagingPoll.ALLOW_MULTIPLE': { de: 'Mehrfachauswahl erlauben', en: 'Allow multiple choices', fr: 'Autoriser plusieurs choix' },
87
88
  'MessagingPoll.CANCEL': { de: 'Abbrechen', en: 'Cancel', fr: 'Annuler' },
89
+ 'MessagingPoll.YOU_VOTED': { de: 'von dir gewählt', en: 'voted by you', fr: 'voté par vous' },
88
90
  'MessagingConfig.LABEL': { de: 'Nachrichten-Einstellungen', en: 'Messaging settings', fr: 'Paramètres de messagerie' },
89
91
  'MessagingConfig.TITLE': { de: 'Nachrichten-Einstellungen', en: 'Messaging settings', fr: 'Paramètres de messagerie' },
90
92
  'MessagingConfig.LOADING': { de: 'Einstellungen werden geladen…', en: 'Loading settings…', fr: 'Chargement des paramètres…' },
@@ -6,7 +6,17 @@ import { useTranslation } from 'react-i18next';
6
6
  import { useMessaging } from './MessagingProvider';
7
7
  import { ConversationLaunchers } from './ConversationLaunchers';
8
8
 
9
- function titleOf(conversation, t) { return conversation.title || conversation.other_user?.display_name || conversation.other_user?.username || t('MessagingList.UNTITLED'); }
9
+ // external_key (dcm 2.38.0+) is the consuming app's own identity for a
10
+ // managed conversation — ucm must not know what any particular app's values
11
+ // mean, so a label for it is host-supplied only; resolveManagedLabel receives
12
+ // the raw conversation (including external_key) and owns all interpretation.
13
+ function titleOf(conversation, t, resolveManagedLabel) {
14
+ if (conversation.kind === 'managed' && resolveManagedLabel) {
15
+ const label = resolveManagedLabel(conversation);
16
+ if (label) return label;
17
+ }
18
+ return conversation.title || conversation.other_user?.display_name || conversation.other_user?.username || t('MessagingList.UNTITLED');
19
+ }
10
20
  function ordered(conversations) {
11
21
  return [...conversations].sort((left, right) => new Date(right.last_message_at || 0) - new Date(left.last_message_at || 0));
12
22
  }
@@ -27,7 +37,7 @@ function KindIcon({ conversation }) {
27
37
  }
28
38
 
29
39
  /** A standalone, provider-backed list; scope picker metadata is supplied as launcher props. */
30
- export function ConversationList({ onOpen, groupLaunchers, broadcastLauncher, autoOpenBroadcast = false, includeArchived = false }) {
40
+ export function ConversationList({ onOpen, groupLaunchers, broadcastLauncher, autoOpenBroadcast = false, includeArchived = false, resolveManagedLabel }) {
31
41
  const { t, i18n } = useTranslation();
32
42
  const { cache, activeConversationId, loadMoreConversations, setConversationArchived, setConversationPreferences, markConversationRead } = useMessaging();
33
43
  const [menuConversation, setMenuConversation] = useState(null);
@@ -68,7 +78,7 @@ export function ConversationList({ onOpen, groupLaunchers, broadcastLauncher, au
68
78
  <Badge color="error" badgeContent={unread || null} max={99} sx={{ mr: 2 }}>
69
79
  {archived ? <Archive fontSize="small" color="disabled" /> : <KindIcon conversation={conversation} />}
70
80
  </Badge>
71
- <ListItemText primary={<Stack direction="row" spacing={1} justifyContent="space-between"><Typography component="span" fontWeight={unread ? 700 : 400} noWrap>{titleOf(conversation, t)}</Typography>{relativeTime(conversation.last_message_at, i18n?.language) && <Typography component="span" variant="caption" color="text.secondary" noWrap>{relativeTime(conversation.last_message_at, i18n?.language)}</Typography>}</Stack>} secondary={conversation.last_message?.body || t('MessagingList.NO_MESSAGES')} secondaryTypographyProps={{ noWrap: true }} />
81
+ <ListItemText primary={<Stack direction="row" spacing={1} justifyContent="space-between"><Typography component="span" fontWeight={unread ? 700 : 400} noWrap>{titleOf(conversation, t, resolveManagedLabel)}</Typography>{relativeTime(conversation.last_message_at, i18n?.language) && <Typography component="span" variant="caption" color="text.secondary" noWrap>{relativeTime(conversation.last_message_at, i18n?.language)}</Typography>}</Stack>} secondary={conversation.last_message?.excerpt || t('MessagingList.NO_MESSAGES')} secondaryTypographyProps={{ noWrap: true }} />
72
82
  <IconButton aria-label={t('MessagingList.ACTIONS')} onClick={(event) => { event.stopPropagation(); setMenuAnchor(event.currentTarget); setMenuConversation(conversation); }}><MoreVert /></IconButton>
73
83
  </ListItemButton>;
74
84
  })}