@micha.bigler/ui-core-micha 2.20.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
@@ -33,4 +33,5 @@ prefixed ID; never reuse a bare flat number across workstreams.
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
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 | planned | | Envelope + map: [work-orders/MSG-3e.md](work-orders/MSG-3e.md). Driver is jg-ferien MSG-6 (global inbox across all of a user's events; a jg DM is event-scoped, so a flat "everyone I may DM" list carries a different scope per person). Alternative was a ~90-line jg-local copy of the component — rejected under AGENTS.md reuse-before-reimplement; the need is generic to any multi-scope host. **Tier 2** because shared-core is excluded from the Tier-1 fast path regardless of diff size, not because the change is large. Named trap: a naive `scope: candidate.scope ?? scope` inside the object literal would start sending `scope: undefined` and break an existing contract. Release 2.20.0 at WO end is the precondition for jg MSG-6's pin bump. |
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,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.20.0",
3
+ "version": "2.21.0",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "repository": {
@@ -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
  }
@@ -1,5 +1,4 @@
1
- import AddReactionOutlinedIcon from '@mui/icons-material/AddReactionOutlined';
2
- import { Alert, Chip, IconButton, Stack } from '@mui/material';
1
+ import { Alert, Chip, Stack } from '@mui/material';
3
2
  import { useState } from 'react';
4
3
  import { useTranslation } from 'react-i18next';
5
4
 
@@ -7,18 +6,17 @@ import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
7
6
 
8
7
  export const QUICK_EMOJIS = ['👍', '❤️', '😂', '🎉', '👀'];
9
8
 
10
- /** Independently mountable aggregate reaction controls for one message. */
11
- export function ReactionBar({ message }) {
9
+ /** Compact aggregate reactions; MessageBubble opens the quick picker from its action menu. */
10
+ export function ReactionBar({ message, expanded = false, onExpandedChange, sx }) {
12
11
  const { t } = useTranslation();
13
12
  const { toggleReaction } = useMessaging();
14
- const [expanded, setExpanded] = useState(false);
15
13
  const [error, setError] = useState(null);
16
14
  // Tracks in-flight emoji toggles for this message so a rapid double-click
17
15
  // can't fire two concurrent requests for the same emoji — toggleReaction's
18
16
  // rollback-on-failure reverts to a pre-optimistic snapshot, which would
19
17
  // silently discard a second, already-succeeded change if both were in
20
- // flight at once. Disabling the chip while its own request is pending
21
- // removes the race instead of trying to make concurrent toggles safe.
18
+ // flight. Disabling the chip while its own request is pending removes the
19
+ // race instead of trying to make concurrent toggles safe.
22
20
  const [pending, setPending] = useState(() => new Set());
23
21
  const reactions = message?.reactions || [];
24
22
  const react = async (emoji, active = false) => {
@@ -29,17 +27,16 @@ export function ReactionBar({ message }) {
29
27
  catch (reactionError) { setError(t('MessagingReactions.ERROR', { message: extractApiErrorMessage(reactionError) })); }
30
28
  finally { setPending((current) => { const next = new Set(current); next.delete(emoji); return next; }); }
31
29
  };
32
- return <Stack spacing={0.5} aria-label={t('MessagingReactions.LABEL')}>
33
- <Stack direction="row" spacing={0.5} flexWrap="wrap">
30
+ return <Stack spacing={0.5} aria-label={t('MessagingReactions.LABEL')} sx={sx}>
31
+ {reactions.length > 0 && <Stack direction="row" spacing={0.5} flexWrap="wrap">
34
32
  {reactions.map((reaction) => <Chip key={reaction.emoji} 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 })} />)}
35
- <IconButton size="small" aria-label={t('MessagingReactions.ADD')} onClick={() => setExpanded((value) => !value)}><AddReactionOutlinedIcon fontSize="small" /></IconButton>
36
- </Stack>
33
+ </Stack>}
37
34
  {expanded && <Stack direction="row" spacing={0.25} flexWrap="wrap">{QUICK_EMOJIS.map((emoji) => {
38
35
  // A quick-emoji already reflected in the aggregate row must toggle it
39
36
  // off, not re-add it — otherwise clicking an already-reacted emoji here
40
37
  // optimistically double-increments the count before the API call.
41
38
  const existing = reactions.find((reaction) => reaction.emoji === emoji);
42
- return <Chip key={emoji} size="small" disabled={pending.has(emoji)} color={existing?.reacted ? 'primary' : 'default'} label={emoji} onClick={() => { react(emoji, Boolean(existing?.reacted)); setExpanded(false); }} />;
39
+ return <Chip key={emoji} size="small" disabled={pending.has(emoji)} color={existing?.reacted ? 'primary' : 'default'} label={emoji} onClick={() => { react(emoji, Boolean(existing?.reacted)); onExpandedChange?.(false); }} />;
43
40
  })}</Stack>}
44
41
  {error && <Alert severity="error" role="alert">{error}</Alert>}
45
42
  </Stack>;
@@ -1,4 +1,6 @@
1
- import { ButtonBase, List, ListItem, Popover, Typography } from '@mui/material';
1
+ import DoneAllOutlinedIcon from '@mui/icons-material/DoneAllOutlined';
2
+ import DoneOutlinedIcon from '@mui/icons-material/DoneOutlined';
3
+ import { ButtonBase, List, ListItem, Popover } from '@mui/material';
2
4
  import { useEffect, useState } from 'react';
3
5
  import { useTranslation } from 'react-i18next';
4
6
 
@@ -17,9 +19,10 @@ export function ReadTicks({ messageId, conversation }) {
17
19
  }, [getMessageReadStatus, messageId]);
18
20
  if (!status) return null;
19
21
  const label = status.all_read ? t('MessagingReadTicks.ALL_READ') : t('MessagingReadTicks.DELIVERED', { count: status.delivered_count || 0 });
22
+ const StatusIcon = status.all_read ? DoneAllOutlinedIcon : DoneOutlinedIcon;
20
23
  // The server never returns DM detail; enforce the same carve-out defensively.
21
24
  const detail = conversation?.kind === 'direct' ? null : status.recipient_detail;
22
25
  const recipients = detail?.map((recipient) => recipient.display_name || recipient.username || recipient.user_id).filter(Boolean) || [];
23
- if (!recipients.length) return <Typography component="span" variant="caption" color="text.secondary" aria-label={label}>{label}</Typography>;
24
- return <><ButtonBase aria-label={label} aria-haspopup="dialog" aria-expanded={Boolean(anchorEl)} onClick={(event) => setAnchorEl(event.currentTarget)} sx={{ typography: 'caption', color: 'text.secondary', textDecoration: 'underline' }}>{label}</ButtonBase><Popover open={Boolean(anchorEl)} anchorEl={anchorEl} onClose={() => setAnchorEl(null)}><List aria-label={t('MessagingReadTicks.RECIPIENTS')}>{recipients.map((recipient) => <ListItem key={recipient}>{recipient}</ListItem>)}</List></Popover></>;
26
+ if (!recipients.length) return <StatusIcon role="img" aria-label={label} color={status.all_read ? 'primary' : 'inherit'} />;
27
+ return <><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' }}><StatusIcon aria-hidden="true" /></ButtonBase><Popover open={Boolean(anchorEl)} anchorEl={anchorEl} onClose={() => setAnchorEl(null)}><List aria-label={t('MessagingReadTicks.RECIPIENTS')}>{recipients.map((recipient) => <ListItem key={recipient}>{recipient}</ListItem>)}</List></Popover></>;
25
28
  }
@@ -0,0 +1,62 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
5
+
6
+ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options?.count != null ? `${key}:${options.count}` : options?.sender ? `${key}:${options.sender}` : key }) }));
7
+ vi.mock('../src/notifications/realtime', () => ({ useRealtime: () => ({ subscribe: () => () => {}, onReconnect: () => () => {} }) }));
8
+
9
+ import { AuthContext } from '../src/auth/AuthContext';
10
+ import { MessageBubble } from '../src/messaging/MessageBubble';
11
+ import { MessagingProvider } from '../src/messaging/MessagingProvider';
12
+ import { ReadTicks } from '../src/messaging/ReadTicks';
13
+
14
+ const api = () => ({
15
+ getReadStatus: vi.fn().mockResolvedValue({ all_read: false, delivered_count: 2 }),
16
+ getAttachment: vi.fn(), getAttachmentThumbnail: vi.fn(), addReaction: vi.fn(), removeReaction: vi.fn(),
17
+ votePoll: vi.fn(), closePoll: vi.fn(),
18
+ });
19
+ function renderBubble(message, { conversation = { kind: 'group' }, replyTo, onJumpToMessage, child, ownId = 1 } = {}) {
20
+ return render(<AuthContext.Provider value={{ user: { id: ownId } }}><MessagingProvider api={api()} active={false}><MessageBubble message={message} conversation={conversation} replyTo={replyTo} onJumpToMessage={onJumpToMessage}>{child}</MessageBubble></MessagingProvider></AuthContext.Provider>);
21
+ }
22
+
23
+ afterEach(cleanup);
24
+
25
+ describe('compact message bubbles', () => {
26
+ it('keeps the timestamp and own-message status icon inside the bubble and has no standalone plain-message controls', async () => {
27
+ const { container } = renderBubble({ id: 1, body: 'Hallo', sender: { id: 1, display_name: 'Me' }, created_at: '2026-08-02T10:00:00Z' }, { child: <ReadTicks messageId={1} conversation={{ kind: 'group' }} /> });
28
+ const tick = await screen.findByLabelText('MessagingReadTicks.DELIVERED:2');
29
+ const paper = container.querySelector('[data-message-id="1"] .MuiPaper-root');
30
+ expect(paper.contains(tick)).toBe(true);
31
+ expect(paper.querySelector('[data-testid="message-meta"]')).toBeTruthy();
32
+ expect(screen.queryByText('MessagingThread.REPLY')).toBeNull();
33
+ expect(screen.queryByLabelText('MessagingReactions.LABEL')).toBeNull();
34
+ });
35
+
36
+ it('marks own and incoming bubbles differently and only shows a sender in non-direct conversations', () => {
37
+ const { container, rerender } = renderBubble({ id: 2, body: 'Mine', sender: { id: 1, display_name: 'Me' } });
38
+ expect(container.querySelector('[data-message-id="2"]').getAttribute('data-message-side')).toBe('own');
39
+ expect(screen.getByText('Me')).toBeTruthy();
40
+ rerender(<AuthContext.Provider value={{ user: { id: 1 } }}><MessagingProvider api={api()} active={false}><MessageBubble message={{ id: 3, body: 'Theirs', sender: { id: 9, display_name: 'Ava' } }} conversation={{ kind: 'direct' }} /></MessagingProvider></AuthContext.Provider>);
41
+ expect(container.querySelector('[data-message-id="3"]').getAttribute('data-message-side')).toBe('incoming');
42
+ expect(screen.queryByText('Ava')).toBeNull();
43
+ });
44
+
45
+ it('keeps a reply quote clickable inside the bubble', () => {
46
+ const onJumpToMessage = vi.fn();
47
+ const { container } = renderBubble({ id: 5, body: 'Reply', sender: { id: 1 } }, { replyTo: { id: 4, body: 'Quoted words', sender: { display_name: 'Ava' } }, onJumpToMessage });
48
+ fireEvent.click(screen.getByText('Quoted words'));
49
+ expect(onJumpToMessage).toHaveBeenCalledWith(4);
50
+ expect(container.querySelector('[data-message-id="5"] .MuiPaper-root').contains(screen.getByText('Quoted words'))).toBe(true);
51
+ });
52
+
53
+ it('continues to render announcement, poll and attachment content inside the bubble', async () => {
54
+ const { container, rerender } = renderBubble({ id: 6, kind: 'announcement', title: 'Notice', body: 'Update', sender: { id: 1 } });
55
+ expect(container.querySelector('.MuiPaper-root').contains(screen.getByText('Notice'))).toBe(true);
56
+ rerender(<AuthContext.Provider value={{ user: { id: 1 } }}><MessagingProvider api={api()} active={false}><MessageBubble message={{ id: 7, kind: 'poll', body: 'Poll', sender: { id: 1 }, poll: { id: 1, question: 'Where?', options: [], allow_multiple: false } }} conversation={{ kind: 'group' }} /></MessagingProvider></AuthContext.Provider>);
57
+ expect(container.querySelector('.MuiPaper-root').contains(screen.getByText('Where?'))).toBe(true);
58
+ rerender(<AuthContext.Provider value={{ user: { id: 1 } }}><MessagingProvider api={api()} active={false}><MessageBubble message={{ id: 8, body: 'File', sender: { id: 1 }, attachments: [{ id: 1, filename: 'notes.pdf', content_type: 'application/pdf' }] }} conversation={{ kind: 'group' }} /></MessagingProvider></AuthContext.Provider>);
59
+ await waitFor(() => expect(screen.getByText(/MessagingAttachments\.DOWNLOAD/)).toBeTruthy());
60
+ expect(container.querySelector('.MuiPaper-root').contains(screen.getByText(/MessagingAttachments\.DOWNLOAD/))).toBe(true);
61
+ });
62
+ });
@@ -17,8 +17,8 @@ function makeApi() {
17
17
  deleteMessage: vi.fn().mockImplementation(async (id) => ({ id, deleted_at: '2026-07-31T10:01:00Z' })),
18
18
  };
19
19
  }
20
- function renderBubble(message, { userId = 1, canModerateMessages = false, api = makeApi() } = {}) {
21
- const result = render(<AuthContext.Provider value={{ user: { id: userId } }}><MessagingProvider api={api} active={false}><MessageBubble message={message} canModerateMessages={canModerateMessages} /></MessagingProvider></AuthContext.Provider>);
20
+ function renderBubble(message, { userId = 1, canModerateMessages = false, api = makeApi(), onReply } = {}) {
21
+ const result = render(<AuthContext.Provider value={{ user: { id: userId } }}><MessagingProvider api={api} active={false}><MessageBubble message={message} canModerateMessages={canModerateMessages} onReply={onReply} /></MessagingProvider></AuthContext.Provider>);
22
22
  return { ...result, api };
23
23
  }
24
24
  function openActions() { fireEvent.click(screen.getByRole('button', { name: 'MessagingActions.MENU' })); }
@@ -26,19 +26,89 @@ function openActions() { fireEvent.click(screen.getByRole('button', { name: 'Mes
26
26
  afterEach(cleanup);
27
27
 
28
28
  describe('message actions', () => {
29
- it('gates the menu to the author or host-supplied moderator capability, never deleted or poll messages', () => {
29
+ it('offers Reply, React and Copy for incoming messages, adding Edit and Delete only for authors or moderators', () => {
30
30
  const mine = { id: 1, body: 'Mine', sender: { id: 1 } };
31
31
  const theirs = { id: 2, body: 'Theirs', sender: { id: 2 } };
32
- const { rerender } = renderBubble(mine);
32
+ const { unmount } = renderBubble(mine);
33
33
  expect(screen.getByRole('button', { name: 'MessagingActions.MENU' })).toBeTruthy();
34
- rerender(<AuthContext.Provider value={{ user: { id: 1 } }}><MessagingProvider api={makeApi()} active={false}><MessageBubble message={theirs} /></MessagingProvider></AuthContext.Provider>);
35
- expect(screen.queryByRole('button', { name: 'MessagingActions.MENU' })).toBeNull();
36
- rerender(<AuthContext.Provider value={{ user: { id: 1 } }}><MessagingProvider api={makeApi()} active={false}><MessageBubble message={theirs} canModerateMessages /></MessagingProvider></AuthContext.Provider>);
34
+ openActions();
35
+ ['MessagingThread.REPLY', 'MessagingReactions.ADD', 'MessagingActions.EDIT', 'MessagingActions.DELETE', 'MessagingActions.COPY'].forEach((name) => expect(screen.getByRole('menuitem', { name })).toBeTruthy());
36
+ fireEvent.click(screen.getByRole('menuitem', { name: 'MessagingReactions.ADD' }));
37
+ expect(screen.getByLabelText('MessagingReactions.LABEL')).toBeTruthy();
38
+ expect(screen.getByText('👍')).toBeTruthy();
39
+ unmount();
40
+ renderBubble(theirs);
37
41
  expect(screen.getByRole('button', { name: 'MessagingActions.MENU' })).toBeTruthy();
38
- rerender(<AuthContext.Provider value={{ user: { id: 1 } }}><MessagingProvider api={makeApi()} active={false}><MessageBubble message={{ ...mine, deleted_at: '2026-07-31T10:00:00Z' }} /></MessagingProvider></AuthContext.Provider>);
39
- expect(screen.queryByRole('button', { name: 'MessagingActions.MENU' })).toBeNull();
40
- rerender(<AuthContext.Provider value={{ user: { id: 1 } }}><MessagingProvider api={makeApi()} active={false}><MessageBubble message={{ ...mine, kind: 'poll' }} /></MessagingProvider></AuthContext.Provider>);
42
+ openActions();
43
+ ['MessagingThread.REPLY', 'MessagingReactions.ADD', 'MessagingActions.COPY'].forEach((name) => expect(screen.getByRole('menuitem', { name })).toBeTruthy());
44
+ expect(screen.queryByRole('menuitem', { name: 'MessagingActions.EDIT' })).toBeNull();
45
+ expect(screen.queryByRole('menuitem', { name: 'MessagingActions.DELETE' })).toBeNull();
46
+ cleanup();
47
+ renderBubble(theirs, { canModerateMessages: true });
48
+ expect(screen.getByRole('button', { name: 'MessagingActions.MENU' })).toBeTruthy();
49
+ cleanup();
50
+ renderBubble({ ...mine, deleted_at: '2026-07-31T10:00:00Z' });
41
51
  expect(screen.queryByRole('button', { name: 'MessagingActions.MENU' })).toBeNull();
52
+ cleanup();
53
+ renderBubble({ ...mine, kind: 'poll' });
54
+ expect(screen.getByRole('button', { name: 'MessagingActions.MENU' })).toBeTruthy();
55
+ });
56
+
57
+ it('opens the affordance from a tap and lets a keyboard user reveal and operate it', () => {
58
+ const onReply = vi.fn();
59
+ const { container } = renderBubble({ id: 3, body: 'Touch me', sender: { id: 2 } }, { onReply });
60
+ const bubble = container.querySelector('[data-message-id="3"] .MuiPaper-root');
61
+ fireEvent.click(bubble);
62
+ expect(bubble.getAttribute('data-actions-visible')).toBe('true');
63
+ const actions = screen.getByRole('button', { name: 'MessagingActions.MENU' });
64
+ fireEvent.focus(actions);
65
+ fireEvent.click(actions);
66
+ fireEvent.click(screen.getByRole('menuitem', { name: 'MessagingThread.REPLY' }));
67
+ expect(onReply).toHaveBeenCalledWith(expect.objectContaining({ id: 3 }));
68
+ });
69
+
70
+ it('dismisses the tap-revealed affordance again on an outside interaction, but not on a reaction-picker click inside the same bubble', () => {
71
+ const { container } = renderBubble({ id: 3, body: 'Touch me', sender: { id: 2 }, reactions: [{ emoji: '👍', count: 1, reacted: false }] });
72
+ const bubble = container.querySelector('[data-message-id="3"] .MuiPaper-root');
73
+ fireEvent.click(bubble);
74
+ expect(bubble.getAttribute('data-actions-visible')).toBe('true');
75
+ // A pointerdown inside the same message's own reaction row (a sibling of
76
+ // the Paper, still within the article) must not count as "outside" —
77
+ // otherwise every reaction click would prematurely hide the affordance.
78
+ fireEvent.pointerDown(screen.getByText('👍 1'));
79
+ expect(bubble.getAttribute('data-actions-visible')).toBe('true');
80
+ // A pointerdown genuinely outside the message dismisses it — this is the
81
+ // U1 fix: without it, the affordance stayed pinned visible forever after
82
+ // any click that bubbled up to the bubble (Part A's "no visible
83
+ // affordance at rest" requirement).
84
+ fireEvent.pointerDown(document.body);
85
+ expect(bubble.getAttribute('data-actions-visible')).toBeNull();
86
+ });
87
+
88
+ it('keeps each message bubble\'s tap-reveal state independent — revealing one does not dismiss another', () => {
89
+ const api = makeApi();
90
+ const { container } = render(
91
+ <AuthContext.Provider value={{ user: { id: 1 } }}>
92
+ <MessagingProvider api={api} active={false}>
93
+ <MessageBubble message={{ id: 10, body: 'First', sender: { id: 2 } }} />
94
+ <MessageBubble message={{ id: 11, body: 'Second', sender: { id: 2 } }} />
95
+ </MessagingProvider>
96
+ </AuthContext.Provider>
97
+ );
98
+ const bubbleA = container.querySelector('[data-message-id="10"] .MuiPaper-root');
99
+ const bubbleB = container.querySelector('[data-message-id="11"] .MuiPaper-root');
100
+ fireEvent.pointerDown(bubbleA);
101
+ fireEvent.click(bubbleA);
102
+ expect(bubbleA.getAttribute('data-actions-visible')).toBe('true');
103
+ expect(bubbleB.getAttribute('data-actions-visible')).toBeNull();
104
+ // Tapping bubble B is, from bubble A's perspective, an outside
105
+ // interaction — A dismisses, B reveals; the two must never share state.
106
+ // (A real tap fires pointerdown before click; fireEvent.click alone
107
+ // wouldn't exercise the pointerdown-driven dismiss listener.)
108
+ fireEvent.pointerDown(bubbleB);
109
+ fireEvent.click(bubbleB);
110
+ expect(bubbleA.getAttribute('data-actions-visible')).toBeNull();
111
+ expect(bubbleB.getAttribute('data-actions-visible')).toBe('true');
42
112
  });
43
113
 
44
114
  it('edits an author message through the API and auto-cancels when deleted elsewhere', async () => {
@@ -1,7 +1,7 @@
1
1
  // @vitest-environment jsdom
2
2
  import React from 'react';
3
3
  import { afterEach, describe, expect, it, vi } from 'vitest';
4
- import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
4
+ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
5
5
 
6
6
  vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options?.emoji ? `${key}:${options.emoji}:${options.count}` : key }) }));
7
7
 
@@ -29,7 +29,7 @@ function baseApi(overrides = {}) {
29
29
  // a live-frame test must read the message back out of the cache.
30
30
  function ConnectedReactionBar({ messageId }) {
31
31
  const { cache } = useMessaging();
32
- return <ReactionBar message={cache.messages[messageId]} />;
32
+ return <ReactionBar message={cache.messages[messageId]} expanded />;
33
33
  }
34
34
 
35
35
  afterEach(() => { cleanup(); realtimeSubscribers.clear(); });
@@ -54,9 +54,7 @@ describe('reaction frame/REST merge against the real aggregate-only contract', (
54
54
  it('the viewer\'s own optimistic reacted flag survives its own REST confirmation, which returns an aggregate-only shape', async () => {
55
55
  const api = baseApi({ addReaction: vi.fn().mockResolvedValue({ reactions: [{ emoji: '👍', count: 1 }] }) });
56
56
  render(<MessagingProvider api={api} active={false}><ConnectedReactionBar messageId="msg-1" /></MessagingProvider>);
57
- dispatchMessagingFrame({ envelope: 'messaging', type: 'message', event_id: 'seed', conversation_id: 'conv-1', message: { id: 'msg-1', conversation_id: 'conv-1', body: 'Hi', reactions: [] } });
58
- await waitFor(() => screen.getByLabelText('MessagingReactions.ADD'));
59
- fireEvent.click(screen.getByLabelText('MessagingReactions.ADD'));
57
+ act(() => { dispatchMessagingFrame({ envelope: 'messaging', type: 'message', event_id: 'seed', conversation_id: 'conv-1', message: { id: 'msg-1', conversation_id: 'conv-1', body: 'Hi', reactions: [] } }); });
60
58
  fireEvent.click(screen.getByText('👍'));
61
59
  await waitFor(() => expect(api.addReaction).toHaveBeenCalledWith('msg-1', '👍'));
62
60
  await waitFor(() => {
@@ -68,7 +68,8 @@ describe('messaging threading and quoting', () => {
68
68
  api.deleteMessage = vi.fn().mockResolvedValue({});
69
69
  render(<AuthContext.Provider value={{ user: { id: 1 } }}><MessagingProvider api={api} activeConversationId={1}><Thread conversationId={1} /></MessagingProvider></AuthContext.Provider>);
70
70
  await screen.findByText('Reply target');
71
- fireEvent.click(screen.getByRole('button', { name: /MessagingThread\.REPLY\b/ }));
71
+ fireEvent.click(screen.getByRole('button', { name: /MessagingActions\.MENU/ }));
72
+ fireEvent.click(screen.getByRole('menuitem', { name: /MessagingThread\.REPLY\b/ }));
72
73
  expect(screen.getByText(/MessagingThread\.REPLYING_TO/)).toBeTruthy();
73
74
  fireEvent.click(screen.getByRole('button', { name: /MessagingActions\.MENU/ }));
74
75
  fireEvent.click(screen.getByRole('menuitem', { name: /MessagingActions\.DELETE/ }));
@@ -0,0 +1,391 @@
1
+ # MSG-3f — compact chat-bubble message layout
2
+
3
+ Status: planned · Tier 2 · Target repo: `ui-core-micha` (main)
4
+
5
+ ---
6
+
7
+ ## Part A — Envelope (Expertenchat, 2026-08-02)
8
+
9
+ ### Goal
10
+
11
+ Make a message occupy the space a message deserves. Today a one-word message renders as **six stacked
12
+ rows**; it must become **one**.
13
+
14
+ ### The problem
15
+
16
+ `MessageBubble` (`src/messaging/MessageBubble.jsx`) wraps everything in a vertical
17
+ `Stack spacing={0.75}`, so every element gets its own full-width row: the meta line, the body, the
18
+ `ReactionBar`, the reply control (a full MUI `Button`, which is why "Antworten" sits centred on a line of
19
+ its own), the overflow-menu `IconButton`, and finally `children` — where the host's `ReadTicks` lands as
20
+ a "Zugestellt an 0" text line. For the word "Hallo" that is roughly 260px of vertical space.
21
+
22
+ Operator direction: much more compact, icon-based rather than text-labelled, and modelled on WhatsApp.
23
+
24
+ ### "Like WhatsApp" — the actual specification
25
+
26
+ "Like WhatsApp" is not a spec, so here is what it means for this WO. Each point is a requirement.
27
+
28
+ **1. Bubbles.** Own messages right-aligned on a tinted surface; incoming messages left-aligned on a
29
+ neutral surface. The bubble shrinks to its content and is capped at roughly three quarters of the thread
30
+ width — not the current full-width outlined `Paper`. Exact radius, tint, and elevation are design-system
31
+ choices, not fixed here; what is fixed is the alignment split and the shrink-to-content behaviour.
32
+
33
+ **2. Meta moves inside the bubble — this is the line-count fix.** Timestamp and status icons sit
34
+ **inside** the bubble, bottom right, instead of being their own full-width sibling rows. Whether they
35
+ share the last text line or occupy their own narrow trailing line is left to the implementation:
36
+ riding the last line is nicer and welcome if it falls out naturally, but it is **explicitly not
37
+ required** (operator decision, 2026-08-02 — "muss nicht perfekt sein"). Do not spend the WO fighting
38
+ that CSS. What matters is that the meta is inside the bubble and right-aligned, not that it is inline.
39
+
40
+ **3. Sender name.** Only for non-direct conversations, small, above the text, inside the bubble. Direct
41
+ conversations show no sender at all (already the case today, keep it).
42
+
43
+ **4. Status as icons.** Replace the "Zugestellt an N" text with tick icons — sent / delivered /
44
+ all-read, distinguished by tick count and emphasis. Own messages only; the existing
45
+ `canShowReadTicks` gate is correct and stays.
46
+
47
+ **5. Actions collapse into one affordance.** No standalone reply button, no permanently rendered
48
+ reaction row. One overflow affordance at the bubble's top edge opens a single menu carrying Reply,
49
+ React, and the existing Edit / Delete / Copy entries.
50
+
51
+ How it is reached (operator decision, 2026-08-02 — **no long-press**):
52
+ - **Desktop:** the affordance appears on hover, and on keyboard focus. The existing right-click
53
+ `onContextMenu` path stays.
54
+ - **Touch:** a plain tap on the bubble reveals the affordance. No long-press, no swipe, no custom
55
+ pointer-event handling — the gesture edge cases (scroll conflict, text selection, iOS's own
56
+ context menu) are not worth their cost here.
57
+ - **React** is a menu entry that expands the existing `ReactionBar` emoji row. Do **not** try to nest an
58
+ emoji picker inside the MUI `Menu` — MUI does not nest menus gracefully, and the existing expandable
59
+ row already works.
60
+
61
+ **5b. The menu must open for messages the user cannot edit.** Today the whole menu is gated on `canAct`
62
+ (own message, or moderator), because it only contained Edit / Delete / Copy. Once Reply lives there, an
63
+ **incoming** message needs a menu too — you reply to other people's messages. So there are two variants:
64
+ any message offers Reply / React / Copy; a message the user may act on additionally offers Edit /
65
+ Delete. Getting this wrong makes replying to anyone else impossible, and it will not show up in a test
66
+ fixture that only contains own messages.
67
+
68
+ **6. Reactions.** Existing reaction chips render as a compact row overlapping the bubble's bottom edge,
69
+ not as their own stacked row. Adding a reaction moves into the action menu.
70
+
71
+ **7. Reply quote.** Stays inside the bubble as a tinted block with a coloured leading border, replacing
72
+ today's full-width text `Button`. Clicking it must still jump to the quoted message.
73
+
74
+ ### Confirmed against operator-supplied reference screenshots (2026-08-02)
75
+
76
+ The operator supplied two WhatsApp screenshots as the reference. What they pin down beyond the points
77
+ above:
78
+
79
+ - In the reference, a two-line message has its timestamp at the right end of the *second* line — so
80
+ WhatsApp's own rule is "meta rides the final line". Per point 2 this is **not** binding here; it is
81
+ recorded so the implementation knows what the ideal looks like if it comes cheaply.
82
+ - **Incoming messages carry no status icon at all** — not even a "sent" tick. Only own messages show
83
+ status.
84
+ - **At rest there is no visible action affordance whatsoever.** No reply control, no reaction button, no
85
+ overflow glyph. The bubble is text plus meta and nothing else until the user hovers or long-presses.
86
+ - **The meta is visually recessed**: noticeably smaller than the body and in a lower-contrast grey; the
87
+ read state is carried by colour on the ticks, not by the timestamp.
88
+ - Bubbles shrink to content — the short incoming message's bubble is markedly narrower than the long
89
+ one's, confirming that the width cap is a maximum and not a fixed width.
90
+
91
+ A bubble tail/notch is visible in the reference. Treat it as optional design-system garnish, not a
92
+ requirement of this WO.
93
+
94
+ ### Acceptance criterion
95
+
96
+ A message with short text, no reactions, no attachments and no reply quote occupies **at most two visual
97
+ lines** — the text and, at most, a narrow right-aligned meta line inside the bubble. One line if the meta
98
+ lands inline without a fight. Today's six rows for that case is what this WO exists to kill; anything
99
+ still costing a third line has not met it.
100
+
101
+ ### Non-negotiable: the icons must keep their accessible names
102
+
103
+ Replacing text with icons is exactly how a UI becomes unusable with a screen reader. Every icon
104
+ introduced by this WO carries the text it replaced as its accessible name — the status icons keep the
105
+ existing `MessagingReadTicks.DELIVERED` / `ALL_READ` strings as `aria-label`, the action affordance keeps
106
+ a label, and no i18n key is deleted because its text stopped being displayed. A visually compact UI that
107
+ announces "button" to a screen reader is a regression, not a redesign.
108
+
109
+ Second, related trap: today's overflow menu is hover-only (`'& .message-actions': { opacity: 0 }`), which
110
+ makes it unreachable on touch **and** invisible to keyboard users. This WO must not carry that forward.
111
+ The affordance must be focusable and operable by keyboard. Note that `opacity: 0` does **not** remove an
112
+ element from the tab order — it is only invisible — so a `:focus-within` (or `:focus-visible`) rule that
113
+ reveals it on keyboard focus closes the keyboard half cheaply. The touch half is point 5's tap-to-reveal.
114
+ That this is a pre-existing defect does not make it acceptable to reproduce.
115
+
116
+ ### Scope
117
+
118
+ `src/messaging/MessageBubble.jsx` primarily; `src/messaging/ReadTicks.jsx` (text → icon),
119
+ `src/messaging/ReactionBar.jsx` (compact placement, add-reaction moves into the menu), and the small part
120
+ of `src/messaging/Thread.jsx` that positions bubbles and passes `children`. Their tests, the messaging
121
+ i18n table if new labels are genuinely needed, and the `dev/` harness entry so the result can actually be
122
+ looked at.
123
+
124
+ ### Non-goals / do-not-touch
125
+
126
+ - **No custom touch gestures at all** — no swipe-to-reply and, per point 5, **no long-press**. Both need
127
+ hand-rolled pointer handling whose real cost is the edge cases (scroll conflict, text selection, iOS's
128
+ own context menu), and neither is needed for "compact". Operator decision 2026-08-02. If either is
129
+ wanted later it is its own WO.
130
+ - **No Composer change.** The screenshot that triggered this also shows a tall composer, but that is not
131
+ what was asked. Raise it separately rather than folding it in.
132
+ - **No change to `Thread`'s data flow**, pagination, reply-thread expansion, unread-reply marker
133
+ (MSG-3d), or the read-status contract.
134
+ - **No dcm change.** See the read-status note below.
135
+ - **No change to what `MessageBubble` accepts or renders as `children`** — hosts pass `ReadTicks` through
136
+ it today and must keep working.
137
+ - No new dependency. MUI plus the existing icon set is enough.
138
+
139
+ ### Read status — an observation, deliberately NOT a precondition
140
+
141
+ `ReadTicks` fetches per message (`getMessageReadStatus` in a `useEffect`), and dcm's `serialize_message`
142
+ carries no read state, so a thread fires one REST call per own message. Tick icons make that fan-out more
143
+ visible but do **not** worsen it: `canShowReadTicks` already bounds it to own messages, and the code
144
+ comments that bound as deliberate. Putting the delivered/read aggregate onto `serialize_message` — the
145
+ way `reply_count`/`last_reply_at` already ride it since MSG-3d, and viewer-independent in the same way —
146
+ would remove the fan-out entirely. That is a worthwhile dcm follow-up and should be raised as one, but
147
+ gating a UI fix behind a dcm release is the wrong trade. **Do not implement it here; do report it.**
148
+
149
+ ### Tier
150
+
151
+ **Tier 2.** Shared-core, and this is an opinionated visual decision imposed on every future consumer of
152
+ ucm's messaging surface — see the cross-consumer note. Codex-first, mandatory independent review, plus a
153
+ `ui_reviewer` pass: this WO is entirely about how something looks and the harness exists to look at it.
154
+
155
+ ### Cross-consumer note
156
+
157
+ ucm is consumer-agnostic by design, and this WO commits it to a specific chat aesthetic. That is
158
+ acceptable **now** because jg-ferien is currently the only messaging consumer (spesix is deferred per the
159
+ messaging-centralization course correction), so the design lands with one adopter rather than being
160
+ retrofitted onto several. Say so in the register row rather than letting a future consumer discover it.
161
+ What must **not** happen is jg-specific vocabulary or layout assumptions leaking into the package — the
162
+ same line MSG-3d drew for `external_key` applies here.
163
+
164
+ ### Risks
165
+
166
+ - **Do not get drawn into the inline-meta CSS.** Placing timestamp and ticks on the last text line, and
167
+ wrapping cleanly when the text ends near the edge, is a known-awkward problem needing a reserved inline
168
+ spacer or a floated meta element. Point 2 deliberately does not require it. If a first attempt does not
169
+ fall out cleanly, take the trailing meta line and move on — this risk is listed to be avoided, not
170
+ managed.
171
+ - **Long text, long words, RTL and zoom.** A shrink-to-content bubble with a right-aligned meta
172
+ breaks in interesting ways with a single unbroken 60-character token, at 200% zoom, and in narrow
173
+ mobile widths. Check those three explicitly.
174
+ - **Losing an action.** Reply, React, Edit, Delete and Copy all currently exist. Collapsing them into one
175
+ menu must not quietly drop one — particularly React, which is moving from a permanent control into the
176
+ menu and is the easiest to forget.
177
+ - **Announcements and polls.** `MessageBubble` also renders announcement titles, announcement link
178
+ buttons, poll cards and attachment lists. A bubble redesign that only considers plain text will make
179
+ those look broken. Each needs a look in the harness.
180
+
181
+ ### Tests to WRITE (scoped — run only these)
182
+
183
+ - a short own message renders its timestamp and status **inside** the bubble, not as a sibling row (the
184
+ acceptance criterion, asserted structurally rather than by pixel — do not assert that the meta shares
185
+ the text's line, which point 2 leaves open);
186
+ - the action menu opens for an **incoming** message and offers Reply, and offers Edit/Delete only where
187
+ `canAct` holds (point 5b — a fixture containing only own messages will not catch this);
188
+ - the affordance is reachable on touch without a long press;
189
+ - no standalone reply button and no permanently rendered reaction row exist in the DOM for a plain
190
+ message;
191
+ - own and incoming messages are distinguishable (alignment/styling hook), and the sender name renders for
192
+ a group conversation but not a direct one;
193
+ - the status icon exposes the same accessible name the text label used, and appears only on own messages;
194
+ - the action affordance is reachable and operable by keyboard alone — no hover, no long press;
195
+ - the action menu contains Reply, React, Edit, Delete and Copy for a message the user may act on, and the
196
+ reply quote still triggers `onJumpToMessage`;
197
+ - an announcement, a poll and an attachment message each still render their existing content inside the
198
+ new bubble.
199
+
200
+ Command: `pnpm vitest run tests/Thread.test.jsx tests/messagingMessageActions.test.jsx tests/messagingInteractions.test.jsx tests/messagingPollRendering.test.jsx` plus any new file. The existing messaging tests are the regression net — if several of them need rewriting, that is a signal the change went wider than the envelope, and it should be reported before proceeding.
201
+
202
+ ### Release
203
+
204
+ One version bump + npm publish at WO end. jg-ferien bumps its pin afterwards to pick the design up; that
205
+ pin bump is not part of this WO.
206
+
207
+ ### Preconditions
208
+
209
+ None. Independent of `MSG-3e` (per-candidate scope in `DirectMessageLauncher`) — different files, either
210
+ order, but if both are in flight coordinate the version bumps so two releases don't collide.
211
+
212
+ ### Approval Gate #1
213
+
214
+ Granted by the operator 2026-08-02: compact, icon-based, chat bubbles, WhatsApp as the model, across all
215
+ three design questions asked.
216
+
217
+ ### Mini-handover (pastable)
218
+
219
+ Orchestrator: implement `work-orders/MSG-3f.md` in `ui-core-micha` (main). `git pull` first, read the WO,
220
+ then follow `orchestrate-codex` (write Part B yourself, Codex-first, own independent review plus a
221
+ `ui_reviewer` pass on the harness, publish at WO end). **The acceptance criterion is one visual line for a
222
+ short message — check it before declaring done.**
223
+
224
+ ---
225
+
226
+ ## Part B — Implementation map (Orchestrator)
227
+
228
+ **Target repo working directory:** `C:\Users\Micha Bigler\Documents\webapps\ui-core-micha` (repo root; main
229
+ branch). Work from this package; open only the named files to verify.
230
+
231
+ ### Current shape (verified while scoping — do not re-derive)
232
+
233
+ **`src/messaging/MessageBubble.jsx`** (105 lines) — one `Paper` (`component="article"`, `:66`) wrapping a
234
+ single `Stack spacing={0.75}` (`:67`) with, in row order: reply-quote `Button` (`:68-73`, only if `replyTo`),
235
+ meta row — sender name (`conversation?.kind !== 'direct'`, `:75`) + `created_at` + "edited" caption
236
+ (`:74-78`), announcement title/link (`:79-80`), error `Alert` (`:81`), body/edit-`TextField` (`:82`),
237
+ `!deleted` wrapper with `AttachmentList` + `<ReactionBar message={message} />` + `PollCard` (`:83-87`),
238
+ standalone "Antworten" reply `Button` (`:88`, **not** inside the existing `Menu` — reply is currently
239
+ ungated by `canAct`, unlike edit/delete/copy), overflow `IconButton` (`:89`, `className="message-actions"`,
240
+ `canAct`-gated), `{children}` (`:90` — this is where `Thread.jsx` injects `<ReadTicks>`, currently its own
241
+ trailing row, not "inside the bubble"), then the edit/delete/copy `Menu` (`:92-96`) and delete-confirm
242
+ `Dialog` (`:97-101`).
243
+
244
+ - `canAct` (`:35-37`): `!deleted && message.kind !== 'poll' && ((Boolean(currentUser?.id) &&
245
+ message.sender?.id === currentUser?.id) || canModerateMessages)` — gates both `onContextMenu` (`:66`) and
246
+ the overflow `IconButton`. **No existing `isOwn`/alignment boolean** — the own-message check is inlined
247
+ into `canAct` only; introduce a named `isOwn` for the alignment split (same condition, without the
248
+ `!deleted`/`kind !== 'poll'` parts that are specific to action-gating, not alignment).
249
+ - Hover-only CSS trap (`:66`, inline `sx` on the `Paper`): `'& .message-actions': { opacity: 0 },
250
+ '&:hover .message-actions': { opacity: 1 }` — pure `:hover`, no `:focus-within`/`:focus-visible` variant,
251
+ no touch/tap path. This is the exact defect the WO's "Non-negotiable" section calls out — fix it here
252
+ (add a focus-visible rule; touch is a tap-to-reveal state, not CSS-only).
253
+ - `Thread.jsx:103` and `:113` are the two `<MessageBubble>` mount sites (root messages and nested replies
254
+ respectively), both passing `children={canShowReadTicks(message, user) && <ReadTicks .../>}`.
255
+ `canShowReadTicks` (`Thread.jsx:25-27`) duplicates (more strictly — adds a pending-optimistic-row guard)
256
+ the same own-message check as `canAct` — a third near-duplicate inline check. Consider factoring these
257
+ three (MessageBubble's `canAct`, Thread's `canShowReadTicks`, the new alignment `isOwn`) toward one
258
+ shared own-message predicate if it falls out cleanly; not required if it adds risk late in the WO.
259
+ - Direct-vs-group signal: `conversation?.kind !== 'direct'`, exactly this string comparison, reused
260
+ identically at `MessageBubble.jsx:75` and `ReadTicks.jsx:21` — no enum, just keep using it.
261
+
262
+ **`src/messaging/ReadTicks.jsx`** (25 lines) — fetch-per-message `useEffect` (`:13-17`,
263
+ `getMessageReadStatus(messageId)` from `useMessaging()`). Only **two** states exist in the data today:
264
+ `status.all_read` (bool) vs `status.delivered_count` (number) — **there is no third "sent, not yet
265
+ delivered" state** in the current contract; do not invent one that the backend can't back. Map the two
266
+ existing states to icon variants (e.g. a single check for "delivered", a double/colored check for
267
+ "all read") — do not add a third tick state without a data source. `status.recipient_detail` (only present
268
+ for non-direct conversations, `:21` comment already documents the DM carve-out) drives the existing
269
+ clickable popover of names (`:24`) — keep that behavior, just change what triggers it from a text caption to
270
+ an icon (`aria-haspopup="dialog"` must move with it). i18n keys already exist and are reusable as
271
+ `aria-label`s: `MessagingReadTicks.ALL_READ`, `.DELIVERED` (`{{count}}`), `.RECIPIENTS`.
272
+
273
+ **`src/messaging/ReactionBar.jsx`** (47 lines) — currently its own sibling row (`Stack`, `:32`), mounted
274
+ inside MessageBubble's `!deleted` wrapper (`MessageBubble.jsx:85`). **Add-reaction already exists and is
275
+ reusable as-is**: `:35`, an `IconButton` (`AddReactionOutlinedIcon`, `aria-label={t('MessagingReactions.ADD')}`)
276
+ toggling local `expanded` state; when expanded, `:37-43` renders `QUICK_EMOJIS`
277
+ (`['👍','❤️','😂','🎉','👀']`, `:8`) as `Chip`s — this is "the existing expandable row" the WO says already
278
+ works; reuse it verbatim inside the action menu's React entry, don't rebuild it. `ReactionBar` takes only
279
+ `message` as a prop today — no position/z-index hook back to the parent; if overlapping the bubble's bottom
280
+ edge needs coordination (e.g. negative margin colliding with bubble padding), that's a MessageBubble-side
281
+ CSS concern, only touch `ReactionBar`'s own layout if the compact-row placement itself needs to change (it
282
+ likely does — from a full sibling row to a smaller overlapping one).
283
+
284
+ ### The new bubble shape — map the seams, not the CSS
285
+
286
+ This WO is explicitly NOT asking for the inline-meta-shares-last-line CSS to be solved (Risks section, Part
287
+ A) — implement the straightforward version (meta as its own narrow trailing line inside the bubble,
288
+ right-aligned) and stop; do not spend cycles chasing the inline variant.
289
+
290
+ 1. **Alignment split**: introduce `isOwn` (own-message boolean, computed once, see above). Own → bubble
291
+ right-aligned on a tinted `Paper`; incoming → left-aligned, neutral surface. Shrink-to-content, capped at
292
+ ~75% of thread width (today's `maxWidth: 'min(100%, 680px)'`, `:66`, is a fixed cap regardless of
293
+ alignment — replace with a percentage-based cap and keep `min(...)` against a sane absolute ceiling for
294
+ very wide threads).
295
+ 2. **Meta moves inside**: move the timestamp + status-tick block from its own row into a small trailing
296
+ `Stack` inside the bubble's content area, right-aligned, "recessed" styling (smaller, lower-contrast —
297
+ Part A's confirmed-against-screenshots section). Sender name stays where it is conceptually (above text,
298
+ inside the bubble) but only for `conversation?.kind !== 'direct'` (unchanged condition).
299
+ 3. **Status as icons**: `ReadTicks.jsx` swaps its two `Typography` text branches for icon components
300
+ (`Done`/`DoneAll` from `@mui/icons-material`, following the repo's `Outlined`-variant convention where one
301
+ exists — check availability before picking exact icon names) with the existing i18n strings as
302
+ `aria-label`, own-messages-only (unchanged `canShowReadTicks` gate in `Thread.jsx`, do not touch it).
303
+ 4. **Actions collapse into one menu**: move the standalone reply `Button` (`:88`) INTO the existing `Menu`
304
+ (`:92-96`) as a new first entry, alongside React (opens `ReactionBar`'s existing `expanded` row — do not
305
+ nest an emoji picker inside the MUI `Menu`, per Part A) and the existing Edit/Delete/Copy entries. The
306
+ overflow `IconButton` (`:89`) becomes the single affordance — reuse the existing `MoreVertIcon` (already
307
+ the established overflow icon in this repo, `ConversationList.jsx` too), don't introduce a different one.
308
+ 5. **Two menu variants (5b — the easy-to-miss one)**: any message (own or incoming) gets Reply + React +
309
+ Copy; only a message where `canAct`-minus-the-reply-specific-parts holds (i.e. own message or moderator,
310
+ the existing author/moderator check) additionally gets Edit + Delete. This means the menu's visibility
311
+ condition changes from "`canAct` gates the whole menu" to "the menu is always offered when there's
312
+ anything to put in it (any message), but its CONTENTS vary" — restructure the render condition
313
+ accordingly; a fixture with only own messages will not catch a regression here (Part A's own warning).
314
+ 6. **Desktop hover+focus, touch tap**: fix the CSS trap directly — add a `&:focus-within .message-actions`
315
+ (or `:focus-visible`) rule alongside the existing `:hover` rule so keyboard Tab-focus reveals the
316
+ affordance (the element is already in the tab order, per Part A's analysis — `opacity:0` doesn't remove
317
+ it). For touch, add a tap-to-reveal state (e.g. local `actionsVisible` state toggled by a tap handler on
318
+ the bubble, independent of hover/focus) — no long-press, no swipe, no custom pointer-event tracking.
319
+ 7. **Reactions compact row**: `ReactionBar`'s existing-reactions row (not the add-reaction affordance, which
320
+ moves into the menu per point 4) becomes a compact row overlapping the bubble's bottom edge rather than a
321
+ full sibling Stack row — likely a negative top margin pulling it up into the bubble's bottom padding, or
322
+ an absolutely-positioned row anchored to the bubble. `ReactionBar`'s own `expanded`/`QUICK_EMOJIS` block
323
+ stays as-is; only its trigger changes (moves from `ReactionBar`'s own `AddReactionOutlinedIcon` button —
324
+ now redundant, since React lives in the menu — but the `expanded` row itself is unchanged).
325
+ 8. **Reply quote**: keep inside the bubble but restyle from a full-width `Button` (`:68-73`) to a tinted
326
+ block with a coloured leading border (e.g. `borderLeft`) — must still call `onJumpToMessage` on click
327
+ (existing prop, keep the click handler, only restyle the container).
328
+ 9. **Announcements/polls/attachments** (`:79-80`, `:83-87`): keep rendering inside the new bubble container;
329
+ check each in the harness (Part A's Risks section) since none of the four scoped test files assert on
330
+ their visual placement, only their data/behavior.
331
+
332
+ ### Verification harness — extend, don't just eyeball the diff
333
+
334
+ `dev/entries.jsx:140-157` already has a `messaging-message-bubble` entry, but it mounts a single bubble with
335
+ no `conversation` prop, no `currentUser` (so `canAct` is always false — no own-message demo), and no
336
+ `replyTo`/`children`/ReadTicks wired in — **insufficient to visually verify this WO's acceptance criterion**.
337
+ Either extend this entry with a currentUser matching one message's `sender.id` (for an own-message demo)
338
+ plus a second incoming-message instance, a `replyTo` prop, and mock `ReadTicks` children — or, likely
339
+ cleaner, extend the existing `messaging-thread` entry (`dev/entries.jsx:156`, `ThreadEntry` against
340
+ `messagingHarnessApi`) which already exercises the real Thread→MessageBubble wiring with realistic
341
+ `sender`/`reply_count`/replies data. Add fixture messages covering: a short own message (acceptance
342
+ criterion — one/two lines), a short incoming message, a long-single-unbroken-token message, a message at
343
+ 200% zoom (use the harness's viewport toggle), a group-conversation message (sender name visible) vs a
344
+ direct one (no sender name), an announcement, a poll, and an attachment. This is the actual verification
345
+ step — visually confirm the acceptance criterion before reporting `RESULT: DONE`.
346
+
347
+ ### Invariants / do-not-touch
348
+
349
+ - No Composer change (a different component, out of scope even though the triggering screenshot showed one).
350
+ - No change to `Thread.jsx`'s data flow, pagination, reply-thread expansion, unread-reply marker (MSG-3d),
351
+ or the read-status REST contract (`getMessageReadStatus` shape stays exactly as-is — two states, no new
352
+ backend call).
353
+ - No dcm change — the fan-out observation (one `getMessageReadStatus` call per own message) is a known,
354
+ pre-existing, and deliberately bounded cost; report the read-aggregate-on-`serialize_message` idea as a
355
+ dcm follow-up suggestion in the WO's own docs, do not implement it.
356
+ - `MessageBubble`'s `children` contract stays: hosts (jg-ferien today) pass `ReadTicks` through it and must
357
+ keep working unchanged from the outside.
358
+ - No new dependency — MUI plus `@mui/icons-material` is enough (both already used throughout `src/messaging/`).
359
+ - Every icon this WO introduces keeps its text as an `aria-label` — no i18n key gets deleted because its
360
+ text stopped being visually displayed (Part A's "Non-negotiable" section).
361
+
362
+ ### Tests — see Part A's "Tests to WRITE" list verbatim
363
+
364
+ Run: `pnpm vitest run tests/Thread.test.jsx tests/messagingMessageActions.test.jsx
365
+ tests/messagingInteractions.test.jsx tests/messagingPollRendering.test.jsx` plus any new file for the new
366
+ assertions (own/incoming alignment, menu-for-incoming-message, keyboard/touch affordance reachability,
367
+ icon aria-labels). These four existing files assert on data/behavior (menu gating, reaction round-trips,
368
+ poll rendering, ReadTicks own-message-only mounting), not DOM layout — good news for a redesign, but per
369
+ Part A: **if several existing tests need rewriting (not just relocating an assertion), that's a signal the
370
+ change went wider than the envelope — stop and report before proceeding**, don't just patch tests to match
371
+ whatever the implementation produced.
372
+
373
+ ### Progress contract
374
+
375
+ Emit a `PLAN: …` line first, then a single-line `PROGRESS: [n/total] <action>` before every relevant action
376
+ and `… done` on completion, no gap > ~2 min, unbuffered stdout, and exactly one final
377
+ `RESULT: DONE|BLOCKED <reason>`.
378
+
379
+ ### Constraints for the implementer
380
+
381
+ The WO text (Part A + this Part B) is the COMPLETE spec. Read `AGENTS.md` and
382
+ `.codex/skills/frontend-engineering` only for conventions. Stay in scope: `MessageBubble.jsx`,
383
+ `ReadTicks.jsx`, `ReactionBar.jsx`, the small `Thread.jsx` slice that mounts them, their tests, the messaging
384
+ i18n file if new labels are genuinely needed, and the `dev/` harness entry. Do not touch `Composer.jsx`,
385
+ `ConversationList.jsx`, poll/attachment components' internals, or anything outside this list. Do not update
386
+ `MEMORY.md`. **Do NOT `git add`/`commit`/`push`** — leave the diff for the Orchestrator's review; a prior WO
387
+ in this campaign (`ui-core-micha` MSG-3e) had its implementer self-commit and self-publish against this same
388
+ instruction — that was a governance breach, not a template to follow. **Do NOT bump the version or
389
+ publish** — the Orchestrator owns the release. WRITE the tests specified in Part A and RUN ONLY the listed
390
+ files plus any new ones — no full suite, no self-review; the Orchestrator owns the authoritative run, the
391
+ independent review, AND the `ui_reviewer` harness pass, and those are the gate.