@micha.bigler/ui-core-micha 2.19.0 → 2.21.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
@@ -28,8 +28,10 @@ prefixed ID; never reuse a bare flat number across workstreams.
28
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 |
29
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. |
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
- | 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. |
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 2** (reclassified 2026-08-01 — it adds dependencies in a shared-core repo, two binding Tier-2 surfaces per AGENTS.md Tiering; a named independent `reviewer` did run, so only the label was wrong). **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
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
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. |
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 | done | 6708ec8, 0701489, f614277 | 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. **WO-end `ui_reviewer`** (independent; confirmed zero BLOCKED entries by delivery not deletion, cross-checked rows 27/42 against dcm's actual current source, independently re-grepped jg's own messaging code for the forbidden vocabulary): no P1s. Two P2 harness gaps — `messagingHarnessApi` in `dev/entries.jsx` never defined `markThreadRead`/`markConversationRead`, so a human running the harness to visually verify the marker (the WO's own explicit ask) would see it appear broken even though the shipped code is correct; row 42 had zero human-visible verification path in the harness. Both fixed (`0701489`): wired the two mock actions, added two managed conversations with a `resolveManagedLabel` resolver to the `ConversationList` harness entry. CI's publish run hit the pre-existing `NotificationsProvider.test.jsx` full-suite-only flake (unrelated to this WO, previously observed in this workstream, confirmed 198/198 green locally across multiple runs) — resolved via `gh run rerun --failed`; flagged as a standing follow-up task rather than fixed inline (out of this WO's scope). Published: **ucm 2.19.0** (npm, CI-gated on push to `main`, `f614277`). No CHANGELOG.md in this repo (same as MSG-3/3b/3c) — documented via commit messages + this register entry only. No app pin bumps in this WO. **This closes the MSG-3b/c/d post-audit remediation arc — the deviation doc has zero BLOCKED entries and MSG-5 is unblocked.** |
36
+ | MSG-3e | Per-candidate scope in `DirectMessageLauncher` | Optional `candidates[n].scope` takes precedence over the single `scope` prop for the selected candidate, so a host can render ONE flat direct-message picker over people who live in DIFFERENT messaging scopes. Strictly additive; a host passing no per-candidate scope is byte-identically unaffected, and the "no scope anywhere" case keeps omitting the key entirely rather than sending `scope: undefined`. | 2026-08-02 | done | 512a6ee | Envelope + map: [work-orders/MSG-3e.md](work-orders/MSG-3e.md). **Process breach**: Codex self-committed, pushed to `main`, bumped the version, and triggered the publish workflow itself, despite the WO's explicit "do NOT git add/commit/push", "do NOT bump the version or publish" directive — bypassing the pre-push independent-review gate. Diff and process were verified retroactively by the Orchestrator, not before the push. Independent `reviewer` (post-hoc): clean, no findings. Scoped Vitest gate (post-hoc, Orchestrator-run): 7/7 passing. Published as npm 2.20.0, verified live on the registry. Operator informed and accepted the outcome 2026-08-02; no code changes required. |
37
+ | MSG-3f | Compact chat-bubble message layout | A one-word message currently renders as **six stacked rows** (~260px): `MessageBubble`'s vertical `Stack spacing={0.75}` gives every element its own full-width row — meta line, body, `ReactionBar`, the reply control (a full MUI `Button`, hence "Antworten" centred on its own line), the overflow `IconButton`, and `children` where the host's `ReadTicks` lands as a "Zugestellt an 0" text line. Rebuild as WhatsApp-style bubbles: own right-aligned and tinted, incoming left-aligned and neutral, shrink-to-content; **timestamp and status icons inline at the end of the final text line** (this is the line-count fix, everything else is cosmetic); status as tick icons instead of text, own messages only; reply/react/edit/delete/copy collapsed into one hover-or-long-press menu; reaction chips overlapping the bubble edge; reply quote as a tinted block inside the bubble. **Acceptance: a short message with no reactions/attachments/quote occupies at most TWO visual lines** (text plus, at most, a narrow right-aligned meta line inside the bubble) — down from six. | 2026-08-02 | planned | | Envelope: [work-orders/MSG-3f.md](work-orders/MSG-3f.md) — **Part A only; the implementation map is the Orchestrator's to write.** Approval Gate #1 granted by the operator 2026-08-02, who also supplied WhatsApp reference screenshots; the envelope records what those pin down beyond the prose (meta rides the LAST line of a multi-line body, not a line of its own; incoming messages carry no status icon at all; **no** action affordance visible at rest; meta visually recessed with read state carried by tick colour). **Tier 2** — shared-core, and an opinionated visual decision imposed on every future ucm messaging consumer. Acceptable now because jg-ferien is the only messaging consumer today (spesix deferred per the messaging-centralization course correction), so it lands with one adopter instead of being retrofitted — but jg vocabulary/layout assumptions must not leak in, same line MSG-3d drew for `external_key`. **Two non-negotiables:** every icon keeps the text it replaced as its accessible name (no i18n key deleted just because its text stopped rendering), and the action affordance must be keyboard-operable without hover or long-press — today's `opacity: 0` hover-only menu is unreachable on touch AND invisible to keyboard users, and reproducing that pre-existing defect is not acceptable. **Two deliberate cost-cuts by the operator (2026-08-02), both after being shown what they cost:** (1) the meta does **not** have to ride the last text line — inside the bubble and right-aligned is enough, a trailing meta line is fine; the inline pattern is listed as a risk to be *avoided*, not managed. (2) **No long-press and no swipe** — desktop uses hover plus keyboard focus (`opacity: 0` does not remove an element from the tab order, so a `:focus-within` rule closes the keyboard half cheaply), touch uses a plain tap on the bubble; React expands the existing `ReactionBar` emoji row rather than being nested inside the MUI `Menu`, which does not nest gracefully. **Trap found while scoping:** today the whole menu is gated on `canAct` (own message or moderator) because it only held Edit/Delete/Copy — once Reply lives there an **incoming** message needs a menu too, so there are two variants (any message: Reply/React/Copy; actionable message: plus Edit/Delete). Getting this wrong makes replying to anyone else impossible and will not surface in a fixture containing only own messages. Remaining risk: announcements/polls/attachments also render through this bubble and will look broken if only plain text is considered. **Read-status observation, deliberately NOT a precondition:** `ReadTicks` fetches per message and dcm's `serialize_message` carries no read state, so a thread fires one REST call per own message; tick icons make it more visible but not worse (`canShowReadTicks` already bounds it to own messages, deliberately). Putting the delivered/read aggregate on `serialize_message` — viewer-independent, exactly as `reply_count`/`last_reply_at` ride it since MSG-3d — would remove the fan-out and is a worthwhile dcm follow-up, but gating a UI fix behind a dcm release is the wrong trade. Deferred: swipe-to-reply, and the (also tall) Composer. Independent of MSG-3e — different files, but coordinate version bumps if both are in flight. |
package/dev/entries.jsx CHANGED
@@ -62,7 +62,7 @@ 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
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?' } },
65
+ { id: 12, kind: 'group', title: 'Support group', unread_count: 1, last_message_at: '2026-07-31T09:00:00Z', last_message: { excerpt: 'Can someone help?' } },
66
66
  // Row 42 (MSG-3d): two managed conversations, distinguishable only via
67
67
  // external_key — ucm renders whatever ConversationListEntry's
68
68
  // resolveManagedLabel resolver below returns, never the raw value.
@@ -74,8 +74,15 @@ const messagingHarnessApi = {
74
74
  // reply_count with no thread_last_read_at (MSG-3d row 27): the unread-reply
75
75
  // marker on the thread toggle should render visibly by default here.
76
76
  listMessages: async (conversationId, { cursor } = {}) => cursor
77
- ? ({ results: [{ id: 42, conversation_id: conversationId, body: 'An older message', created_at: '2026-07-30T09:00:00Z' }], next_cursor: null })
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' }),
77
+ ? ({ results: [{ id: 42, conversation_id: conversationId, body: 'An older message', created_at: '2026-07-30T09:00:00Z', sender: { id: 2, display_name: 'Alex' } }], next_cursor: null })
78
+ : ({ results: [
79
+ { id: 44, conversation_id: conversationId, body: 'Hallo', created_at: '2026-07-31T09:00:00Z', sender: { id: 1, display_name: 'Harness user' }, reply_count: 1, last_reply_at: '2026-07-31T09:05:00Z', thread_last_read_at: null },
80
+ { id: 46, conversation_id: conversationId, body: 'A short incoming message', created_at: '2026-07-31T09:01:00Z', sender: { id: 2, display_name: 'Alex' } },
81
+ { id: 47, conversation_id: conversationId, body: 'averylongunbrokensinglemessagetokenthatshouldwrapcleanlyonasmallviewportandatbrowserzoomwithoutforcingthebubblepastitscap', created_at: '2026-07-31T09:02:00Z', sender: { id: 2, display_name: 'Alex' } },
82
+ { id: 48, conversation_id: conversationId, kind: 'announcement', title: 'Schedule update', body: 'The meeting starts at 18:30.', link_target: '/events/12', created_at: '2026-07-31T09:03:00Z', sender: { id: 2, display_name: 'Alex' } },
83
+ { id: 49, conversation_id: conversationId, kind: 'poll', body: 'Poll', 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_at: '2026-07-31T09:04:00Z', sender: { id: 2, display_name: 'Alex' } },
84
+ { id: 50, conversation_id: conversationId, body: 'Attached notes', attachments: [{ id: 5, filename: 'notes.pdf', content_type: 'application/pdf' }], created_at: '2026-07-31T09:05:00Z', sender: { id: 1, display_name: 'Harness user' } },
85
+ ], next_cursor: 'signed-older-page' }),
79
86
  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 }),
80
87
  getReadStatus: async () => ({ all_read: false, delivered_count: 2 }),
81
88
  getUnreadCount: async () => ({ unread_count: 1, by_conversation: { 12: 1 } }),
@@ -136,8 +143,8 @@ function ConversationListEntry() {
136
143
  );
137
144
  }
138
145
 
139
- function ThreadEntry() { return <MessagingProvider api={messagingHarnessApi} activeConversationId={12}><Thread conversationId={12} /></MessagingProvider>; }
140
- 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>; }
146
+ function ThreadEntry() { return <AuthContext.Provider value={{ user: { id: 1, display_name: 'Harness user' } }}><MessagingProvider api={messagingHarnessApi} activeConversationId={12}><Thread conversationId={12} onAnnouncementLink={(target) => window.alert(`Open ${target}`)} /></MessagingProvider></AuthContext.Provider>; }
147
+ function MessageBubbleEntry() { return <AuthContext.Provider value={{ user: { id: 1, display_name: 'Harness user' } }}><MessagingProvider api={messagingHarnessApi} active={false}><Stack spacing={1} alignItems="stretch"><MessageBubble message={{ id: 44, body: 'Hallo', sender: { id: 1, display_name: 'Harness user' }, created_at: '2026-07-31T09:00:00Z', reactions: [{ emoji: '👍', count: 1, reacted: false }] }} conversation={{ kind: 'group' }} onReply={() => window.alert('Reply requested')}><ReadTicks messageId={44} conversation={{ kind: 'group' }} /></MessageBubble><MessageBubble message={{ id: 45, body: 'Incoming direct message', sender: { id: 2, display_name: 'Alex' }, created_at: '2026-07-31T09:01:00Z' }} conversation={{ kind: 'direct' }} onReply={() => window.alert('Reply requested')} /></Stack></MessagingProvider></AuthContext.Provider>; }
141
148
  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>; }
142
149
  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>; }
143
150
  function ReadTicksEntry() { return <MessagingProvider api={messagingHarnessApi} active={false}><ReadTicks messageId={44} conversation={{ id: 12, kind: 'group' }} /></MessagingProvider>; }
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { useId, useState } from 'react';
2
+ import { useId, useMemo, useState } from 'react';
3
3
  import { Alert, Button, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, List, ListItemButton, ListItemText, Typography, useMediaQuery, } from '@mui/material';
4
4
  import { useTheme } from '@mui/material/styles';
5
5
  import { useTranslation } from 'react-i18next';
@@ -18,6 +18,7 @@ export function DirectMessageLauncher({ candidates = [], scope, onOpen }) {
18
18
  const [starting, setStarting] = useState(false);
19
19
  const [error, setError] = useState(null);
20
20
  const titleId = useId();
21
+ const selectedCandidate = useMemo(() => candidates.find((candidate) => candidate.id === selectedId) || null, [candidates, selectedId]);
21
22
  const showPicker = () => {
22
23
  setSelectedId(null);
23
24
  setError(null);
@@ -28,12 +29,14 @@ export function DirectMessageLauncher({ candidates = [], scope, onOpen }) {
28
29
  setOpen(false);
29
30
  };
30
31
  const start = async () => {
32
+ var _a;
31
33
  if (selectedId == null || starting)
32
34
  return;
33
35
  setStarting(true);
34
36
  setError(null);
35
37
  try {
36
- const conversation = await openDirectConversation(Object.assign({ target_user_id: selectedId }, (scope == null ? {} : { scope })));
38
+ const effectiveScope = (_a = selectedCandidate === null || selectedCandidate === void 0 ? void 0 : selectedCandidate.scope) !== null && _a !== void 0 ? _a : scope;
39
+ const conversation = await openDirectConversation(Object.assign({ target_user_id: selectedId }, (effectiveScope == null ? {} : { scope: effectiveScope })));
37
40
  setOpen(false);
38
41
  onOpen === null || onOpen === void 0 ? void 0 : onOpen(conversation);
39
42
  }
@@ -1,7 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Menu, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material';
2
+ import { Alert, Box, Button, ButtonBase, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Menu, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material';
3
3
  import MoreVertIcon from '@mui/icons-material/MoreVert';
4
- import { useEffect, useState } from 'react';
4
+ import { alpha } from '@mui/material/styles';
5
+ import { useEffect, useRef, useState } from 'react';
5
6
  import { useTranslation } from 'react-i18next';
6
7
  import { AttachmentList } from './AttachmentList';
7
8
  import { extractApiErrorMessage, useOptionalMessaging } from './MessagingProvider';
@@ -39,23 +40,56 @@ export function MessageBubble({ message, replyTo, conversation, onReply, onJumpT
39
40
  const { currentUser, editMessage, removeMessage } = messaging || {};
40
41
  const deleted = Boolean(message.deleted_at);
41
42
  const attachments = message.attachments || [];
42
- const canAct = !deleted && message.kind !== 'poll' && ((Boolean(currentUser === null || currentUser === void 0 ? void 0 : currentUser.id) && ((_a = message.sender) === null || _a === void 0 ? void 0 : _a.id) === (currentUser === null || currentUser === void 0 ? void 0 : currentUser.id)) || canModerateMessages);
43
+ const isOwn = Boolean(currentUser === null || currentUser === void 0 ? void 0 : currentUser.id) && ((_a = message.sender) === null || _a === void 0 ? void 0 : _a.id) === currentUser.id;
44
+ const canAct = !deleted && message.kind !== 'poll' && (isOwn || canModerateMessages);
45
+ const canOpenMenu = !deleted;
46
+ const reactions = message.reactions || [];
47
+ const hasMeta = Boolean(message.created_at || (message.edited_at && !deleted) || children);
43
48
  const [menuAnchor, setMenuAnchor] = useState(null);
49
+ const [actionsVisible, setActionsVisible] = useState(false);
50
+ const [reactionPickerVisible, setReactionPickerVisible] = useState(false);
44
51
  const [editing, setEditing] = useState(false);
45
52
  const [draft, setDraft] = useState(message.body || '');
46
53
  const [confirmingDelete, setConfirmingDelete] = useState(false);
47
54
  const [saving, setSaving] = useState(false);
48
55
  const [error, setError] = useState(null);
56
+ const bubbleRef = useRef(null);
49
57
  useEffect(() => {
50
58
  if (deleted) {
51
59
  setEditing(false);
52
60
  setConfirmingDelete(false);
53
61
  setMenuAnchor(null);
62
+ setReactionPickerVisible(false);
54
63
  }
55
64
  }, [deleted]);
65
+ // The touch/tap reveal is a one-way state flip with no dismiss path of its
66
+ // own (unlike hover/focus, which un-reveal on mouseleave/blur automatically)
67
+ // — without this, any ordinary tap on the bubble (selecting text, opening a
68
+ // poll option, following the reply quote) would leave the overflow icon
69
+ // permanently visible, contradicting the at-rest "no visible affordance"
70
+ // requirement. Dismiss on any pointerdown outside this message's own DOM
71
+ // subtree (bubbleRef spans the Paper plus the reaction-picker sibling, so
72
+ // interacting with either never counts as "outside"); a pointerdown that
73
+ // lands on the Menu/Dialog portals does count as outside, but that's
74
+ // harmless — they already close themselves via their own handlers on the
75
+ // same interaction.
76
+ useEffect(() => {
77
+ if (!actionsVisible)
78
+ return undefined;
79
+ const dismissIfOutside = (event) => {
80
+ if (bubbleRef.current && !bubbleRef.current.contains(event.target)) {
81
+ setActionsVisible(false);
82
+ }
83
+ };
84
+ document.addEventListener('pointerdown', dismissIfOutside, true);
85
+ return () => document.removeEventListener('pointerdown', dismissIfOutside, true);
86
+ }, [actionsVisible]);
56
87
  const closeMenu = () => setMenuAnchor(null);
57
- const openMenu = (event) => { event.preventDefault(); setMenuAnchor(event.currentTarget); };
88
+ const revealActions = () => setActionsVisible(true);
89
+ const openMenu = (event) => { event.preventDefault(); revealActions(); setMenuAnchor(event.currentTarget); };
58
90
  const startEdit = () => { setDraft(message.body || ''); setEditing(true); closeMenu(); };
91
+ const startReply = () => { onReply === null || onReply === void 0 ? void 0 : onReply(message); closeMenu(); };
92
+ const startReact = () => { setReactionPickerVisible(true); closeMenu(); };
59
93
  const saveEdit = async () => {
60
94
  setSaving(true);
61
95
  setError(null);
@@ -93,5 +127,12 @@ export function MessageBubble({ message, replyTo, conversation, onReply, onJumpT
93
127
  setError(t('MessagingActions.COPY_ERROR', { message: extractApiErrorMessage(copyError) }));
94
128
  }
95
129
  };
96
- return (_jsxs(Paper, { component: "article", variant: "outlined", sx: { p: 1.25, maxWidth: 'min(100%, 680px)', '& .message-actions': { opacity: 0 }, '&:hover .message-actions': { opacity: 1 } }, "aria-label": t('MessagingThread.MESSAGE'), "data-message-id": message.id, onContextMenu: canAct ? openMenu : undefined, children: [_jsxs(Stack, { spacing: 0.75, children: [replyTo && _jsx(Button, { type: "button", variant: "text", size: "small", onClick: () => onJumpToMessage === null || onJumpToMessage === void 0 ? void 0 : onJumpToMessage(replyTo.id), sx: { justifyContent: 'flex-start', px: 0, textAlign: 'left', textTransform: 'none' }, children: _jsxs(Stack, { spacing: 0, alignItems: "flex-start", children: [_jsx(Typography, { variant: "caption", color: "text.secondary", children: t('MessagingThread.REPLY_TO', { sender: senderName(replyTo) || t('MessagingThread.UNKNOWN_SENDER') }) }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: quoteLabel(replyTo, t) })] }) }), _jsxs(Stack, { direction: "row", spacing: 1, alignItems: "baseline", children: [(conversation === null || conversation === void 0 ? void 0 : conversation.kind) !== 'direct' && _jsx(Typography, { variant: "subtitle2", children: senderName(message) || t('MessagingThread.UNKNOWN_SENDER') }), message.created_at && _jsx(Typography, { variant: "caption", color: "text.secondary", children: new Date(message.created_at).toLocaleString() }), message.edited_at && !deleted && _jsx(Typography, { variant: "caption", color: "text.secondary", children: t('MessagingThread.EDITED') })] }), !deleted && message.kind === 'announcement' && message.title && _jsx(Typography, { variant: "subtitle1", fontWeight: 700, children: message.title }), !deleted && message.kind === 'announcement' && message.link_target && onAnnouncementLink && _jsx(Button, { type: "button", size: "small", onClick: () => onAnnouncementLink(message.link_target), children: t('MessagingAnnouncement.OPEN_LINK') }), error && _jsx(Alert, { severity: "error", role: "alert", children: error }), editing ? _jsxs(Stack, { spacing: 0.75, children: [_jsx(TextField, { label: t('MessagingActions.EDIT'), value: draft, onChange: (event) => setDraft(event.target.value), multiline: true, minRows: 2, autoFocus: true, disabled: saving }), _jsxs(Stack, { direction: "row", spacing: 1, children: [_jsx(Button, { type: "button", onClick: saveEdit, disabled: saving, children: t('MessagingActions.SAVE') }), _jsx(Button, { type: "button", onClick: () => setEditing(false), disabled: saving, children: t('MessagingActions.CANCEL') })] })] }) : _jsx(Typography, { sx: { whiteSpace: 'pre-wrap' }, children: deleted ? t('MessagingThread.DELETED') : (message.kind === 'announcement' ? message.body : (message.body || message.title)) || '' }), !deleted && _jsxs(Stack, { spacing: 0.75, children: [attachments.length > 0 && _jsx(AttachmentList, { attachments: attachments }), _jsx(ReactionBar, { message: message }), message.poll && _jsx(PollCard, { message: message })] }), !deleted && onReply && _jsx(Button, { type: "button", size: "small", onClick: () => onReply(message), children: t('MessagingThread.REPLY') }), canAct && _jsx(IconButton, { className: "message-actions", size: "small", "aria-label": t('MessagingActions.MENU'), onClick: openMenu, children: _jsx(MoreVertIcon, {}) }), children] }), _jsxs(Menu, { anchorEl: menuAnchor, open: Boolean(menuAnchor), onClose: closeMenu, children: [_jsx(MenuItem, { onClick: startEdit, children: t('MessagingActions.EDIT') }), _jsx(MenuItem, { onClick: () => { setConfirmingDelete(true); closeMenu(); }, children: t('MessagingActions.DELETE') }), _jsx(MenuItem, { onClick: copyMessage, children: t('MessagingActions.COPY') })] }), _jsxs(Dialog, { open: confirmingDelete, onClose: () => !saving && setConfirmingDelete(false), children: [_jsx(DialogTitle, { children: t('MessagingActions.DELETE_CONFIRM_TITLE') }), _jsx(DialogContent, { children: t('MessagingActions.DELETE_CONFIRM_BODY') }), _jsxs(DialogActions, { children: [_jsx(Button, { onClick: () => setConfirmingDelete(false), disabled: saving, children: t('MessagingActions.CANCEL') }), _jsx(Button, { onClick: confirmDelete, disabled: saving, color: "error", children: t('MessagingActions.DELETE') })] })] })] }));
130
+ return (_jsxs(Box, { ref: bubbleRef, component: "article", "data-message-id": message.id, "data-message-side": isOwn ? 'own' : 'incoming', "aria-label": t('MessagingThread.MESSAGE'), sx: { alignSelf: isOwn ? 'flex-end' : 'flex-start', width: 'fit-content', maxWidth: 'min(75%, 680px)', minWidth: 0 }, children: [_jsxs(Paper, { variant: "outlined", "data-actions-visible": actionsVisible || undefined, onClick: revealActions, onContextMenu: canOpenMenu ? openMenu : undefined, sx: {
131
+ position: 'relative', p: 1.25, minWidth: 0,
132
+ bgcolor: isOwn ? (theme) => alpha(theme.palette.primary.light, 0.24) : 'background.paper',
133
+ borderColor: isOwn ? (theme) => alpha(theme.palette.primary.main, 0.35) : 'divider',
134
+ overflowWrap: 'anywhere',
135
+ '& .message-actions': { opacity: 0, pointerEvents: 'none', transition: 'opacity 120ms ease-in-out' },
136
+ '&:hover .message-actions, &:focus-within .message-actions, &[data-actions-visible="true"] .message-actions': { opacity: 1, pointerEvents: 'auto' },
137
+ }, children: [canOpenMenu && _jsx(IconButton, { className: "message-actions", size: "small", "aria-label": t('MessagingActions.MENU'), onClick: openMenu, sx: { position: 'absolute', top: -12, right: -12, bgcolor: 'background.paper', boxShadow: 1 }, children: _jsx(MoreVertIcon, { fontSize: "small" }) }), _jsxs(Stack, { spacing: 0.75, children: [replyTo && _jsx(ButtonBase, { type: "button", onClick: () => onJumpToMessage === null || onJumpToMessage === void 0 ? void 0 : onJumpToMessage(replyTo.id), sx: { display: 'block', width: '100%', px: 0.75, py: 0.5, textAlign: 'left', borderLeft: 3, borderColor: 'primary.main', bgcolor: 'action.hover', borderRadius: 0.5 }, children: _jsxs(Stack, { spacing: 0, alignItems: "flex-start", children: [_jsx(Typography, { variant: "caption", color: "text.secondary", children: t('MessagingThread.REPLY_TO', { sender: senderName(replyTo) || t('MessagingThread.UNKNOWN_SENDER') }) }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: quoteLabel(replyTo, t) })] }) }), (conversation === null || conversation === void 0 ? void 0 : conversation.kind) !== 'direct' && _jsx(Typography, { variant: "subtitle2", children: senderName(message) || t('MessagingThread.UNKNOWN_SENDER') }), !deleted && message.kind === 'announcement' && message.title && _jsx(Typography, { variant: "subtitle1", fontWeight: 700, children: message.title }), !deleted && message.kind === 'announcement' && message.link_target && onAnnouncementLink && _jsx(Button, { type: "button", size: "small", onClick: () => onAnnouncementLink(message.link_target), children: t('MessagingAnnouncement.OPEN_LINK') }), error && _jsx(Alert, { severity: "error", role: "alert", children: error }), editing ? _jsxs(Stack, { spacing: 0.75, children: [_jsx(TextField, { label: t('MessagingActions.EDIT'), value: draft, onChange: (event) => setDraft(event.target.value), multiline: true, minRows: 2, autoFocus: true, disabled: saving }), _jsxs(Stack, { direction: "row", spacing: 1, children: [_jsx(Button, { type: "button", onClick: saveEdit, disabled: saving, children: t('MessagingActions.SAVE') }), _jsx(Button, { type: "button", onClick: () => setEditing(false), disabled: saving, children: t('MessagingActions.CANCEL') })] })] }) : _jsx(Typography, { sx: { whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }, children: deleted ? t('MessagingThread.DELETED') : (message.kind === 'announcement' ? message.body : (message.body || message.title)) || '' }), !deleted && _jsxs(Stack, { spacing: 0.75, children: [attachments.length > 0 && _jsx(AttachmentList, { attachments: attachments }), message.poll && _jsx(PollCard, { message: message })] }), hasMeta && _jsxs(Stack, { "data-testid": "message-meta", direction: "row", spacing: 0.5, justifyContent: "flex-end", alignItems: "center", sx: { color: 'text.disabled', '& .MuiSvgIcon-root': { fontSize: '1rem' } }, children: [message.created_at && _jsx(Typography, { component: "span", variant: "caption", color: "inherit", children: new Date(message.created_at).toLocaleString() }), message.edited_at && !deleted && _jsx(Typography, { component: "span", variant: "caption", color: "inherit", children: t('MessagingThread.EDITED') }), children] })] })] }), !deleted && (reactions.length > 0 || reactionPickerVisible) && _jsx(ReactionBar, { message: message, expanded: reactionPickerVisible, onExpandedChange: setReactionPickerVisible, sx: { mt: -0.5, ml: 0.75, position: 'relative', zIndex: 1 } }), _jsxs(Menu, { anchorEl: menuAnchor, open: Boolean(menuAnchor), onClose: closeMenu, children: [_jsx(MenuItem, { onClick: startReply, children: t('MessagingThread.REPLY') }), _jsx(MenuItem, { onClick: startReact, children: t('MessagingReactions.ADD') }), canAct && _jsx(MenuItem, { onClick: startEdit, children: t('MessagingActions.EDIT') }), canAct && _jsx(MenuItem, { onClick: () => { setConfirmingDelete(true); closeMenu(); }, children: t('MessagingActions.DELETE') }), _jsx(MenuItem, { onClick: copyMessage, children: t('MessagingActions.COPY') })] }), _jsxs(Dialog, { open: confirmingDelete, onClose: () => !saving && setConfirmingDelete(false), children: [_jsx(DialogTitle, { children: t('MessagingActions.DELETE_CONFIRM_TITLE') }), _jsx(DialogContent, { children: t('MessagingActions.DELETE_CONFIRM_BODY') }), _jsxs(DialogActions, { children: [_jsx(Button, { onClick: () => setConfirmingDelete(false), disabled: saving, children: t('MessagingActions.CANCEL') }), _jsx(Button, { onClick: confirmDelete, disabled: saving, color: "error", children: t('MessagingActions.DELETE') })] })] })] }));
97
138
  }
@@ -1,22 +1,20 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import AddReactionOutlinedIcon from '@mui/icons-material/AddReactionOutlined';
3
- import { Alert, Chip, IconButton, Stack } from '@mui/material';
2
+ import { Alert, Chip, Stack } from '@mui/material';
4
3
  import { useState } from 'react';
5
4
  import { useTranslation } from 'react-i18next';
6
5
  import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
7
6
  export const QUICK_EMOJIS = ['👍', '❤️', '😂', '🎉', '👀'];
8
- /** Independently mountable aggregate reaction controls for one message. */
9
- export function ReactionBar({ message }) {
7
+ /** Compact aggregate reactions; MessageBubble opens the quick picker from its action menu. */
8
+ export function ReactionBar({ message, expanded = false, onExpandedChange, sx }) {
10
9
  const { t } = useTranslation();
11
10
  const { toggleReaction } = useMessaging();
12
- const [expanded, setExpanded] = useState(false);
13
11
  const [error, setError] = useState(null);
14
12
  // Tracks in-flight emoji toggles for this message so a rapid double-click
15
13
  // can't fire two concurrent requests for the same emoji — toggleReaction's
16
14
  // rollback-on-failure reverts to a pre-optimistic snapshot, which would
17
15
  // silently discard a second, already-succeeded change if both were in
18
- // flight at once. Disabling the chip while its own request is pending
19
- // removes the race instead of trying to make concurrent toggles safe.
16
+ // flight. Disabling the chip while its own request is pending removes the
17
+ // race instead of trying to make concurrent toggles safe.
20
18
  const [pending, setPending] = useState(() => new Set());
21
19
  const reactions = (message === null || message === void 0 ? void 0 : message.reactions) || [];
22
20
  const react = async (emoji, active = false) => {
@@ -34,11 +32,11 @@ export function ReactionBar({ message }) {
34
32
  setPending((current) => { const next = new Set(current); next.delete(emoji); return next; });
35
33
  }
36
34
  };
37
- return _jsxs(Stack, { spacing: 0.5, "aria-label": t('MessagingReactions.LABEL'), children: [_jsxs(Stack, { direction: "row", spacing: 0.5, flexWrap: "wrap", children: [reactions.map((reaction) => _jsx(Chip, { size: "small", disabled: pending.has(reaction.emoji), color: reaction.reacted ? 'primary' : 'default', label: `${reaction.emoji} ${reaction.count || 0}`, onClick: () => react(reaction.emoji, Boolean(reaction.reacted)), "aria-label": t('MessagingReactions.TOGGLE', { emoji: reaction.emoji, count: reaction.count || 0 }) }, reaction.emoji)), _jsx(IconButton, { size: "small", "aria-label": t('MessagingReactions.ADD'), onClick: () => setExpanded((value) => !value), children: _jsx(AddReactionOutlinedIcon, { fontSize: "small" }) })] }), expanded && _jsx(Stack, { direction: "row", spacing: 0.25, flexWrap: "wrap", children: QUICK_EMOJIS.map((emoji) => {
35
+ return _jsxs(Stack, { spacing: 0.5, "aria-label": t('MessagingReactions.LABEL'), sx: sx, children: [reactions.length > 0 && _jsx(Stack, { direction: "row", spacing: 0.5, flexWrap: "wrap", children: reactions.map((reaction) => _jsx(Chip, { size: "small", disabled: pending.has(reaction.emoji), color: reaction.reacted ? 'primary' : 'default', label: `${reaction.emoji} ${reaction.count || 0}`, onClick: () => react(reaction.emoji, Boolean(reaction.reacted)), "aria-label": t('MessagingReactions.TOGGLE', { emoji: reaction.emoji, count: reaction.count || 0 }) }, reaction.emoji)) }), expanded && _jsx(Stack, { direction: "row", spacing: 0.25, flexWrap: "wrap", children: QUICK_EMOJIS.map((emoji) => {
38
36
  // A quick-emoji already reflected in the aggregate row must toggle it
39
37
  // off, not re-add it — otherwise clicking an already-reacted emoji here
40
38
  // optimistically double-increments the count before the API call.
41
39
  const existing = reactions.find((reaction) => reaction.emoji === emoji);
42
- return _jsx(Chip, { size: "small", disabled: pending.has(emoji), color: (existing === null || existing === void 0 ? void 0 : existing.reacted) ? 'primary' : 'default', label: emoji, onClick: () => { react(emoji, Boolean(existing === null || existing === void 0 ? void 0 : existing.reacted)); setExpanded(false); } }, emoji);
40
+ return _jsx(Chip, { size: "small", disabled: pending.has(emoji), color: (existing === null || existing === void 0 ? void 0 : existing.reacted) ? 'primary' : 'default', label: emoji, onClick: () => { react(emoji, Boolean(existing === null || existing === void 0 ? void 0 : existing.reacted)); onExpandedChange === null || onExpandedChange === void 0 ? void 0 : onExpandedChange(false); } }, emoji);
43
41
  }) }), error && _jsx(Alert, { severity: "error", role: "alert", children: error })] });
44
42
  }
@@ -1,5 +1,7 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { ButtonBase, List, ListItem, Popover, Typography } from '@mui/material';
2
+ import DoneAllOutlinedIcon from '@mui/icons-material/DoneAllOutlined';
3
+ import DoneOutlinedIcon from '@mui/icons-material/DoneOutlined';
4
+ import { ButtonBase, List, ListItem, Popover } from '@mui/material';
3
5
  import { useEffect, useState } from 'react';
4
6
  import { useTranslation } from 'react-i18next';
5
7
  import { useMessaging } from './MessagingProvider';
@@ -19,10 +21,11 @@ export function ReadTicks({ messageId, conversation }) {
19
21
  if (!status)
20
22
  return null;
21
23
  const label = status.all_read ? t('MessagingReadTicks.ALL_READ') : t('MessagingReadTicks.DELIVERED', { count: status.delivered_count || 0 });
24
+ const StatusIcon = status.all_read ? DoneAllOutlinedIcon : DoneOutlinedIcon;
22
25
  // The server never returns DM detail; enforce the same carve-out defensively.
23
26
  const detail = (conversation === null || conversation === void 0 ? void 0 : conversation.kind) === 'direct' ? null : status.recipient_detail;
24
27
  const recipients = (detail === null || detail === void 0 ? void 0 : detail.map((recipient) => recipient.display_name || recipient.username || recipient.user_id).filter(Boolean)) || [];
25
28
  if (!recipients.length)
26
- return _jsx(Typography, { component: "span", variant: "caption", color: "text.secondary", "aria-label": label, children: label });
27
- return _jsxs(_Fragment, { children: [_jsx(ButtonBase, { "aria-label": label, "aria-haspopup": "dialog", "aria-expanded": Boolean(anchorEl), onClick: (event) => setAnchorEl(event.currentTarget), sx: { typography: 'caption', color: 'text.secondary', textDecoration: 'underline' }, children: label }), _jsx(Popover, { open: Boolean(anchorEl), anchorEl: anchorEl, onClose: () => setAnchorEl(null), children: _jsx(List, { "aria-label": t('MessagingReadTicks.RECIPIENTS'), children: recipients.map((recipient) => _jsx(ListItem, { children: recipient }, recipient)) }) })] });
29
+ return _jsx(StatusIcon, { role: "img", "aria-label": label, color: status.all_read ? 'primary' : 'inherit' });
30
+ return _jsxs(_Fragment, { children: [_jsx(ButtonBase, { "aria-label": label, "aria-haspopup": "dialog", "aria-expanded": Boolean(anchorEl), onClick: (event) => setAnchorEl(event.currentTarget), sx: { display: 'inline-flex', color: status.all_read ? 'primary.main' : 'inherit' }, children: _jsx(StatusIcon, { "aria-hidden": "true" }) }), _jsx(Popover, { open: Boolean(anchorEl), anchorEl: anchorEl, onClose: () => setAnchorEl(null), children: _jsx(List, { "aria-label": t('MessagingReadTicks.RECIPIENTS'), children: recipients.map((recipient) => _jsx(ListItem, { children: recipient }, recipient)) }) })] });
28
31
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "2.19.0",
3
+ "version": "2.21.0",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "repository": {
@@ -1,4 +1,4 @@
1
- import { useId, useState } from 'react';
1
+ import { useId, useMemo, useState } from 'react';
2
2
  import {
3
3
  Alert,
4
4
  Button,
@@ -32,6 +32,10 @@ export function DirectMessageLauncher({ candidates = [], scope, onOpen }) {
32
32
  const [starting, setStarting] = useState(false);
33
33
  const [error, setError] = useState(null);
34
34
  const titleId = useId();
35
+ const selectedCandidate = useMemo(
36
+ () => candidates.find((candidate) => candidate.id === selectedId) || null,
37
+ [candidates, selectedId],
38
+ );
35
39
 
36
40
  const showPicker = () => {
37
41
  setSelectedId(null);
@@ -46,9 +50,10 @@ export function DirectMessageLauncher({ candidates = [], scope, onOpen }) {
46
50
  setStarting(true);
47
51
  setError(null);
48
52
  try {
53
+ const effectiveScope = selectedCandidate?.scope ?? scope;
49
54
  const conversation = await openDirectConversation({
50
55
  target_user_id: selectedId,
51
- ...(scope == null ? {} : { scope }),
56
+ ...(effectiveScope == null ? {} : { scope: effectiveScope }),
52
57
  });
53
58
  setOpen(false);
54
59
  onOpen?.(conversation);
@@ -1,6 +1,7 @@
1
- import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Menu, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material';
1
+ import { Alert, Box, Button, ButtonBase, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Menu, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material';
2
2
  import MoreVertIcon from '@mui/icons-material/MoreVert';
3
- import { useEffect, useState } from 'react';
3
+ import { alpha } from '@mui/material/styles';
4
+ import { useEffect, useRef, useState } from 'react';
4
5
  import { useTranslation } from 'react-i18next';
5
6
  import { AttachmentList } from './AttachmentList';
6
7
  import { extractApiErrorMessage, useOptionalMessaging } from './MessagingProvider';
@@ -34,19 +35,52 @@ export function MessageBubble({ message, replyTo, conversation, onReply, onJumpT
34
35
  const { currentUser, editMessage, removeMessage } = messaging || {};
35
36
  const deleted = Boolean(message.deleted_at);
36
37
  const attachments = message.attachments || [];
37
- const canAct = !deleted && message.kind !== 'poll' && ((Boolean(currentUser?.id) && message.sender?.id === currentUser?.id) || canModerateMessages);
38
+ const isOwn = Boolean(currentUser?.id) && message.sender?.id === currentUser.id;
39
+ const canAct = !deleted && message.kind !== 'poll' && (isOwn || canModerateMessages);
40
+ const canOpenMenu = !deleted;
41
+ const reactions = message.reactions || [];
42
+ const hasMeta = Boolean(message.created_at || (message.edited_at && !deleted) || children);
38
43
  const [menuAnchor, setMenuAnchor] = useState(null);
44
+ const [actionsVisible, setActionsVisible] = useState(false);
45
+ const [reactionPickerVisible, setReactionPickerVisible] = useState(false);
39
46
  const [editing, setEditing] = useState(false);
40
47
  const [draft, setDraft] = useState(message.body || '');
41
48
  const [confirmingDelete, setConfirmingDelete] = useState(false);
42
49
  const [saving, setSaving] = useState(false);
43
50
  const [error, setError] = useState(null);
51
+ const bubbleRef = useRef(null);
44
52
  useEffect(() => {
45
- if (deleted) { setEditing(false); setConfirmingDelete(false); setMenuAnchor(null); }
53
+ if (deleted) {
54
+ setEditing(false); setConfirmingDelete(false); setMenuAnchor(null); setReactionPickerVisible(false);
55
+ }
46
56
  }, [deleted]);
57
+ // The touch/tap reveal is a one-way state flip with no dismiss path of its
58
+ // own (unlike hover/focus, which un-reveal on mouseleave/blur automatically)
59
+ // — without this, any ordinary tap on the bubble (selecting text, opening a
60
+ // poll option, following the reply quote) would leave the overflow icon
61
+ // permanently visible, contradicting the at-rest "no visible affordance"
62
+ // requirement. Dismiss on any pointerdown outside this message's own DOM
63
+ // subtree (bubbleRef spans the Paper plus the reaction-picker sibling, so
64
+ // interacting with either never counts as "outside"); a pointerdown that
65
+ // lands on the Menu/Dialog portals does count as outside, but that's
66
+ // harmless — they already close themselves via their own handlers on the
67
+ // same interaction.
68
+ useEffect(() => {
69
+ if (!actionsVisible) return undefined;
70
+ const dismissIfOutside = (event) => {
71
+ if (bubbleRef.current && !bubbleRef.current.contains(event.target)) {
72
+ setActionsVisible(false);
73
+ }
74
+ };
75
+ document.addEventListener('pointerdown', dismissIfOutside, true);
76
+ return () => document.removeEventListener('pointerdown', dismissIfOutside, true);
77
+ }, [actionsVisible]);
47
78
  const closeMenu = () => setMenuAnchor(null);
48
- const openMenu = (event) => { event.preventDefault(); setMenuAnchor(event.currentTarget); };
79
+ const revealActions = () => setActionsVisible(true);
80
+ const openMenu = (event) => { event.preventDefault(); revealActions(); setMenuAnchor(event.currentTarget); };
49
81
  const startEdit = () => { setDraft(message.body || ''); setEditing(true); closeMenu(); };
82
+ const startReply = () => { onReply?.(message); closeMenu(); };
83
+ const startReact = () => { setReactionPickerVisible(true); closeMenu(); };
50
84
  const saveEdit = async () => {
51
85
  setSaving(true); setError(null);
52
86
  try { await editMessage?.(message.id, { body: draft.trim() }); setEditing(false); }
@@ -63,35 +97,51 @@ export function MessageBubble({ message, replyTo, conversation, onReply, onJumpT
63
97
  catch (copyError) { setError(t('MessagingActions.COPY_ERROR', { message: extractApiErrorMessage(copyError) })); }
64
98
  };
65
99
  return (
66
- <Paper component="article" variant="outlined" sx={{ p: 1.25, maxWidth: 'min(100%, 680px)', '& .message-actions': { opacity: 0 }, '&:hover .message-actions': { opacity: 1 } }} aria-label={t('MessagingThread.MESSAGE')} data-message-id={message.id} onContextMenu={canAct ? openMenu : undefined}>
67
- <Stack spacing={0.75}>
68
- {replyTo && <Button type="button" variant="text" size="small" onClick={() => onJumpToMessage?.(replyTo.id)} sx={{ justifyContent: 'flex-start', px: 0, textAlign: 'left', textTransform: 'none' }}>
69
- <Stack spacing={0} alignItems="flex-start">
70
- <Typography variant="caption" color="text.secondary">{t('MessagingThread.REPLY_TO', { sender: senderName(replyTo) || t('MessagingThread.UNKNOWN_SENDER') })}</Typography>
71
- <Typography variant="caption" color="text.secondary">{quoteLabel(replyTo, t)}</Typography>
72
- </Stack>
73
- </Button>}
74
- <Stack direction="row" spacing={1} alignItems="baseline">
100
+ <Box ref={bubbleRef} component="article" data-message-id={message.id} data-message-side={isOwn ? 'own' : 'incoming'} aria-label={t('MessagingThread.MESSAGE')} sx={{ alignSelf: isOwn ? 'flex-end' : 'flex-start', width: 'fit-content', maxWidth: 'min(75%, 680px)', minWidth: 0 }}>
101
+ <Paper
102
+ variant="outlined"
103
+ data-actions-visible={actionsVisible || undefined}
104
+ onClick={revealActions}
105
+ onContextMenu={canOpenMenu ? openMenu : undefined}
106
+ sx={{
107
+ position: 'relative', p: 1.25, minWidth: 0,
108
+ bgcolor: isOwn ? (theme) => alpha(theme.palette.primary.light, 0.24) : 'background.paper',
109
+ borderColor: isOwn ? (theme) => alpha(theme.palette.primary.main, 0.35) : 'divider',
110
+ overflowWrap: 'anywhere',
111
+ '& .message-actions': { opacity: 0, pointerEvents: 'none', transition: 'opacity 120ms ease-in-out' },
112
+ '&:hover .message-actions, &:focus-within .message-actions, &[data-actions-visible="true"] .message-actions': { opacity: 1, pointerEvents: 'auto' },
113
+ }}
114
+ >
115
+ {canOpenMenu && <IconButton className="message-actions" size="small" aria-label={t('MessagingActions.MENU')} onClick={openMenu} sx={{ position: 'absolute', top: -12, right: -12, bgcolor: 'background.paper', boxShadow: 1 }}><MoreVertIcon fontSize="small" /></IconButton>}
116
+ <Stack spacing={0.75}>
117
+ {replyTo && <ButtonBase type="button" onClick={() => onJumpToMessage?.(replyTo.id)} sx={{ display: 'block', width: '100%', px: 0.75, py: 0.5, textAlign: 'left', borderLeft: 3, borderColor: 'primary.main', bgcolor: 'action.hover', borderRadius: 0.5 }}>
118
+ <Stack spacing={0} alignItems="flex-start">
119
+ <Typography variant="caption" color="text.secondary">{t('MessagingThread.REPLY_TO', { sender: senderName(replyTo) || t('MessagingThread.UNKNOWN_SENDER') })}</Typography>
120
+ <Typography variant="caption" color="text.secondary">{quoteLabel(replyTo, t)}</Typography>
121
+ </Stack>
122
+ </ButtonBase>}
75
123
  {conversation?.kind !== 'direct' && <Typography variant="subtitle2">{senderName(message) || t('MessagingThread.UNKNOWN_SENDER')}</Typography>}
76
- {message.created_at && <Typography variant="caption" color="text.secondary">{new Date(message.created_at).toLocaleString()}</Typography>}
77
- {message.edited_at && !deleted && <Typography variant="caption" color="text.secondary">{t('MessagingThread.EDITED')}</Typography>}
124
+ {!deleted && message.kind === 'announcement' && message.title && <Typography variant="subtitle1" fontWeight={700}>{message.title}</Typography>}
125
+ {!deleted && message.kind === 'announcement' && message.link_target && onAnnouncementLink && <Button type="button" size="small" onClick={() => onAnnouncementLink(message.link_target)}>{t('MessagingAnnouncement.OPEN_LINK')}</Button>}
126
+ {error && <Alert severity="error" role="alert">{error}</Alert>}
127
+ {editing ? <Stack spacing={0.75}><TextField label={t('MessagingActions.EDIT')} value={draft} onChange={(event) => setDraft(event.target.value)} multiline minRows={2} autoFocus disabled={saving} /><Stack direction="row" spacing={1}><Button type="button" onClick={saveEdit} disabled={saving}>{t('MessagingActions.SAVE')}</Button><Button type="button" onClick={() => setEditing(false)} disabled={saving}>{t('MessagingActions.CANCEL')}</Button></Stack></Stack> : <Typography sx={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>{deleted ? t('MessagingThread.DELETED') : (message.kind === 'announcement' ? message.body : (message.body || message.title)) || ''}</Typography>}
128
+ {!deleted && <Stack spacing={0.75}>
129
+ {attachments.length > 0 && <AttachmentList attachments={attachments} />}
130
+ {message.poll && <PollCard message={message} />}
131
+ </Stack>}
132
+ {hasMeta && <Stack data-testid="message-meta" direction="row" spacing={0.5} justifyContent="flex-end" alignItems="center" sx={{ color: 'text.disabled', '& .MuiSvgIcon-root': { fontSize: '1rem' } }}>
133
+ {message.created_at && <Typography component="span" variant="caption" color="inherit">{new Date(message.created_at).toLocaleString()}</Typography>}
134
+ {message.edited_at && !deleted && <Typography component="span" variant="caption" color="inherit">{t('MessagingThread.EDITED')}</Typography>}
135
+ {children}
136
+ </Stack>}
78
137
  </Stack>
79
- {!deleted && message.kind === 'announcement' && message.title && <Typography variant="subtitle1" fontWeight={700}>{message.title}</Typography>}
80
- {!deleted && message.kind === 'announcement' && message.link_target && onAnnouncementLink && <Button type="button" size="small" onClick={() => onAnnouncementLink(message.link_target)}>{t('MessagingAnnouncement.OPEN_LINK')}</Button>}
81
- {error && <Alert severity="error" role="alert">{error}</Alert>}
82
- {editing ? <Stack spacing={0.75}><TextField label={t('MessagingActions.EDIT')} value={draft} onChange={(event) => setDraft(event.target.value)} multiline minRows={2} autoFocus disabled={saving} /><Stack direction="row" spacing={1}><Button type="button" onClick={saveEdit} disabled={saving}>{t('MessagingActions.SAVE')}</Button><Button type="button" onClick={() => setEditing(false)} disabled={saving}>{t('MessagingActions.CANCEL')}</Button></Stack></Stack> : <Typography sx={{ whiteSpace: 'pre-wrap' }}>{deleted ? t('MessagingThread.DELETED') : (message.kind === 'announcement' ? message.body : (message.body || message.title)) || ''}</Typography>}
83
- {!deleted && <Stack spacing={0.75}>
84
- {attachments.length > 0 && <AttachmentList attachments={attachments} />}
85
- <ReactionBar message={message} />
86
- {message.poll && <PollCard message={message} />}
87
- </Stack>}
88
- {!deleted && onReply && <Button type="button" size="small" onClick={() => onReply(message)}>{t('MessagingThread.REPLY')}</Button>}
89
- {canAct && <IconButton className="message-actions" size="small" aria-label={t('MessagingActions.MENU')} onClick={openMenu}><MoreVertIcon /></IconButton>}
90
- {children}
91
- </Stack>
138
+ </Paper>
139
+ {!deleted && (reactions.length > 0 || reactionPickerVisible) && <ReactionBar message={message} expanded={reactionPickerVisible} onExpandedChange={setReactionPickerVisible} sx={{ mt: -0.5, ml: 0.75, position: 'relative', zIndex: 1 }} />}
92
140
  <Menu anchorEl={menuAnchor} open={Boolean(menuAnchor)} onClose={closeMenu}>
93
- <MenuItem onClick={startEdit}>{t('MessagingActions.EDIT')}</MenuItem>
94
- <MenuItem onClick={() => { setConfirmingDelete(true); closeMenu(); }}>{t('MessagingActions.DELETE')}</MenuItem>
141
+ <MenuItem onClick={startReply}>{t('MessagingThread.REPLY')}</MenuItem>
142
+ <MenuItem onClick={startReact}>{t('MessagingReactions.ADD')}</MenuItem>
143
+ {canAct && <MenuItem onClick={startEdit}>{t('MessagingActions.EDIT')}</MenuItem>}
144
+ {canAct && <MenuItem onClick={() => { setConfirmingDelete(true); closeMenu(); }}>{t('MessagingActions.DELETE')}</MenuItem>}
95
145
  <MenuItem onClick={copyMessage}>{t('MessagingActions.COPY')}</MenuItem>
96
146
  </Menu>
97
147
  <Dialog open={confirmingDelete} onClose={() => !saving && setConfirmingDelete(false)}>
@@ -99,6 +149,6 @@ export function MessageBubble({ message, replyTo, conversation, onReply, onJumpT
99
149
  <DialogContent>{t('MessagingActions.DELETE_CONFIRM_BODY')}</DialogContent>
100
150
  <DialogActions><Button onClick={() => setConfirmingDelete(false)} disabled={saving}>{t('MessagingActions.CANCEL')}</Button><Button onClick={confirmDelete} disabled={saving} color="error">{t('MessagingActions.DELETE')}</Button></DialogActions>
101
151
  </Dialog>
102
- </Paper>
152
+ </Box>
103
153
  );
104
154
  }