@micha.bigler/ui-core-micha 2.15.0 → 2.17.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.
Files changed (64) hide show
  1. package/README.md +7 -0
  2. package/WORK_ORDERS.md +6 -1
  3. package/dev/entries.jsx +151 -0
  4. package/dev/fixtures.js +26 -0
  5. package/dev/main.jsx +56 -0
  6. package/dev/mockTransport.jsx +128 -0
  7. package/dist/i18n/messagingTranslations.js +107 -0
  8. package/dist/index.js +15 -0
  9. package/dist/messaging/AttachmentList.js +43 -0
  10. package/dist/messaging/Composer.js +156 -0
  11. package/dist/messaging/ConversationLaunchers.js +39 -0
  12. package/dist/messaging/ConversationList.js +82 -0
  13. package/dist/messaging/DirectMessageLauncher.js +48 -0
  14. package/dist/messaging/MessageBubble.js +97 -0
  15. package/dist/messaging/MessagingProvider.js +363 -0
  16. package/dist/messaging/MessagingScopeConfig.js +28 -0
  17. package/dist/messaging/PollCard.js +53 -0
  18. package/dist/messaging/ReactionBar.js +44 -0
  19. package/dist/messaging/ReadTicks.js +28 -0
  20. package/dist/messaging/Thread.js +110 -0
  21. package/dist/messaging/api.js +51 -0
  22. package/dist/messaging/composerImageCompression.js +36 -0
  23. package/dist/notifications/NotificationsProvider.js +2 -2
  24. package/dist/notifications/realtime.js +21 -1
  25. package/docs/messaging-deviations.md +101 -0
  26. package/index.html +12 -0
  27. package/package.json +4 -1
  28. package/src/i18n/messagingTranslations.ts +107 -0
  29. package/src/index.js +16 -0
  30. package/src/messaging/AttachmentList.jsx +38 -0
  31. package/src/messaging/Composer.jsx +156 -0
  32. package/src/messaging/ConversationLaunchers.jsx +51 -0
  33. package/src/messaging/ConversationList.jsx +84 -0
  34. package/src/messaging/DirectMessageLauncher.jsx +96 -0
  35. package/src/messaging/MessageBubble.jsx +104 -0
  36. package/src/messaging/MessagingProvider.jsx +395 -0
  37. package/src/messaging/MessagingScopeConfig.jsx +24 -0
  38. package/src/messaging/PollCard.jsx +36 -0
  39. package/src/messaging/ReactionBar.jsx +46 -0
  40. package/src/messaging/ReadTicks.jsx +25 -0
  41. package/src/messaging/Thread.jsx +102 -0
  42. package/src/messaging/api.js +48 -0
  43. package/src/messaging/composerImageCompression.js +35 -0
  44. package/src/notifications/NotificationsProvider.jsx +2 -2
  45. package/src/notifications/realtime.jsx +21 -1
  46. package/tests/ConversationList.test.jsx +66 -0
  47. package/tests/MessagingProvider.test.jsx +50 -0
  48. package/tests/Thread.test.jsx +100 -0
  49. package/tests/messagingApi.test.js +32 -0
  50. package/tests/messagingChunk6.test.jsx +109 -0
  51. package/tests/messagingComposer.test.jsx +111 -0
  52. package/tests/messagingComposerChunk5.test.jsx +110 -0
  53. package/tests/messagingContractConformance.test.js +42 -0
  54. package/tests/messagingDirectMessageLauncher.test.jsx +78 -0
  55. package/tests/messagingExports.test.js +21 -0
  56. package/tests/messagingInteractions.test.jsx +112 -0
  57. package/tests/messagingMessageActions.test.jsx +93 -0
  58. package/tests/messagingThreadingQuoting.test.jsx +86 -0
  59. package/tests/messagingUnreadLifecycle.test.jsx +63 -0
  60. package/tests/realtime.test.jsx +27 -0
  61. package/vite.config.mjs +6 -0
  62. package/work-orders/DX-1.md +166 -0
  63. package/work-orders/MSG-3.md +375 -0
  64. package/work-orders/MSG-3b.md +1002 -0
package/README.md ADDED
@@ -0,0 +1,7 @@
1
+ # ui-core-micha
2
+
3
+ ## Development harness
4
+
5
+ Run `pnpm dev` to open the local Vite harness. It mounts entries with the package's MUI theme, `react-i18next`, a memory router, and fixture-backed REST/realtime transport. Use the controls to change theme and viewport.
6
+
7
+ To add an entry, create a small component in `dev/entries.jsx` and append it to `entries`. It may be a complete surface or one standalone component. Keep fixtures and transport behaviour in `dev/`, never in `src/`: the harness is not part of the published package.
package/WORK_ORDERS.md CHANGED
@@ -12,6 +12,8 @@ rules are defined centrally in `webapps/AGENTS.md` → "Work-Order Register".
12
12
  | `PERF-*` | Performance improvements |
13
13
  | `NOTIF-*` | Shared notifications platform (dcm register is canonical; this repo's rows are its ucm-side WOs) |
14
14
  | `CHART-*` | Shared chart / data-viz kit (`ChartFrame` + MUI X-Charts presets + neutral palette/formatters) |
15
+ | `MSG-*` | Shared messaging platform, Phase B (dcm register is canonical; this repo's rows are its ucm-side WOs) |
16
+ | `DX-*` | Developer experience / repo tooling (ucm-local prefix; unrelated to `django-core-micha`'s own `DX-*`) |
15
17
 
16
18
  Introduce a new prefix when none fits and add it here. New WOs always get a
17
19
  prefixed ID; never reuse a bare flat number across workstreams.
@@ -24,5 +26,8 @@ prefixed ID; never reuse a bare flat number across workstreams.
24
26
  | PERF-3B1 | Parallel auth bootstrap after CSRF | Starts auth-methods and current-user concurrently once CSRF is available, while preserving error handling and loading semantics. | 2026-07-19 | done | ee89329 | Independent `reviewer` + `sec_reviewer` passes both clean (no findings); one P3 test-coverage gap from the reviewer (missing mirror case: auth-methods rejects, current-user succeeds) closed with an added regression test — 45/45 tests green. Published as 2.10.1 (patch, no interface change) and pinned in jg-ferien alongside its PERF-3A companion WO. |
25
27
  | CHART-1 | Shared chart kit: themed `ChartFrame` + MUI X-Charts Bar/Line presets | Typed-agnostic `ChartFrame` (Paper + title + toolbar slot + loading/error/empty + responsive container + SVG/PNG export + a11y) wrapping any chart body via `children`; thin `BarChart`/`LineChart` presets over MUI X-Charts baking the governance chart contract (both-axis labels, legend-when-multiseries, tooltip, theme-token colours, responsive); neutral theme-aware palette + locale-aware formatters. Additive exports; publishes ucm 2.15.0. | 2026-07-30 | done | f1b3614 | Envelope authored by Expertenchat; `work-orders/CHART-1.md`. Operator approved the `@mui/x-charts` peerDependency 2026-07-30 (in-chat) before Codex ran. Codex-built direct (no nesting). Independent `reviewer` (background, author=Codex ≠ reviewer): found R1 (P1, real bug — 3 of 4 WO-required tests failed on actual execution: no RTL `cleanup()` between `render()` calls left stale DOM across the `ChartFrame` state-precedence/empty tests, and the `BarChart` responsive-sizing assertion called `.not.toContain(null)` on a `getAttribute('style')` that MUI's `sx` never sets inline — an invalid matcher call), R2 (P2 — `BarChart`/`LineChart` passed an invalid `tooltip={{trigger:'axis'}}` prop that isn't part of either component's real API per installed `@mui/x-charts@8.29.2` types; silently worked only via the library's own default, and the mocked tests couldn't catch it), R3 (P2 — `ChartFrame`'s chart region set both `aria-label` and `aria-labelledby`; per ARIA accessible-name computation `aria-labelledby` wins, so a caller-supplied `ariaLabel` override was silently discarded by assistive tech), R4 (P3 — the PNG export control was never actually clicked in the export test, only checked for presence). All four fixed by the orchestrator directly (in-scope reviewer-finding fixes, not a re-implementation): added `afterEach(cleanup)` to the three render-based test files, fixed the responsive-sizing assertion to tolerate `null`/empty inline style, moved tooltip config to the real `slotProps={{tooltip:{trigger:'axis'}}}` API (tests updated to match), suppressed `aria-labelledby` whenever a custom `ariaLabel` is supplied (with a new regression test), and added a PNG-export regression test asserting the callback fires. Re-verified: 16/16 scoped vitest tests green, `pnpm`/tsc build clean. hram adoption (pin bump + ~20 Results/Research panel migration) is a gated follow-on WO in the hram repo, gated on 2.15.0 published (publish-from-main CI on this push). SVG specialists (scatter/tornado/heatmap/CI/frontier) get frame+export in adoption but keep raw-SVG bodies — retiring raw SVG is a tracked future CHART-2. |
26
28
  | NOTIF-6 | NotificationsProvider + bell on canonical dcm feed/* API | Single-WS-owner `NotificationsProvider`/`useNotifications` (initial REST seed via `feedApi.js` + one `/ws/notifications/` socket, reconnect w/ backoff) + generic MUI `NotificationBell`; additive exports only | 2026-07-27 | done | 6c63fb9 | Codex-built direct (no nesting), independent `reviewer`: single-WS-owner invariant traced clean (no way to break it — `authenticated`/`replaceNotifications`/`refresh` all referentially stable across unrelated re-renders, re-render test is genuine not a no-op). R1 (P2, real bug): the `notification.status` WS handler patched item flags but never adjusted `unreadCount`, so the badge went stale after a cross-tab/cross-device mark — the test had locked in the stale count as expected behavior. Fixed: delta computed from before/after unread state on the WS patch path, mirroring `mark()`'s own logic; test corrected + a new cross-tab regression test added. 57 passing, build clean. No new npm dependency. Full history + backend contract: dcm's `WORK_ORDERS.md`/`docs/design/notifications-platform.md`. Publishes ucm 2.11.0 |
27
- | NOTIF-13 | Layer-1 transport extraction (pluggable realtime) | Extract the socket out of `NotificationsProvider` into a generic subscribe-by-message-type realtime primitive so a second domain (messaging) can ride the same socket; the catch-all that turns every non-`notification.status` message into a feed entry is replaced by explicit dispatch with unknown types ignored. Additive: `NotificationsProvider` stays the sole mount point, its context value unchanged. Full spec: [work-orders/NOTIF-13.md](work-orders/NOTIF-13.md). | 2026-07-29 | done | ucm: 7a83ee9; dcm: de77335 | Tier 2 (cockpit consumes `NotificationsProvider` at 2.12.0 in prod). Phase A, the hinge: unblocks NOTIF-15 (jg bell adoption) and is the substrate for Phase B messaging. Canonical register row: django-core-micha `WORK_ORDERS.md`. Operator decision 2026-07-29: additive only, NO standalone `RealtimeProvider` apps must mount. Scope E (envelope contract + helper + doc) also touches dcm; note that the roadmap wording "dcm multi-stream consumer" overstates it — `push_to_users`/`NotificationConsumer` are already stream-agnostic. Codex failed twice on this WO with a genuine CLI/model-version error (`gpt-5.6-terra requires a newer version of Codex`), not a transient issue — orchestrator fell back to direct Claude implementation per the Codex→Claude fallback rule. Independent `reviewer` (sec_reviewer not required per WO): found R1 (P2, real bug — decoupling the REST seed from the socket connect opened a race where a live push arriving before the initial feed loaded would be silently discarded by `refresh()`'s unconditional `replaceNotifications()`; fixed via a `seeded` state gate restoring the exact original seed-then-connect ordering, re-verified 70/70 green), R2 (P3 — the WO's "must be called out in the CHANGELOG" has no home since ucm has no CHANGELOG.md convention at all; operator decision: document via commit message + this register entry only, no new file), R3 (register housekeeping, closed by this update). ucm full suite 70/70 + `pnpm build` clean; dcm notifications suite 92/92, full dcm suite 392/392. Published: ucm 2.13.0 (npm), dcm 2.33.0 (PyPI, verified live before this record) — no app pin bumps in this WO per spec. |
29
+ | NOTIF-13 | Layer-1 transport extraction (pluggable realtime) | Extract the socket out of `NotificationsProvider` into a generic **subscribe-by-envelope** realtime primitive (shipped API: `useRealtime() -> subscribe(envelope, handler)`; the WO said "by message-type", but routing is on the `envelope` discriminator, deliberately NOT on the overloaded `type` field — that overload was the bug) so a second domain (messaging) can ride the same socket; the catch-all that turns every non-`notification.status` message into a feed entry is replaced by explicit dispatch with unknown types ignored. Additive: `NotificationsProvider` stays the sole mount point, its context value unchanged. Full spec: [work-orders/NOTIF-13.md](work-orders/NOTIF-13.md). | 2026-07-29 | done | ucm: 7a83ee9; dcm: de77335 | Tier 2 (cockpit consumes `NotificationsProvider` at 2.12.0 in prod). Phase A, the hinge: unblocks NOTIF-15 (jg bell adoption) and is the substrate for Phase B messaging. Canonical register row: django-core-micha `WORK_ORDERS.md`. Operator decision 2026-07-29: additive only, NO standalone `RealtimeProvider` apps must mount. Scope E (envelope contract + helper + doc) also touches dcm; note that the roadmap wording "dcm multi-stream consumer" overstates it — `push_to_users`/`NotificationConsumer` are already stream-agnostic. Codex failed twice on this WO with a genuine CLI/model-version error (`gpt-5.6-terra requires a newer version of Codex`), not a transient issue — orchestrator fell back to direct Claude implementation per the Codex→Claude fallback rule. Independent `reviewer` (sec_reviewer not required per WO): found R1 (P2, real bug — decoupling the REST seed from the socket connect opened a race where a live push arriving before the initial feed loaded would be silently discarded by `refresh()`'s unconditional `replaceNotifications()`; fixed via a `seeded` state gate restoring the exact original seed-then-connect ordering, re-verified 70/70 green), R2 (P3 — the WO's "must be called out in the CHANGELOG" has no home since ucm has no CHANGELOG.md convention at all; operator decision: document via commit message + this register entry only, no new file), R3 (register housekeeping, closed by this update). ucm full suite 70/70 + `pnpm build` clean; dcm notifications suite 92/92, full dcm suite 392/392. Published: ucm 2.13.0 (npm), dcm 2.33.0 (PyPI, verified live before this record) — no app pin bumps in this WO per spec. |
28
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
+ | 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
+ | 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. |
@@ -0,0 +1,151 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { Box, Button, Stack, Typography } from '@mui/material';
3
+
4
+ import { AuthContext } from '../src/auth/AuthContext';
5
+ import { BarChart } from '../src/components/charts/BarChart';
6
+ import { ChartFrame } from '../src/components/charts/ChartFrame';
7
+ import { NotificationBell } from '../src/notifications/NotificationBell';
8
+ import { NotificationsProvider } from '../src/notifications/NotificationsProvider';
9
+ import { useRealtime } from '../src/notifications/realtime';
10
+ import { MessagingProvider, useMessaging } from '../src/messaging/MessagingProvider';
11
+ import { ConversationList } from '../src/messaging/ConversationList';
12
+ import { ConversationLaunchers } from '../src/messaging/ConversationLaunchers';
13
+ import { DirectMessageLauncher } from '../src/messaging/DirectMessageLauncher';
14
+ import { Thread } from '../src/messaging/Thread';
15
+ import { MessageBubble } from '../src/messaging/MessageBubble';
16
+ import { ReadTicks } from '../src/messaging/ReadTicks';
17
+ import { Composer } from '../src/messaging/Composer';
18
+ import { AttachmentList } from '../src/messaging/AttachmentList';
19
+ import { ReactionBar } from '../src/messaging/ReactionBar';
20
+ import { PollCard } from '../src/messaging/PollCard';
21
+ import { MessagingScopeConfig } from '../src/messaging/MessagingScopeConfig';
22
+ import { barChartFixture } from './fixtures';
23
+ import { useMockTransport } from './mockTransport';
24
+
25
+ function NotificationBellEntry() {
26
+ return (
27
+ <AuthContext.Provider value={{ user: { id: 1, username: 'harness-user' } }}>
28
+ <NotificationsProvider wsUrlBase="ws://127.0.0.1:9">
29
+ <NotificationBell resolveLink={(link) => window.alert(`Navigate to ${link}`)} />
30
+ </NotificationsProvider>
31
+ </AuthContext.Provider>
32
+ );
33
+ }
34
+
35
+ function BarChartEntry() {
36
+ return (
37
+ <ChartFrame title="Monthly cases" subtitle="Standalone BarChart entry" minHeight={360}>
38
+ <BarChart {...barChartFixture} xAxisLabel="Month" yAxisLabel="Cases" height={320} />
39
+ </ChartFrame>
40
+ );
41
+ }
42
+
43
+ function RealtimeAdapterEntry() {
44
+ const { subscribe } = useRealtime();
45
+ const transport = useMockTransport();
46
+ const [frames, setFrames] = useState([]);
47
+ useEffect(() => subscribe('notification', (frame) => setFrames((current) => [frame, ...current])), [subscribe]);
48
+ return (
49
+ <Stack spacing={2}>
50
+ <Typography>The mock provider accepts manually dispatched realtime frames without a socket.</Typography>
51
+ <Button variant="contained" onClick={() => transport.dispatch({ envelope: 'notification', type: 'notification.created', notification_id: Date.now() })}>
52
+ Dispatch notification frame
53
+ </Button>
54
+ <Box component="pre" sx={{ m: 0, p: 2, bgcolor: 'action.hover', overflow: 'auto' }}>
55
+ {JSON.stringify(frames, null, 2)}
56
+ </Box>
57
+ </Stack>
58
+ );
59
+ }
60
+
61
+ const messagingHarnessApi = {
62
+ listConversations: async ({ cursor } = {}) => cursor
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' }),
65
+ // GET conversations/{id}/messages/ returns roots only (design §REST); replies
66
+ // come from GET messages/{root_id}/thread/ (listThread below), never mixed in here.
67
+ listMessages: async (conversationId, { cursor } = {}) => cursor
68
+ ? ({ 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' }),
70
+ 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
+ getReadStatus: async () => ({ all_read: false, delivered_count: 2 }),
72
+ getUnreadCount: async () => ({ unread_count: 1, by_conversation: { 12: 1 } }),
73
+ archiveConversation: async (id, archived) => ({ id, archived }),
74
+ patchConversationPreferences: async (id, patch) => ({ id, ...patch }),
75
+ createGroupConversation: async (payload) => ({ id: 20, title: payload.title || 'Opened group', kind: 'group' }),
76
+ createDirectConversation: async (payload) => ({ id: 21, title: `Direct message with ${payload.target_user_id}`, kind: 'direct' }),
77
+ createBroadcastConversation: async () => ({ id: 21, title: 'Announcements', kind: 'broadcast' }),
78
+ createMessage: async (conversationId, payload) => ({ id: 46, conversation_id: conversationId, body: payload.body, client_request_id: payload.client_request_id, sender: { display_name: 'Harness user' } }),
79
+ uploadAttachments: async (conversationId, formData) => ({ id: 47, conversation_id: conversationId, body: formData.get('body'), attachments: [{ id: 5, filename: 'example.png', content_type: 'image/png' }] }),
80
+ getAttachment: async () => new Blob(['harness']),
81
+ getAttachmentThumbnail: async () => new Blob(['harness'], { type: 'image/png' }),
82
+ addReaction: async (messageId, emoji) => ({ reactions: [{ emoji, count: 1, reacted: true }] }),
83
+ removeReaction: async () => ({ reactions: [] }),
84
+ createPoll: async (conversationId, payload) => ({ id: 48, conversation_id: conversationId, poll: { id: 8, question: payload.question, options: payload.options.map((text, index) => ({ id: index + 1, text, vote_count: 0 })), allow_multiple: payload.allow_multiple } }),
85
+ votePoll: async (pollId, optionIds) => ({ poll: { id: pollId, question: 'Where should we meet?', options: [{ id: 1, text: 'Library', vote_count: optionIds.includes(1) ? 1 : 0, selected: optionIds.includes(1) }, { id: 2, text: 'Café', vote_count: optionIds.includes(2) ? 1 : 0, selected: optionIds.includes(2) }], allow_multiple: false } }),
86
+ closePoll: async (pollId) => ({ poll: { id: pollId, question: 'Where should we meet?', options: [{ id: 1, text: 'Library', vote_count: 2 }, { id: 2, text: 'Café', vote_count: 3 }], closed_at: new Date().toISOString() } }),
87
+ getConversationConfig: async () => ({ dm_policy: 'all', group_chat_enabled: true, everyone_can_post: true }),
88
+ patchConversationConfig: async (_id, patch) => ({ dm_policy: 'all', group_chat_enabled: true, everyone_can_post: true, ...patch }),
89
+ };
90
+
91
+ function MessagingStateDump() {
92
+ const { cache } = useMessaging();
93
+ const transport = useMockTransport();
94
+ return (
95
+ <Stack spacing={2}>
96
+ <Typography>Provider-only entry: REST seed, a simulated messaging frame, and a simulated Layer-1 reconnect.</Typography>
97
+ <Stack direction="row" spacing={2}>
98
+ <Button variant="contained" onClick={() => transport.dispatch({ envelope: 'messaging', type: 'message', event_id: `harness-${Date.now()}`, conversation_id: 12, message: { id: Date.now(), body: 'Live message' } })}>Dispatch messaging frame</Button>
99
+ <Button variant="outlined" onClick={() => transport.dispatchReconnect()}>Simulate reconnect</Button>
100
+ </Stack>
101
+ <Box component="pre" sx={{ m: 0, p: 2, bgcolor: 'action.hover', overflow: 'auto' }}>{JSON.stringify(cache, null, 2)}</Box>
102
+ </Stack>
103
+ );
104
+ }
105
+
106
+ function MessagingProviderEntry() {
107
+ return <MessagingProvider api={messagingHarnessApi} activeConversationId={12}><MessagingStateDump /></MessagingProvider>;
108
+ }
109
+
110
+ function ConversationListEntry() {
111
+ const [opened, setOpened] = useState(null);
112
+ return (
113
+ <MessagingProvider api={messagingHarnessApi}>
114
+ <ConversationList
115
+ groupLaunchers={[{ id: 'volunteers', label: 'Open volunteers group', payload: { title: 'Volunteers', participant_ids: [1, 2] } }]}
116
+ broadcastLauncher={{ label: 'Open announcements', payload: { scope: { kind: 'global' } } }}
117
+ onOpen={(conversation) => setOpened(conversation)}
118
+ />
119
+ {opened && <Typography sx={{ mt: 2 }}>Opened: {opened.title}</Typography>}
120
+ </MessagingProvider>
121
+ );
122
+ }
123
+
124
+ function ThreadEntry() { return <MessagingProvider api={messagingHarnessApi} activeConversationId={12}><Thread conversationId={12} /></MessagingProvider>; }
125
+ function MessageBubbleEntry() { return <MessagingProvider api={messagingHarnessApi} active={false}><MessageBubble message={{ id: 44, body: 'Standalone bubble mount', sender: { display_name: 'Alex' }, created_at: '2026-07-31T09:00:00Z', reactions: [{ emoji: '👍', count: 1, reacted: false }] }} onReply={() => window.alert('Reply requested')} /></MessagingProvider>; }
126
+ function ConversationLaunchersEntry() { return <MessagingProvider api={messagingHarnessApi} active={false}><ConversationLaunchers groupLaunchers={[{ id: 'volunteers', label: 'Open volunteers group', payload: { title: 'Volunteers', participant_ids: [1, 2] } }]} broadcastLauncher={{ label: 'Open announcements', payload: { scope: { kind: 'global' } } }} onOpen={(conversation) => window.alert(`Opened: ${conversation.title}`)} /></MessagingProvider>; }
127
+ function DirectMessageLauncherEntry() { return <MessagingProvider api={messagingHarnessApi} active={false}><DirectMessageLauncher candidates={[{ id: 2, display_name: 'Alex' }, { id: 3, display_name: 'Sam' }]} onOpen={(conversation) => window.alert(`Opened: ${conversation.title}`)} /></MessagingProvider>; }
128
+ function ReadTicksEntry() { return <MessagingProvider api={messagingHarnessApi} active={false}><ReadTicks messageId={44} conversation={{ id: 12, kind: 'group' }} /></MessagingProvider>; }
129
+ function ComposerEntry() { return <MessagingProvider api={messagingHarnessApi} active={false}><Composer conversationId={12} replyTarget={{ id: 44, sender: { display_name: 'Alex' } }} allowAnnouncement linkTarget="/events/12/info" /></MessagingProvider>; }
130
+ function AttachmentListEntry() { return <MessagingProvider api={messagingHarnessApi} active={false}><AttachmentList attachments={[{ id: 5, filename: 'example.png', content_type: 'image/png' }, { id: 6, filename: 'notes.pdf', content_type: 'application/pdf' }]} /></MessagingProvider>; }
131
+ function ReactionBarEntry() { return <MessagingProvider api={messagingHarnessApi} active={false}><ReactionBar message={{ id: 44, reactions: [{ emoji: '👍', count: 2, reacted: false }, { emoji: '🎉', count: 1, reacted: true }] }} /></MessagingProvider>; }
132
+ function PollCardEntry() { return <AuthContext.Provider value={{ user: { id: 1 } }}><MessagingProvider api={messagingHarnessApi} active={false}><PollCard message={{ id: 48, poll: { id: 8, question: 'Where should we meet?', options: [{ id: 1, text: 'Library', vote_count: 2 }, { id: 2, text: 'Café', vote_count: 3 }], allow_multiple: false, created_by: 1 }}} /></MessagingProvider></AuthContext.Provider>; }
133
+ function MessagingScopeConfigEntry() { return <MessagingProvider api={messagingHarnessApi} active={false}><MessagingScopeConfig conversationId={12} /></MessagingProvider>; }
134
+
135
+ export const entries = [
136
+ { id: 'notification-bell', label: 'Notifications / Bell', Component: NotificationBellEntry },
137
+ { id: 'bar-chart', label: 'Charts / BarChart', Component: BarChartEntry },
138
+ { id: 'realtime-adapter', label: 'Transport / Realtime adapter', Component: RealtimeAdapterEntry },
139
+ { id: 'messaging-provider', label: 'Messaging / Provider state', Component: MessagingProviderEntry },
140
+ { id: 'messaging-conversation-list', label: 'Messaging / Conversation list', Component: ConversationListEntry },
141
+ { id: 'messaging-thread', label: 'Messaging / Thread', Component: ThreadEntry },
142
+ { id: 'messaging-message-bubble', label: 'Messaging / Message bubble', Component: MessageBubbleEntry },
143
+ { id: 'messaging-conversation-launchers', label: 'Messaging / Conversation launchers', Component: ConversationLaunchersEntry },
144
+ { id: 'messaging-direct-message-launcher', label: 'Messaging / Direct message launcher', Component: DirectMessageLauncherEntry },
145
+ { id: 'messaging-read-ticks', label: 'Messaging / Read ticks', Component: ReadTicksEntry },
146
+ { id: 'messaging-composer', label: 'Messaging / Composer', Component: ComposerEntry },
147
+ { id: 'messaging-attachment-list', label: 'Messaging / Attachment list', Component: AttachmentListEntry },
148
+ { id: 'messaging-reaction-bar', label: 'Messaging / Reaction bar', Component: ReactionBarEntry },
149
+ { id: 'messaging-poll-card', label: 'Messaging / Poll card', Component: PollCardEntry },
150
+ { id: 'messaging-scope-config', label: 'Messaging / Scope config', Component: MessagingScopeConfigEntry },
151
+ ];
@@ -0,0 +1,26 @@
1
+ export const notificationFixtures = [
2
+ {
3
+ id: 101,
4
+ notification_id: 501,
5
+ notification_type: 'case.updated',
6
+ content: { title_key: 'harness.caseUpdated', body_key: 'harness.caseUpdatedBody' },
7
+ created_at: '2026-07-31T09:00:00Z',
8
+ seen_at: null,
9
+ },
10
+ {
11
+ id: 102,
12
+ notification_id: 502,
13
+ notification_type: 'case.assigned',
14
+ content: { title_key: 'harness.caseAssigned', body_key: 'harness.caseAssignedBody' },
15
+ created_at: '2026-07-30T12:00:00Z',
16
+ seen_at: '2026-07-30T12:05:00Z',
17
+ },
18
+ ];
19
+
20
+ export const barChartFixture = {
21
+ series: [
22
+ { data: [24, 36, 28, 52], label: 'Opened' },
23
+ { data: [18, 29, 31, 44], label: 'Resolved' },
24
+ ],
25
+ xAxis: [{ data: ['Apr', 'May', 'Jun', 'Jul'] }],
26
+ };
package/dev/main.jsx ADDED
@@ -0,0 +1,56 @@
1
+ import { StrictMode, useMemo, useState } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import { I18nextProvider } from 'react-i18next';
4
+ import i18next from 'i18next';
5
+ import { MemoryRouter } from 'react-router-dom';
6
+ import { Box, Button, ButtonGroup, CssBaseline, FormControl, InputLabel, MenuItem, Paper, Select, Stack, ThemeProvider, Typography, createTheme } from '@mui/material';
7
+
8
+ import { chartsTranslations } from '../src/i18n/chartsTranslations';
9
+ import { notificationsTranslations } from '../src/i18n/notificationsTranslations';
10
+ import { messagingTranslations } from '../src/i18n/messagingTranslations';
11
+ import { entries } from './entries';
12
+ import { MockTransportProvider } from './mockTransport';
13
+
14
+ const translations = Object.fromEntries(
15
+ Object.entries({ ...notificationsTranslations, ...chartsTranslations, ...messagingTranslations }).map(([key, value]) => [key, value.en]),
16
+ );
17
+ Object.assign(translations, {
18
+ 'harness.caseUpdated': 'Case updated',
19
+ 'harness.caseUpdatedBody': 'The case is ready for review.',
20
+ 'harness.caseAssigned': 'Case assigned',
21
+ 'harness.caseAssignedBody': 'A case was assigned to you.',
22
+ });
23
+
24
+ const i18n = i18next.createInstance();
25
+ i18n.init({ lng: 'en', fallbackLng: 'en', resources: { en: { translation: translations } }, interpolation: { escapeValue: false } });
26
+
27
+ function Harness() {
28
+ const [entryId, setEntryId] = useState(entries[0].id);
29
+ const [mode, setMode] = useState('light');
30
+ const [width, setWidth] = useState('desktop');
31
+ const entry = entries.find((item) => item.id === entryId) || entries[0];
32
+ const theme = useMemo(() => createTheme({ palette: { mode } }), [mode]);
33
+ const viewportWidth = width === 'mobile' ? 390 : width === 'tablet' ? 768 : 1180;
34
+ const Entry = entry.Component;
35
+
36
+ return (
37
+ <ThemeProvider theme={theme}>
38
+ <CssBaseline />
39
+ <Box sx={{ minHeight: '100vh', p: 3, bgcolor: 'background.default' }}>
40
+ <Stack direction={{ xs: 'column', md: 'row' }} spacing={2} sx={{ mb: 3 }} alignItems={{ md: 'center' }}>
41
+ <Typography variant="h5" sx={{ mr: 'auto' }}>ui-core-micha harness</Typography>
42
+ <FormControl size="small" sx={{ minWidth: 220 }}><InputLabel id="entry-label">Entry</InputLabel><Select labelId="entry-label" label="Entry" value={entryId} onChange={(event) => setEntryId(event.target.value)}>{entries.map((item) => <MenuItem key={item.id} value={item.id}>{item.label}</MenuItem>)}</Select></FormControl>
43
+ <ButtonGroup size="small"><Button variant={mode === 'light' ? 'contained' : 'outlined'} onClick={() => setMode('light')}>Light</Button><Button variant={mode === 'dark' ? 'contained' : 'outlined'} onClick={() => setMode('dark')}>Dark</Button></ButtonGroup>
44
+ <ButtonGroup size="small">{['mobile', 'tablet', 'desktop'].map((size) => <Button key={size} variant={width === size ? 'contained' : 'outlined'} onClick={() => setWidth(size)}>{size}</Button>)}</ButtonGroup>
45
+ </Stack>
46
+ <Paper variant="outlined" sx={{ width: viewportWidth, maxWidth: '100%', mx: 'auto', p: 3, overflow: 'hidden' }}>
47
+ <Entry />
48
+ </Paper>
49
+ </Box>
50
+ </ThemeProvider>
51
+ );
52
+ }
53
+
54
+ createRoot(document.getElementById('root')).render(
55
+ <StrictMode><I18nextProvider i18n={i18n}><MemoryRouter><MockTransportProvider><Harness /></MockTransportProvider></MemoryRouter></I18nextProvider></StrictMode>,
56
+ );
@@ -0,0 +1,128 @@
1
+ import { createContext, useContext, useMemo, useRef } from 'react';
2
+
3
+ import apiClient from '../src/auth/apiClient';
4
+ import { RealtimeContext } from '../src/notifications/realtime';
5
+ import { notificationFixtures } from './fixtures';
6
+
7
+ const MockTransportContext = createContext(null);
8
+
9
+ /**
10
+ * Stands in for the browser's WebSocket wherever a component (e.g.
11
+ * NotificationsProvider) opens its own socket internally via
12
+ * `useRealtimeCore` rather than reading it from context — that path can't be
13
+ * satisfied by wrapping RealtimeContext.Provider, since the component never
14
+ * looks at ambient context for it. Registering this as `window.WebSocket`
15
+ * lets the harness intercept the connection at the point the component
16
+ * actually creates it, instead of opening a real (and always-failing)
17
+ * socket to a harness-local URL.
18
+ */
19
+ class MockWebSocket {
20
+ constructor(url) {
21
+ this.url = url;
22
+ this.readyState = MockWebSocket.CONNECTING;
23
+ MockWebSocket._instances.add(this);
24
+ // Mirror the async-open timing of a real WebSocket handshake.
25
+ setTimeout(() => {
26
+ this.readyState = MockWebSocket.OPEN;
27
+ this.onopen?.(new Event('open'));
28
+ }, 0);
29
+ }
30
+
31
+ send() {}
32
+
33
+ close() {
34
+ this.readyState = MockWebSocket.CLOSED;
35
+ MockWebSocket._instances.delete(this);
36
+ this.onclose?.(new CloseEvent('close'));
37
+ }
38
+
39
+ static _instances = new Set();
40
+
41
+ static dispatch(frame) {
42
+ const payload = { data: JSON.stringify(frame) };
43
+ MockWebSocket._instances.forEach((socket) => {
44
+ if (socket.readyState === MockWebSocket.OPEN) socket.onmessage?.(payload);
45
+ });
46
+ }
47
+ }
48
+ MockWebSocket.CONNECTING = 0;
49
+ MockWebSocket.OPEN = 1;
50
+ MockWebSocket.CLOSING = 2;
51
+ MockWebSocket.CLOSED = 3;
52
+
53
+ export function createMockTransport({ notifications = notificationFixtures } = {}) {
54
+ let feed = [...notifications];
55
+ const subscribers = new Map();
56
+ const reconnectSubscribers = new Set();
57
+
58
+ return {
59
+ async adapter(config) {
60
+ const { method = 'get', url = '' } = config;
61
+ let data = {};
62
+ if (method === 'get' && url === '/api/notifications/feed/') {
63
+ data = { results: feed };
64
+ } else if (method === 'get' && url === '/api/notifications/feed/unread-count/') {
65
+ data = { count: feed.filter((item) => !item.seen_at && !item.dismissed_at).length };
66
+ } else if (method === 'post' && url === '/api/notifications/feed/mark/') {
67
+ const { action, ids = [] } = JSON.parse(config.data || '{}');
68
+ feed = feed.map((item) => (ids.includes(item.id)
69
+ ? { ...item, [`${action}_at`]: item[`${action}_at`] || new Date().toISOString() }
70
+ : item));
71
+ data = { updated: ids.length };
72
+ } else {
73
+ return Promise.reject(new Error(`No harness response for ${method.toUpperCase()} ${url}`));
74
+ }
75
+ return { config, data, headers: {}, status: 200, statusText: 'OK' };
76
+ },
77
+ subscribe(envelope, handler) {
78
+ const handlers = subscribers.get(envelope) || new Set();
79
+ handlers.add(handler);
80
+ subscribers.set(envelope, handlers);
81
+ return () => handlers.delete(handler);
82
+ },
83
+ // Mirrors the shape `useRealtimeCore` returns (`{ subscribe, onReconnect }`,
84
+ // the MSG-3 chunk-1 additive extension) so a component that calls
85
+ // `onReconnect()` internally (e.g. MessagingProvider) can be exercised in
86
+ // the harness the same way `dispatchReconnect()` lets it be triggered.
87
+ onReconnect(handler) {
88
+ reconnectSubscribers.add(handler);
89
+ return () => reconnectSubscribers.delete(handler);
90
+ },
91
+ // Drives both consumption paths: components reading `useRealtime()`
92
+ // from an ambient RealtimeContext (e.g. a harness entry composed
93
+ // directly under MockTransportProvider), and components that open
94
+ // their own socket internally (e.g. NotificationsProvider), which
95
+ // receive the frame via the intercepted `window.WebSocket` instead.
96
+ dispatch(frame) {
97
+ const envelope = frame.envelope || 'notification';
98
+ subscribers.get(envelope)?.forEach((handler) => handler(frame));
99
+ MockWebSocket.dispatch(frame);
100
+ },
101
+ dispatchReconnect() {
102
+ reconnectSubscribers.forEach((handler) => handler());
103
+ },
104
+ };
105
+ }
106
+
107
+ export function MockTransportProvider({ children }) {
108
+ const transportRef = useRef(null);
109
+ if (!transportRef.current) {
110
+ transportRef.current = createMockTransport();
111
+ apiClient.defaults.adapter = transportRef.current.adapter;
112
+ window.WebSocket = MockWebSocket;
113
+ }
114
+ const transport = transportRef.current;
115
+ const realtimeValue = useMemo(() => ({ subscribe: transport.subscribe, onReconnect: transport.onReconnect }), [transport]);
116
+
117
+ return (
118
+ <MockTransportContext.Provider value={transport}>
119
+ <RealtimeContext.Provider value={realtimeValue}>{children}</RealtimeContext.Provider>
120
+ </MockTransportContext.Provider>
121
+ );
122
+ }
123
+
124
+ export function useMockTransport() {
125
+ const transport = useContext(MockTransportContext);
126
+ if (!transport) throw new Error('useMockTransport must be used within MockTransportProvider');
127
+ return transport;
128
+ }
@@ -0,0 +1,107 @@
1
+ export const messagingTranslations = {
2
+ 'MessagingComposer.ADD_EMOJI': { de: 'Emoji hinzufügen', en: 'Add emoji', fr: 'Ajouter un emoji' },
3
+ 'MessagingComposer.STAGED_FILES': { de: 'Vorgemerkte Anhänge', en: 'Staged attachments', fr: 'Pièces jointes préparées' },
4
+ 'MessagingComposer.REMOVE_ATTACHMENT': { de: '{{name}} entfernen', en: 'Remove {{name}}', fr: 'Supprimer {{name}}' },
5
+ 'MessagingComposer.UPLOADING': { de: 'Anhänge werden hochgeladen…', en: 'Uploading attachments…', fr: 'Téléversement des pièces jointes…' },
6
+ 'MessagingList.LABEL': { de: 'Unterhaltungen', en: 'Conversations', fr: 'Conversations' },
7
+ 'MessagingList.EMPTY': { de: 'Noch keine Unterhaltungen.', en: 'No conversations yet.', fr: 'Aucune conversation pour le moment.' },
8
+ 'MessagingList.NO_MESSAGES': { de: 'Noch keine Nachrichten', en: 'No messages yet', fr: 'Aucun message pour le moment' },
9
+ 'MessagingList.UNTITLED': { de: 'Unbenannte Unterhaltung', en: 'Untitled conversation', fr: 'Conversation sans titre' },
10
+ 'MessagingList.ACTIONS': { de: 'Aktionen für Unterhaltung', en: 'Conversation actions', fr: 'Actions de la conversation' },
11
+ 'MessagingList.LOAD_MORE': { de: 'Mehr laden', en: 'Load more', fr: 'Charger plus' },
12
+ 'MessagingList.LOAD_ERROR': { de: 'Unterhaltungen konnten nicht geladen werden.', en: 'Conversations could not be loaded.', fr: 'Impossible de charger les conversations.' },
13
+ 'MessagingList.ARCHIVE': { de: 'Archivieren', en: 'Archive', fr: 'Archiver' },
14
+ 'MessagingList.ARCHIVE_ERROR': { de: 'Unterhaltung konnte nicht archiviert werden.', en: 'Conversation could not be archived.', fr: 'Impossible d’archiver la conversation.' },
15
+ 'MessagingList.MUTE': { de: 'Stummschalten', en: 'Mute', fr: 'Mettre en sourdine' },
16
+ 'MessagingList.UNMUTE': { de: 'Stummschaltung aufheben', en: 'Unmute', fr: 'Réactiver le son' },
17
+ 'MessagingList.PREFERENCES_ERROR': { de: 'Einstellungen konnten nicht gespeichert werden.', en: 'Preferences could not be saved.', fr: 'Impossible d’enregistrer les préférences.' },
18
+ 'MessagingLaunchers.OPEN_GROUP': { de: 'Gruppenunterhaltung öffnen', en: 'Open group conversation', fr: 'Ouvrir la conversation de groupe' },
19
+ 'MessagingLaunchers.OPEN_BROADCAST': { de: 'Ankündigungen öffnen', en: 'Open announcements', fr: 'Ouvrir les annonces' },
20
+ 'MessagingLaunchers.OPEN_ERROR': { de: 'Unterhaltung konnte nicht geöffnet werden.', en: 'Conversation could not be opened.', fr: 'Impossible d’ouvrir la conversation.' },
21
+ 'MessagingThread.TIMELINE': { de: 'Nachrichtenverlauf', en: 'Message timeline', fr: 'Chronologie des messages' },
22
+ 'MessagingThread.MESSAGE': { de: 'Nachricht', en: 'Message', fr: 'Message' },
23
+ 'MessagingThread.EMPTY': { de: 'Noch keine Nachrichten.', en: 'No messages yet.', fr: 'Aucun message pour le moment.' },
24
+ 'MessagingThread.LOAD_OLDER': { de: 'Ältere Nachrichten laden', en: 'Load older messages', fr: 'Charger les messages plus anciens' },
25
+ 'MessagingThread.LOAD_ERROR': { de: 'Nachrichten konnten nicht geladen werden.', en: 'Messages could not be loaded.', fr: 'Impossible de charger les messages.' },
26
+ 'MessagingThread.REPLIES_ERROR': { de: 'Antworten konnten nicht geladen werden.', en: 'Replies could not be loaded.', fr: 'Impossible de charger les réponses.' },
27
+ 'MessagingThread.DELETED': { de: 'Diese Nachricht wurde gelöscht.', en: 'This message was deleted.', fr: 'Ce message a été supprimé.' },
28
+ 'MessagingThread.EDITED': { de: 'bearbeitet', en: 'edited', fr: 'modifié' },
29
+ 'MessagingThread.REPLY': { de: 'Antworten', en: 'Reply', fr: 'Répondre' },
30
+ 'MessagingThread.REPLY_TO': { de: 'Antwort auf {{sender}}', en: 'Reply to {{sender}}', fr: 'Réponse à {{sender}}' },
31
+ 'MessagingThread.REPLYING_TO': { de: 'Antwort an {{sender}}', en: 'Replying to {{sender}}', fr: 'Réponse à {{sender}}' },
32
+ 'MessagingThread.SHOW_REPLIES': { de: '{{count}} Antworten anzeigen', en: 'Show {{count}} replies', fr: 'Afficher {{count}} réponses' },
33
+ 'MessagingThread.HIDE_REPLIES': { de: 'Antworten ausblenden', en: 'Hide replies', fr: 'Masquer les réponses' },
34
+ 'MessagingThread.UNKNOWN_SENDER': { de: 'Unbekannte Person', en: 'Unknown person', fr: 'Personne inconnue' },
35
+ 'MessagingThread.QUOTE_ATTACHMENT': { de: 'Anhang', en: 'Attachment', fr: 'Pièce jointe' },
36
+ 'MessagingActions.MENU': { de: 'Nachrichtenaktionen', en: 'Message actions', fr: 'Actions du message' },
37
+ 'MessagingActions.EDIT': { de: 'Bearbeiten', en: 'Edit', fr: 'Modifier' },
38
+ 'MessagingActions.SAVE': { de: 'Speichern', en: 'Save', fr: 'Enregistrer' },
39
+ 'MessagingActions.CANCEL': { de: 'Abbrechen', en: 'Cancel', fr: 'Annuler' },
40
+ 'MessagingActions.DELETE': { de: 'Löschen', en: 'Delete', fr: 'Supprimer' },
41
+ 'MessagingActions.COPY': { de: 'Kopieren', en: 'Copy', fr: 'Copier' },
42
+ 'MessagingActions.DELETE_CONFIRM_TITLE': { de: 'Nachricht löschen?', en: 'Delete message?', fr: 'Supprimer le message ?' },
43
+ 'MessagingActions.DELETE_CONFIRM_BODY': { de: 'Diese Nachricht wird für alle entfernt.', en: 'This message will be removed for everyone.', fr: 'Ce message sera supprimé pour tout le monde.' },
44
+ 'MessagingActions.EDIT_ERROR': { de: 'Nachricht konnte nicht bearbeitet werden: {{message}}', en: 'Message could not be edited: {{message}}', fr: 'Impossible de modifier le message : {{message}}' },
45
+ 'MessagingActions.DELETE_ERROR': { de: 'Nachricht konnte nicht gelöscht werden: {{message}}', en: 'Message could not be deleted: {{message}}', fr: 'Impossible de supprimer le message : {{message}}' },
46
+ 'MessagingActions.COPY_ERROR': { de: 'Nachricht konnte nicht kopiert werden: {{message}}', en: 'Message could not be copied: {{message}}', fr: 'Impossible de copier le message : {{message}}' },
47
+ 'MessagingDirect.LAUNCH': { de: 'Direktnachricht starten', en: 'Start direct message', fr: 'Démarrer un message direct' },
48
+ 'MessagingDirect.TITLE': { de: 'Direktnachricht starten', en: 'Start direct message', fr: 'Démarrer un message direct' },
49
+ 'MessagingDirect.CANDIDATES': { de: 'Empfängerinnen und Empfänger', en: 'Recipients', fr: 'Destinataires' },
50
+ 'MessagingDirect.EMPTY': { de: 'Keine Personen verfügbar.', en: 'No people are available.', fr: 'Aucune personne n’est disponible.' },
51
+ 'MessagingDirect.START': { de: 'Starten', en: 'Start', fr: 'Démarrer' },
52
+ 'MessagingDirect.STARTING': { de: 'Direktnachricht wird gestartet…', en: 'Starting direct message…', fr: 'Démarrage du message direct…' },
53
+ 'MessagingDirect.CANCEL': { de: 'Abbrechen', en: 'Cancel', fr: 'Annuler' },
54
+ 'MessagingDirect.START_ERROR': { de: 'Direktnachricht konnte nicht gestartet werden.', en: 'Direct message could not be started.', fr: 'Impossible de démarrer le message direct.' },
55
+ 'MessagingReadTicks.ALL_READ': { de: 'Von allen gelesen', en: 'Read by everyone', fr: 'Lu par tout le monde' },
56
+ 'MessagingReadTicks.DELIVERED': { de: 'Zugestellt an {{count}}', en: 'Delivered to {{count}}', fr: 'Distribué à {{count}}' },
57
+ 'MessagingReadTicks.RECIPIENTS': { de: 'Lesebestätigungen', en: 'Read recipients', fr: 'Destinataires lus' },
58
+ 'MessagingComposer.LABEL': { de: 'Nachricht verfassen', en: 'Compose message', fr: 'Rédiger un message' },
59
+ 'MessagingComposer.MESSAGE': { de: 'Nachricht', en: 'Message', fr: 'Message' },
60
+ 'MessagingComposer.SEND': { de: 'Senden', en: 'Send', fr: 'Envoyer' },
61
+ 'MessagingComposer.RETRY': { de: 'Erneut versuchen', en: 'Retry', fr: 'Réessayer' },
62
+ 'MessagingComposer.ADD_ATTACHMENT': { de: 'Anhang hinzufügen', en: 'Add attachment', fr: 'Ajouter une pièce jointe' },
63
+ 'MessagingComposer.FILES_SELECTED': { de: '{{count}} Anhänge ausgewählt', en: '{{count}} attachments selected', fr: '{{count}} pièces jointes sélectionnées' },
64
+ 'MessagingComposer.REPLYING_TO': { de: 'Antwort an {{sender}}', en: 'Replying to {{sender}}', fr: 'Réponse à {{sender}}' },
65
+ 'MessagingComposer.SEND_ERROR': { de: 'Nachricht konnte nicht gesendet werden: {{message}}', en: 'Message could not be sent: {{message}}', fr: 'Impossible d’envoyer le message : {{message}}' },
66
+ 'MessagingComposer.UPLOAD_ERROR': { de: 'Anhang wurde abgelehnt: {{message}}', en: 'Attachment was rejected: {{message}}', fr: 'La pièce jointe a été rejetée : {{message}}' },
67
+ 'MessagingAttachments.LABEL': { de: 'Anhänge', en: 'Attachments', fr: 'Pièces jointes' },
68
+ 'MessagingAttachments.DOWNLOAD': { de: '{{name}} herunterladen', en: 'Download {{name}}', fr: 'Télécharger {{name}}' },
69
+ 'MessagingReactions.LABEL': { de: 'Reaktionen', en: 'Reactions', fr: 'Réactions' },
70
+ 'MessagingReactions.ADD': { de: 'Reaktion hinzufügen', en: 'Add reaction', fr: 'Ajouter une réaction' },
71
+ 'MessagingReactions.TOGGLE': { de: '{{emoji}} Reaktion mit {{count}} Stimmen umschalten', en: 'Toggle {{emoji}} reaction with {{count}} votes', fr: 'Basculer la réaction {{emoji}} avec {{count}} votes' },
72
+ 'MessagingReactions.ERROR': { de: 'Reaktion konnte nicht gespeichert werden: {{message}}', en: 'Reaction could not be saved: {{message}}', fr: 'Impossible d’enregistrer la réaction : {{message}}' },
73
+ 'MessagingPoll.LABEL': { de: 'Umfrage', en: 'Poll', fr: 'Sondage' },
74
+ 'MessagingPoll.VOTE': { de: 'Abstimmen', en: 'Vote', fr: 'Voter' },
75
+ 'MessagingPoll.CLOSE': { de: 'Umfrage schliessen', en: 'Close poll', fr: 'Clore le sondage' },
76
+ 'MessagingPoll.CLOSED': { de: 'Geschlossen', en: 'Closed', fr: 'Clos' },
77
+ 'MessagingPoll.SELECT_OPTION': { de: 'Wähle mindestens eine Option.', en: 'Select at least one option.', fr: 'Sélectionnez au moins une option.' },
78
+ 'MessagingPoll.VOTE_ERROR': { de: 'Stimme konnte nicht gespeichert werden: {{message}}', en: 'Vote could not be saved: {{message}}', fr: 'Impossible d’enregistrer le vote : {{message}}' },
79
+ 'MessagingPoll.CLOSE_ERROR': { de: 'Umfrage konnte nicht geschlossen werden: {{message}}', en: 'Poll could not be closed: {{message}}', fr: 'Impossible de clore le sondage : {{message}}' },
80
+ 'MessagingPoll.CREATE': { de: 'Umfrage erstellen', en: 'Create poll', fr: 'Créer un sondage' },
81
+ 'MessagingPoll.CREATE_ERROR': { de: 'Umfrage konnte nicht erstellt werden: {{message}}', en: 'Poll could not be created: {{message}}', fr: 'Impossible de créer le sondage : {{message}}' },
82
+ 'MessagingPoll.CREATE_VALIDATION': { de: 'Frage und mindestens zwei Optionen sind erforderlich.', en: 'A question and at least two options are required.', fr: 'Une question et au moins deux options sont requises.' },
83
+ 'MessagingPoll.QUESTION': { de: 'Frage', en: 'Question', fr: 'Question' },
84
+ 'MessagingPoll.OPTION': { de: 'Option {{count}}', en: 'Option {{count}}', fr: 'Option {{count}}' },
85
+ 'MessagingPoll.ADD_OPTION': { de: 'Option hinzufügen', en: 'Add option', fr: 'Ajouter une option' },
86
+ 'MessagingPoll.ALLOW_MULTIPLE': { de: 'Mehrfachauswahl erlauben', en: 'Allow multiple choices', fr: 'Autoriser plusieurs choix' },
87
+ 'MessagingPoll.CANCEL': { de: 'Abbrechen', en: 'Cancel', fr: 'Annuler' },
88
+ 'MessagingConfig.LABEL': { de: 'Nachrichten-Einstellungen', en: 'Messaging settings', fr: 'Paramètres de messagerie' },
89
+ 'MessagingConfig.TITLE': { de: 'Nachrichten-Einstellungen', en: 'Messaging settings', fr: 'Paramètres de messagerie' },
90
+ 'MessagingConfig.LOADING': { de: 'Einstellungen werden geladen…', en: 'Loading settings…', fr: 'Chargement des paramètres…' },
91
+ 'MessagingConfig.LOAD_ERROR': { de: 'Einstellungen konnten nicht geladen werden: {{message}}', en: 'Settings could not be loaded: {{message}}', fr: 'Impossible de charger les paramètres : {{message}}' },
92
+ 'MessagingConfig.SAVE_ERROR': { de: 'Einstellungen konnten nicht gespeichert werden: {{message}}', en: 'Settings could not be saved: {{message}}', fr: 'Impossible d’enregistrer les paramètres : {{message}}' },
93
+ 'MessagingConfig.DM_POLICY': { de: 'Direktnachrichten-Regel', en: 'Direct-message policy', fr: 'Règle des messages directs' },
94
+ 'MessagingConfig.DM_ALL': { de: 'Alle', en: 'Everyone', fr: 'Tout le monde' },
95
+ 'MessagingConfig.DM_SHARED': { de: 'Nur gemeinsame Gruppen', en: 'Shared groups only', fr: 'Groupes communs uniquement' },
96
+ 'MessagingConfig.DM_TEAM': { de: 'Nur Team', en: 'Team only', fr: 'Équipe uniquement' },
97
+ 'MessagingConfig.GROUP_ENABLED': { de: 'Gruppenchat aktivieren', en: 'Enable group chat', fr: 'Activer le chat de groupe' },
98
+ 'MessagingConfig.EVERYONE_POSTS': { de: 'Alle dürfen schreiben', en: 'Everyone can post', fr: 'Tout le monde peut publier' },
99
+ 'MessagingAnnouncement.CREATE': { de: 'Ankündigung erstellen', en: 'Create announcement', fr: 'Créer une annonce' },
100
+ 'MessagingAnnouncement.TITLE': { de: 'Titel', en: 'Title', fr: 'Titre' },
101
+ 'MessagingAnnouncement.BODY': { de: 'Text', en: 'Body', fr: 'Texte' },
102
+ 'MessagingAnnouncement.SEND': { de: 'Ankündigung senden', en: 'Send announcement', fr: 'Envoyer l’annonce' },
103
+ 'MessagingAnnouncement.CANCEL': { de: 'Abbrechen', en: 'Cancel', fr: 'Annuler' },
104
+ 'MessagingAnnouncement.VALIDATION': { de: 'Ein Titel ist erforderlich.', en: 'A title is required.', fr: 'Un titre est requis.' },
105
+ 'MessagingAnnouncement.ERROR': { de: 'Ankündigung konnte nicht gesendet werden: {{message}}', en: 'Announcement could not be sent: {{message}}', fr: 'Impossible d’envoyer l’annonce : {{message}}' },
106
+ 'MessagingAnnouncement.OPEN_LINK': { de: 'Link öffnen', en: 'Open link', fr: 'Ouvrir le lien' },
107
+ };
package/dist/index.js CHANGED
@@ -43,6 +43,20 @@ export { useRealtime } from './notifications/realtime';
43
43
  export { NotificationBell } from './notifications/NotificationBell';
44
44
  export { PopupSurface } from './notifications/PopupSurface';
45
45
  export { getNotificationFeed, getUnreadCount, markNotifications } from './notifications/feedApi';
46
+ // --- 9. Messaging ---
47
+ export * from './messaging/api';
48
+ export { MessagingProvider, useMessaging } from './messaging/MessagingProvider';
49
+ export { ConversationList } from './messaging/ConversationList';
50
+ export { ConversationLaunchers } from './messaging/ConversationLaunchers';
51
+ export { DirectMessageLauncher } from './messaging/DirectMessageLauncher';
52
+ export { Thread } from './messaging/Thread';
53
+ export { MessageBubble } from './messaging/MessageBubble';
54
+ export { ReadTicks } from './messaging/ReadTicks';
55
+ export { Composer } from './messaging/Composer';
56
+ export { AttachmentList } from './messaging/AttachmentList';
57
+ export { ReactionBar } from './messaging/ReactionBar';
58
+ export { PollCard } from './messaging/PollCard';
59
+ export { MessagingScopeConfig } from './messaging/MessagingScopeConfig';
46
60
  // --- 9. Onboarding ---
47
61
  export * from './onboarding/api';
48
62
  export { selectActiveSteps } from './onboarding/stepSelection';
@@ -56,3 +70,4 @@ export { PwaInstallStep } from './onboarding/steps/PwaInstallStep';
56
70
  export { notificationsTranslations } from './i18n/notificationsTranslations';
57
71
  export { onboardingTranslations } from './i18n/onboardingTranslations';
58
72
  export { chartsTranslations } from './i18n/chartsTranslations';
73
+ export { messagingTranslations } from './i18n/messagingTranslations';