@micha.bigler/ui-core-micha 2.23.0 → 2.24.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
@@ -39,5 +39,5 @@ prefixed ID; never reuse a bare flat number across workstreams.
39
39
  | MSG-3f | Compact chat-bubble message layout | A one-word message currently renders as **six stacked rows** (~260px): `MessageBubble`'s vertical `Stack spacing={0.75}` gives every element its own full-width row — meta line, body, `ReactionBar`, the reply control (a full MUI `Button`, hence "Antworten" centred on its own line), the overflow `IconButton`, and `children` where the host's `ReadTicks` lands as a "Zugestellt an 0" text line. Rebuild as WhatsApp-style bubbles: own right-aligned and tinted, incoming left-aligned and neutral, shrink-to-content; **timestamp and status icons inline at the end of the final text line** (this is the line-count fix, everything else is cosmetic); status as tick icons instead of text, own messages only; reply/react/edit/delete/copy collapsed into one hover-or-long-press menu; reaction chips overlapping the bubble edge; reply quote as a tinted block inside the bubble. **Acceptance: a short message with no reactions/attachments/quote occupies at most TWO visual lines** (text plus, at most, a narrow right-aligned meta line inside the bubble) — down from six. | 2026-08-02 | done | 1cd777b | Envelope + map: [work-orders/MSG-3f.md](work-orders/MSG-3f.md). Independent `reviewer`: no findings. `ui_reviewer` pass 1 (static-only, no browser tooling that session): one P2 (U1) — the touch-tap reveal (`actionsVisible`) had no dismiss path, permanently pinning the overflow icon visible after any click that bubbled to the bubble (poll option, announcement link, reply quote), violating the "no visible affordance at rest" requirement; one P3 (U2, a consequence of U1). Fixed by the Orchestrator: a `bubbleRef` spanning the Paper + reaction-picker sibling, plus a document-level capture-phase `pointerdown` listener that dismisses `actionsVisible` on any outside interaction — live-verified in the running dev harness (reveal, dismiss-on-outside-click, and reaction-chip-click-does-not-dismiss all confirmed via DOM/computed-style inspection; screenshot tooling was unavailable this session, verification was structural/computed-style based). `ui_reviewer` pass 2 (re-review of the fix): confirmed the fix sound (event-phase ordering, per-instance isolation, cleanup all correct), but found one more gap (U3, P2) — no regression test pinned the dismiss behavior. Closed by adding two tests to `tests/messagingMessageActions.test.jsx`: outside-click dismiss + reaction-chip-does-not-dismiss, and cross-instance isolation (two bubbles, revealing one doesn't affect the other). Scoped gate: 42/42 passing across all 7 named test files. Acceptance criterion verified live: a short incoming direct-conversation message (no sender name, no reactions) renders at exactly 2 visual lines, 78px total height (down from ~260px). Published as npm 2.21.0, verified live on the registry. New `ui-core-micha-dev-harness` launch.json entry added (port 5140) for this and future ucm visual-verification WOs. | Approval Gate #1 granted by the operator 2026-08-02, who also supplied WhatsApp reference screenshots; the envelope records what those pin down beyond the prose (meta rides the LAST line of a multi-line body, not a line of its own; incoming messages carry no status icon at all; **no** action affordance visible at rest; meta visually recessed with read state carried by tick colour). **Tier 2** — shared-core, and an opinionated visual decision imposed on every future ucm messaging consumer. Acceptable now because jg-ferien is the only messaging consumer today (spesix deferred per the messaging-centralization course correction), so it lands with one adopter instead of being retrofitted — but jg vocabulary/layout assumptions must not leak in, same line MSG-3d drew for `external_key`. **Two non-negotiables:** every icon keeps the text it replaced as its accessible name (no i18n key deleted just because its text stopped rendering), and the action affordance must be keyboard-operable without hover or long-press — today's `opacity: 0` hover-only menu is unreachable on touch AND invisible to keyboard users, and reproducing that pre-existing defect is not acceptable. **Two deliberate cost-cuts by the operator (2026-08-02), both after being shown what they cost:** (1) the meta does **not** have to ride the last text line — inside the bubble and right-aligned is enough, a trailing meta line is fine; the inline pattern is listed as a risk to be *avoided*, not managed. (2) **No long-press and no swipe** — desktop uses hover plus keyboard focus (`opacity: 0` does not remove an element from the tab order, so a `:focus-within` rule closes the keyboard half cheaply), touch uses a plain tap on the bubble; React expands the existing `ReactionBar` emoji row rather than being nested inside the MUI `Menu`, which does not nest gracefully. **Trap found while scoping:** today the whole menu is gated on `canAct` (own message or moderator) because it only held Edit/Delete/Copy — once Reply lives there an **incoming** message needs a menu too, so there are two variants (any message: Reply/React/Copy; actionable message: plus Edit/Delete). Getting this wrong makes replying to anyone else impossible and will not surface in a fixture containing only own messages. Remaining risk: announcements/polls/attachments also render through this bubble and will look broken if only plain text is considered. **Read-status observation, deliberately NOT a precondition:** `ReadTicks` fetches per message and dcm's `serialize_message` carries no read state, so a thread fires one REST call per own message; tick icons make it more visible but not worse (`canShowReadTicks` already bounds it to own messages, deliberately). Putting the delivered/read aggregate on `serialize_message` — viewer-independent, exactly as `reply_count`/`last_reply_at` ride it since MSG-3d — would remove the fan-out and is a worthwhile dcm follow-up, but gating a UI fix behind a dcm release is the wrong trade. Deferred: swipe-to-reply, and the (also tall) Composer. Independent of MSG-3e — different files, but coordinate version bumps if both are in flight. |
40
40
  | MSG-6e | Messaging surface defects found by click-through: timestamps, poll cache key, read-state frames, double read-marking, plurals | Six independent defects reproduced in a running app: (A) `MessageBubble.jsx:133` formats with a bare `toLocaleString()` — runtime locale, not i18next's, and the full date instead of `HH:MM` + a day separator; (B) `createConversationPoll` (`MessagingProvider.jsx:403`) does `poll?.message \|\| poll`, but dcm's `_poll_response` has no `message` key, so a phantom entry is cached under the POLL id with no `created_at`; (C) `applyFrame` discards dcm's `read_state`/`delivered`/`thread_read_state` frames; (D) `markConversationRead` fires twice per open (Thread's useEffect + ConversationList's onClick); (E) `SHOW_REPLIES` has no plural forms. | 2026-08-03 | done | 3c7cfbd | Envelope: [work-orders/MSG-6e.md](work-orders/MSG-6e.md). **Reviewed independently: `reviewer` + `ui_reviewer` (requested per envelope for scope A), both clean after one fix round.** Fix round 1 closed a **P1** (`reviewer`): the read-state receipt fix only worked through `recipient_detail`, which dcm never returns for direct (1:1) conversations — the most common case — so `ReadTicks` silently never updated for DMs; fixed by deriving the receipt from `cache.receipts` keyed by `conversation.other_user_id` for the direct case. **Scope C landed narrower than the original envelope** (see the WO's "OPERATOR DECISIONS" section): only `read_state`/`thread_read_state` are handled — `delivered` was deliberately left out, since `django-core-micha` MSG-7 (same release) retires its only publisher (`mark_delivered`). **Not fixed, tracked as follow-ups (P2/P3, `ui_reviewer`, non-blocking):** the day-separator is a bespoke `Divider`+`Typography` pair rather than reusing `LoginForm.jsx`'s existing divider-with-label convention; the separator text has no `nowrap` guard (unlike `MessageBubble`'s timestamp); the first day in a thread never gets a separator (matches the WO's literal spec, not a deviation). Full suite 215→222 passed, 0 failed; `tsc` clean. jg's pin bump is a separate step, still NOT part of this WO — jg also needs `django-core-micha` MSG-7. **B is the one that corrupts state and the most instructive:** the `\|\| poll` fallback turns a wrong assumption about the response shape into silent data corruption instead of an error — observed as a fresh poll rendering with no timestamp and sorting to the TOP of the timeline, above messages from two days earlier, because `chronological()` sorts on `new Date(undefined)` → NaN. Correct after reload. **Explicit non-goal: do NOT touch `isOwn`/`canShowReadTicks`/`senderName`** — the own/incoming alignment, missing ticks and "Unbekannte Person" all trace to dcm never sending a `sender` object; ucm's code is already correct (`MessageBubble.jsx:100/108/109` implements the alignment in full, it just never sees `isOwn === true`). That fix is `django-core-micha` MSG-7. **Ordering caveat on C:** jg MSG-9 finding 1 (live WS push ~10 min late) is undiagnosed — handling frames that do not arrive fixes nothing, so do C last and report if finding 1 is still open. jg's pin bump is NOT part of this WO: jg also needs dcm MSG-7, and bumping one alone leaves the surface half-fixed. |
41
41
  | MSG-6f | `titleOf` never calls the host resolver for a broadcast; the read ticks claim a delivery that does not exist | (A) `ConversationList.jsx` `titleOf()` gates the host callback on `conversation.kind === 'managed'`, so jg-ferien MSG-10's correct broadcast branch in `resolveManagedConversationLabel` is **unreachable from the list** — the row still falls through to `MessagingList.UNTITLED`. (B) dcm MSG-7 removed `delivered_count` from `read-status`, but `ReadTicks.jsx` still renders `t('MessagingReadTicks.DELIVERED', { count: renderedStatus.delivered_count \|\| 0 })`, so `undefined \|\| 0` produces the identical "Zugestellt an 0" label as before — a claimed delivery with no data source behind it. (C) a viewer already sitting in an open thread when a new message lands is recorded as "not read" until they navigate away and back — the mount-effect only re-marks once per open. | 2026-08-03 | done | 61e13b0 | Envelope: [work-orders/MSG-6f.md](work-orders/MSG-6f.md); all three scopes released together per operator's critical-path directive (one release instead of two, since A and C never depended on dcm) — this also stands as the operator confirmation the envelope asked for before implementing B's vocabulary change. **A:** kind gate removed entirely from `titleOf()`; a host resolver returning `null` already means "no label", so gating duplicated a decision that belongs to the host. **B:** `DELIVERED` retired; dcm MSG-9 (published 2.40.0) added `read_count`/`recipient_count` to `read-status`, gated the same way as `recipient_detail` (non-direct, `read_receipt_detail`-eligible). `ReadTicks.jsx` now renders: counts present → a read ratio (`READ_RATIO`, e.g. "18/40"), no counts + `direct` → two-state tick (`SENT`/`ALL_READ`), no counts + non-direct → nothing (was a permanently-grey "always delivered" tick before). **C:** `Thread.jsx` now also subscribes to `MESSAGING_ENVELOPE` directly (independent of `MessagingProvider`'s own subscription — the registry supports multiple handlers per envelope) and re-marks read on a live `message` frame for the open conversation, skipped when the tab is hidden. **Reviewed independently: `reviewer` + `ui_reviewer`, both clean after one fix round.** Fix round 1 closed a **P1** (`reviewer`, self-caught during the fix, not shipped): the ratio was briefly recomputed client-side from `recipientDetail.filter(r => r.last_read_at).length` to make it "live-update" from `read_state` frames — this silently contradicts the WO's explicit instruction and is wrong, because `recipientDetail`'s `last_read_at` is never thresholded against the specific message's `created_at` the way dcm's own aggregate is (`last_read_at__gte=message.created_at`, dcm `services.py`); in a real group with history it would read as close to N/N regardless of who actually saw *this* message. Reverted to trusting dcm's `read_count`/`recipient_count` verbatim, with the trade-off documented in `ReadTicks.jsx`: the ratio no longer live-updates from a `read_state` frame, only on the next read-status fetch (unlike the DM tick and the per-person popover, both viewer/timestamp-relative facts that safely reuse `cache.receipts`). Also fixed a P3 (`ui_reviewer`): the ratio's `color="text.secondary"` diverged from the sibling meta row's `inherit` convention, changed to `inherit`. **Known, disclosed, non-blocking follow-up (`ui_reviewer` P2, not fixed here):** the DM tick's fixed `grey.500`/`primary.main` colour pair is not fully dark-mode contrast-neutral — verified by hand (WCAG-style luminance/contrast math against both themes' actual palette values, not just accepting the reviewer's claim): light-mode state separation ≈1.71:1, dark-mode ≈1.53:1 (worse, since `primary.main` lightens substantially in dark mode while `grey.500` is a fixed, non-mode-aware value). No single grey threads both themes well — the two themes need opposite-direction adjustments relative to their own inverted primary-blue lightness — and the icon shape (`Done` vs `DoneAll`) remains the primary, non-colour signal per the component's own design principle, so this is tracked rather than guessed at further without live visual verification. Test-infrastructure fix alongside C, not a behavioural change: `Thread.jsx`'s new `subscribe()` call broke 3 existing test files' single-captured-handler mock pattern (`frameHandler = handler`, overwritten per call) — converted to Set/Map-based multi-handler dispatch in `messagingChunk6.test.jsx`, `messagingMsg6.test.jsx`, `messagingThreadReceipts.test.jsx`. Full suite 234/234 (was 222), `tsc` clean. Published as npm 2.22.0 (confirmed live on the registry). jg-ferien pin bump to `dcm 2.40.0` / `ucm 2.22.0` done separately (MSG-14).
42
- | MSG-6g | Poll UI: tap-to-vote with proportional result bars, no separate vote button | `PollCard.jsx` required select-then-click-"Abstimmen" two-step voting, with results as a bare `(n)` count suffix. Operator request: WhatsApp-style tap-to-vote with proportional bars. | 2026-08-03 | done | (pending push) | Envelope: [work-orders/MSG-6g.md](work-orders/MSG-6g.md). UX improvement, blocked nothing, blocked by nothing — except a real blocker found mid-implementation: the WO's own assumption that dcm's vote endpoint treats `option_ids` as a complete authoritative set per call did not hold for multi-select (`vote_poll` only ever added votes, never retracted one for an option missing from a later call — untoggling was silently a no-op). Reported per the WO's own "stop and report" contingency; operator confirmed fixing it directly in dcm rather than working around it client-side — see dcm `work-orders/MSG-11.md`, published as dcm 2.40.1. **Implemented directly by the Orchestrator** (Codex unavailable, out of credits). Single-choice now renders a real `RadioGroup` (native "different option moves the vote, same option is a no-op" semantics, matching `AuthFactorRequirementCard.jsx`'s existing radio convention); multi-select renders `Checkbox`. Each option shows a `LinearProgress` bar + count/percentage (in the accessible name, not width alone); count/percent never `NaN` at zero votes. Every tap is optimistic (`MessagingProvider.castPollVote`, patterned on `toggleReaction`'s immediate-patch/reconcile-or-revert shape) with a per-poll "latest tap wins" token so rapid re-taps race deterministically instead of a stale response clobbering a newer one; a rejected vote visibly reverts. **Independent `reviewer` + `ui_reviewer` (both mandatory per the WO), 5 findings total, all fixed** — see `work-orders/MSG-6g.md`'s as-built record for full detail: `reviewer` R1 (**P1**, real bug) — the optimistic count projection was built from the stale caller-supplied `poll` prop instead of the freshly cache-read `previousPoll`, computing a wrong transient vote count in the shared cache on a second rapid tap; fixed, and verified non-vacuous by reverting the fix and confirming the new regression test fails with the exact predicted wrong number before restoring it. R2 (P2) — no test covered non-zero-count rapid-tap math (zero-vote fixtures elsewhere masked R1 via a `Math.max(0, ...)` clamp); closed with a dedicated regression test. R3 (P3) — a possible `undefined` pushed into an option's `voters` array when `user?.id` is momentarily unset; guarded. `ui_reviewer` U1 (P2) — no visual distinction between an in-flight optimistic vote and a confirmed one; fixed by reusing `ReactionBar.jsx`'s `pending`-Set dimming convention, deliberately *not* disabling the control (the WO requires rapid re-taps to stay possible, not be blocked). U2 (P2) — bare ungrouped `Radio` controls diverged from this repo's own convention and lost native arrow-key/group semantics; fixed with `RadioGroup`, which also let a manual `Box onClick`+`stopPropagation` whole-row-tap hack be removed in favour of `FormControlLabel`'s native label-click — that same change surfaced and fixed an unreported bug (`RadioGroup`'s own `onChange` only ever returns a DOM string via `event.target.value`, silently coercing a non-string option id) caught by an existing numeric-id test fixture. Removed now-orphaned `MessagingPoll.VOTE`/`SELECT_OPTION` i18n keys; added `MessagingPoll.OPTION_RESULT`. Scoped gate: full suite 234 → 241 (7 new tests), `tsc` clean. Published as npm 2.23.0. jg-ferien pin bump is a separate, subsequent step. |
43
- | MSG-6g | Poll UI: tap-to-vote with result bars, no separate vote button | `PollCard.jsx` renders options as `FormControlLabel` + `Checkbox`/radio labelled `"<text> (<vote_count>)"`, with a separate "Abstimmen" submit button — voting is a two-step select-then-submit, and the result is a bare parenthesised number. Operator request: one tap on the option itself casts the vote, and the distribution reads at a glance from proportional bars (WhatsApp-style). | 2026-08-03 | planned | | Envelope: [work-orders/MSG-6g.md](work-orders/MSG-6g.md). Operator request 2026-08-03. **UX improvement, not a defect — the current poll works.** Scoped separately so it never blocks the messaging defect WOs (dcm MSG-10, ucm MSG-6f); it blocks nothing and is blocked by nothing. Frontend only: if tap-to-vote appears to need a backend change, stop and report. Two things easy to break and pinned by tests: a failed vote must **visibly revert** (a silently swallowed failure leaving a bar showing an unrecorded vote is worse than the current two-step flow), and the viewer's `voted_option_ids` must survive a `poll_updated` frame that does not carry it — that is viewer-specific and REST-only by design, and the merge-trap guard was a deliberate earlier fix. Also specified: zero-vote rendering must be defined rather than letting a division produce `NaN`; the denominator is total votes cast, not participant count; the closed state must stay unmistakable now that a missing submit button no longer signals it; and the accessible name must carry count/percentage, since bar width alone does not. Explicit non-goal: no "who voted for what" disclosure — `serialize_poll`'s `voters` data is not licence to surface it, that is an untaken privacy decision. |
42
+ | MSG-6g | Poll UI: tap-to-vote with proportional result bars, no separate vote button | `PollCard.jsx` required select-then-click-"Abstimmen" two-step voting, with results as a bare `(n)` count suffix. Operator request: WhatsApp-style tap-to-vote with proportional bars. | 2026-08-03 | done | ef66eb5 | Envelope: [work-orders/MSG-6g.md](work-orders/MSG-6g.md). UX improvement, blocked nothing, blocked by nothing — except a real blocker found mid-implementation: the WO's own assumption that dcm's vote endpoint treats `option_ids` as a complete authoritative set per call did not hold for multi-select (`vote_poll` only ever added votes, never retracted one for an option missing from a later call — untoggling was silently a no-op). Reported per the WO's own "stop and report" contingency; operator confirmed fixing it directly in dcm rather than working around it client-side — see dcm `work-orders/MSG-11.md`, published as dcm 2.40.1. **Implemented directly by the Orchestrator** (Codex unavailable, out of credits). Single-choice now renders a real `RadioGroup` (native "different option moves the vote, same option is a no-op" semantics, matching `AuthFactorRequirementCard.jsx`'s existing radio convention); multi-select renders `Checkbox`. Each option shows a `LinearProgress` bar + count/percentage (in the accessible name, not width alone); count/percent never `NaN` at zero votes. Every tap is optimistic (`MessagingProvider.castPollVote`, patterned on `toggleReaction`'s immediate-patch/reconcile-or-revert shape) with a per-poll "latest tap wins" token so rapid re-taps race deterministically instead of a stale response clobbering a newer one; a rejected vote visibly reverts. **Independent `reviewer` + `ui_reviewer` (both mandatory per the WO), 5 findings total, all fixed** — see `work-orders/MSG-6g.md`'s as-built record for full detail: `reviewer` R1 (**P1**, real bug) — the optimistic count projection was built from the stale caller-supplied `poll` prop instead of the freshly cache-read `previousPoll`, computing a wrong transient vote count in the shared cache on a second rapid tap; fixed, and verified non-vacuous by reverting the fix and confirming the new regression test fails with the exact predicted wrong number before restoring it. R2 (P2) — no test covered non-zero-count rapid-tap math (zero-vote fixtures elsewhere masked R1 via a `Math.max(0, ...)` clamp); closed with a dedicated regression test. R3 (P3) — a possible `undefined` pushed into an option's `voters` array when `user?.id` is momentarily unset; guarded. `ui_reviewer` U1 (P2) — no visual distinction between an in-flight optimistic vote and a confirmed one; fixed by reusing `ReactionBar.jsx`'s `pending`-Set dimming convention, deliberately *not* disabling the control (the WO requires rapid re-taps to stay possible, not be blocked). U2 (P2) — bare ungrouped `Radio` controls diverged from this repo's own convention and lost native arrow-key/group semantics; fixed with `RadioGroup`, which also let a manual `Box onClick`+`stopPropagation` whole-row-tap hack be removed in favour of `FormControlLabel`'s native label-click — that same change surfaced and fixed an unreported bug (`RadioGroup`'s own `onChange` only ever returns a DOM string via `event.target.value`, silently coercing a non-string option id) caught by an existing numeric-id test fixture. Removed now-orphaned `MessagingPoll.VOTE`/`SELECT_OPTION` i18n keys; added `MessagingPoll.OPTION_RESULT`. Scoped gate: full suite 234 → 241 (7 new tests), `tsc` clean. Published as npm 2.23.0. jg-ferien pin bump is a separate, subsequent step. |
43
+ | MSG-6h | Attachment gallery (thumbnail/lightbox/right-click download), reaction bar overlap | `AttachmentList.jsx` renders each attachment as a bare download button (36px thumbnail if loaded, plus a text label that always fell back to the raw UUID `id` since dcm never returned a filename). `MessageBubble.jsx`s `ReactionBar` uses a fixed `mt: -0.5` overlap trick against the bubble that now reads as visually broken. | 2026-08-03 | done | (pending push) | Envelope: [work-orders/MSG-6h.md](work-orders/MSG-6h.md). Operator report from a staging screenshot, seen for the first time end-to-end now that `django-core-micha` MSG-10 fixed attachment uploads (previously unconditionally rejected). Companion to dcm `work-orders/MSG-12.md` (published 2.40.2, exposes the real filename this WO consumes). **Implemented directly by the Orchestrator** (Codex unavailable). `AttachmentList.jsx` rewritten: each attachment is a small 64×64 tile (image thumbnail via the existing `getAttachmentThumbnail`, or a file-icon+filename for non-images); click on an image opens a lightbox (full image via `getAttachment`, cached per-attachment so a re-open doesn't re-fetch); right-click opens a context menu with Download, `stopPropagation`'d so it never also opens `MessageBubble`'s own bubble-level menu. `ReactionBar`'s fixed `mt: -0.5` overlap trick (`MessageBubble.jsx:139`) replaced with a small positive `mt: 0.5` gap — the old trick pulled the chip into whatever sat immediately above it in flow, which used to always be plain text but now can be MSG-6g's poll bars or this WO's own bordered attachment tiles. **Independent `reviewer` + `ui_reviewer` (both mandatory per the WO), 9 findings total, all fixed or explicitly resolved/disclosed** — see `work-orders/MSG-6h.md`'s as-built record for full detail. Headline: `reviewer` R1 and `ui_reviewer` U1 independently found the **same real regression** from two angles — the redesign made an image's only download path a mouse right-click (the lightbox had no download control), unreachable by keyboard or touch (no long-press). One fix closed both: the lightbox now carries its own Download button, reachable by click, Tab+Enter, or touch. `reviewer` R2 (P2) — the new lightbox fetch had no loading/error state; added a `loading`/`ready`/`error` status with a spinner and a visible error alert, guarded against a stale in-flight fetch being superseded by a newer open. R3 (P3) — lightbox `Dialog` had no accessible name; added. `ui_reviewer` U2 (P2, resolved not separately fixed) — no visual affordance for right-click discoverability; the R1/U1 fix already makes every attachment fully reachable via primary click, so right-click stays a shortcut, not a required hidden path — a redundant hover-icon on top of that was judged scope creep past the finding's actual concern. U3 (P2) — truncated filenames got a `title` tooltip. U4 (P3) — French `PREVIEW` translation fixed for verb-first parallelism with its siblings. U5 (P3) — lightbox buttons gained the codebase's established `boxShadow` convention for floating icons. U6 (P3, disclosed not fixed) — a residual silent-no-op edge case matching a pre-existing pattern elsewhere in the same component; R2's loading/error state already covers the actually-reachable failure path. **Scope B (reaction spacing) explicitly NOT live-verified** against the operator's original staging screenshot — no staging credentials this round; both reviewers judged the fix low-risk by static reading only. Full suite 241 → 249 (8 new tests), `tsc` clean. Published as npm 2.24.0.
@@ -70,6 +70,11 @@ export const messagingTranslations = {
70
70
  'MessagingComposer.UPLOAD_ERROR': { de: 'Anhang wurde abgelehnt: {{message}}', en: 'Attachment was rejected: {{message}}', fr: 'La pièce jointe a été rejetée : {{message}}' },
71
71
  'MessagingAttachments.LABEL': { de: 'Anhänge', en: 'Attachments', fr: 'Pièces jointes' },
72
72
  'MessagingAttachments.DOWNLOAD': { de: '{{name}} herunterladen', en: 'Download {{name}}', fr: 'Télécharger {{name}}' },
73
+ 'MessagingAttachments.PREVIEW': { de: '{{name}} als Vorschau öffnen', en: 'Preview {{name}}', fr: 'Prévisualiser {{name}}' },
74
+ 'MessagingAttachments.CLOSE_PREVIEW': { de: 'Vorschau schliessen', en: 'Close preview', fr: 'Fermer l’aperçu' },
75
+ 'MessagingAttachments.DOWNLOAD_ACTION': { de: 'Herunterladen', en: 'Download', fr: 'Télécharger' },
76
+ 'MessagingAttachments.LOADING_PREVIEW': { de: 'Vorschau wird geladen', en: 'Loading preview', fr: 'Chargement de l’aperçu' },
77
+ 'MessagingAttachments.PREVIEW_ERROR': { de: 'Vorschau konnte nicht geladen werden.', en: 'Preview could not be loaded.', fr: 'Impossible de charger l’aperçu.' },
73
78
  'MessagingReactions.LABEL': { de: 'Reaktionen', en: 'Reactions', fr: 'Réactions' },
74
79
  'MessagingReactions.ADD': { de: 'Reaktion hinzufügen', en: 'Add reaction', fr: 'Ajouter une réaction' },
75
80
  'MessagingReactions.TOGGLE_one': { de: '{{emoji}} Reaktion mit {{count}} Stimme umschalten', en: 'Toggle {{emoji}} reaction with {{count}} vote', fr: 'Basculer la réaction {{emoji}} avec {{count}} vote' },
@@ -1,15 +1,37 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Button, Stack } from '@mui/material';
3
- import { useEffect, useState } from 'react';
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import CloseIcon from '@mui/icons-material/Close';
3
+ import DownloadIcon from '@mui/icons-material/DownloadOutlined';
4
+ import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined';
5
+ import { Alert, Box, ButtonBase, CircularProgress, Dialog, IconButton, Menu, MenuItem, Stack, Typography } from '@mui/material';
6
+ import { useEffect, useRef, useState } from 'react';
4
7
  import { useTranslation } from 'react-i18next';
5
8
  import { useMessaging } from './MessagingProvider';
6
9
  function isImage(attachment) { var _a; return (_a = attachment.content_type) === null || _a === void 0 ? void 0 : _a.startsWith('image/'); }
10
+ // `filename` is dcm's real, sanitized upload name (MSG-12) -- `id` is the
11
+ // last-resort fallback for interop with a host still on a pre-MSG-12 pin.
7
12
  function nameOf(attachment) { return attachment.filename || attachment.name || attachment.id; }
8
- /** Download-only attachment renderer; all bytes remain behind the authenticated REST endpoints. */
13
+ /**
14
+ * WhatsApp-style attachment gallery (MSG-6h): a small thumbnail per image
15
+ * attachment; click opens a full-size lightbox, which itself carries its own
16
+ * Download action (reachable by mouse, touch, or keyboard -- right-click is a
17
+ * shortcut, never the only path). A non-image attachment has no preview to
18
+ * open -- click downloads directly. Right-click still opens the same context
19
+ * menu on either kind, for the mouse-user shortcut. Bytes only ever move
20
+ * through the existing authenticated `getAttachment`/`getAttachmentThumbnail`
21
+ * blob endpoints -- no new byte-handling path.
22
+ */
9
23
  export function AttachmentList({ attachments = [] }) {
10
24
  const { t } = useTranslation();
11
25
  const { getAttachment, getAttachmentThumbnail } = useMessaging();
12
26
  const [previews, setPreviews] = useState({});
27
+ // { attachment, url: string|null, status: 'loading'|'ready'|'error' } | null
28
+ const [lightbox, setLightbox] = useState(null);
29
+ const [menu, setMenu] = useState(null); // { attachment, anchorPosition } | null
30
+ // Full-resolution object URLs, fetched once per attachment on first
31
+ // lightbox open and reused for the rest of this mount (a re-open must not
32
+ // re-fetch what's already sitting in memory). Only ever revoked on
33
+ // unmount -- never while the Dialog might still be displaying one.
34
+ const fullUrlCacheRef = useRef({});
13
35
  useEffect(() => {
14
36
  let live = true;
15
37
  const urls = [];
@@ -26,6 +48,7 @@ export function AttachmentList({ attachments = [] }) {
26
48
  });
27
49
  return () => { live = false; urls.forEach((url) => URL.revokeObjectURL(url)); };
28
50
  }, [attachments, getAttachmentThumbnail]);
51
+ useEffect(() => () => { Object.values(fullUrlCacheRef.current).forEach((url) => URL.revokeObjectURL(url)); }, []);
29
52
  if (!attachments.length)
30
53
  return null;
31
54
  const download = async (attachment) => {
@@ -39,5 +62,42 @@ export function AttachmentList({ attachments = [] }) {
39
62
  link.click();
40
63
  URL.revokeObjectURL(url);
41
64
  };
42
- return _jsx(Stack, { direction: "row", spacing: 1, flexWrap: "wrap", "aria-label": t('MessagingAttachments.LABEL'), children: attachments.map((attachment) => _jsxs(Button, { type: "button", size: "small", onClick: () => download(attachment), children: [previews[attachment.id] && _jsx(Box, { component: "img", src: previews[attachment.id], alt: "", width: 36, height: 36, sx: { objectFit: 'cover', mr: 0.75 } }), t('MessagingAttachments.DOWNLOAD', { name: nameOf(attachment) })] }, attachment.id)) });
65
+ const openLightbox = async (attachment) => {
66
+ const cachedUrl = fullUrlCacheRef.current[attachment.id];
67
+ if (cachedUrl) {
68
+ setLightbox({ attachment, url: cachedUrl, status: 'ready' });
69
+ return;
70
+ }
71
+ setLightbox({ attachment, url: null, status: 'loading' });
72
+ try {
73
+ const blob = await getAttachment(attachment.id);
74
+ if (!(blob instanceof Blob))
75
+ throw new Error('Attachment response was not a Blob');
76
+ const url = URL.createObjectURL(blob);
77
+ fullUrlCacheRef.current[attachment.id] = url;
78
+ // A newer open (a different attachment) may have superseded this one
79
+ // while the fetch was in flight -- don't stomp on it with a stale result.
80
+ setLightbox((current) => ((current === null || current === void 0 ? void 0 : current.attachment.id) === attachment.id ? { attachment, url, status: 'ready' } : current));
81
+ }
82
+ catch (_a) {
83
+ setLightbox((current) => ((current === null || current === void 0 ? void 0 : current.attachment.id) === attachment.id ? Object.assign(Object.assign({}, current), { status: 'error' }) : current));
84
+ }
85
+ };
86
+ const openMenu = (event, attachment) => {
87
+ event.preventDefault();
88
+ // Attachments live inside a message bubble that has its own right-click
89
+ // menu (MessageBubble.jsx's onContextMenu) -- this must not also open.
90
+ event.stopPropagation();
91
+ setMenu({ attachment, anchorPosition: { top: event.clientY, left: event.clientX } });
92
+ };
93
+ const closeMenu = () => setMenu(null);
94
+ return _jsxs(_Fragment, { children: [_jsx(Stack, { direction: "row", spacing: 1, flexWrap: "wrap", "aria-label": t('MessagingAttachments.LABEL'), children: attachments.map((attachment) => {
95
+ const image = isImage(attachment);
96
+ const preview = previews[attachment.id];
97
+ const label = image ? t('MessagingAttachments.PREVIEW', { name: nameOf(attachment) }) : t('MessagingAttachments.DOWNLOAD', { name: nameOf(attachment) });
98
+ return (_jsx(ButtonBase, { type: "button", title: nameOf(attachment), onClick: () => (image ? openLightbox(attachment) : download(attachment)), onContextMenu: (event) => openMenu(event, attachment), "aria-label": label, sx: { width: 64, height: 64, borderRadius: 1, overflow: 'hidden', border: '1px solid', borderColor: 'divider', display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'action.hover' }, children: image && preview
99
+ ? _jsx(Box, { component: "img", src: preview, alt: "", sx: { width: '100%', height: '100%', objectFit: 'cover' } })
100
+ : _jsxs(Stack, { alignItems: "center", spacing: 0.25, sx: { px: 0.5, maxWidth: '100%' }, children: [_jsx(InsertDriveFileOutlinedIcon, { fontSize: "small" }), _jsx(Typography, { variant: "caption", noWrap: true, sx: { maxWidth: '100%' }, children: nameOf(attachment) })] }) }, attachment.id));
101
+ }) }), _jsx(Dialog, { open: Boolean(lightbox), onClose: () => setLightbox(null), maxWidth: "lg", "aria-label": lightbox ? nameOf(lightbox.attachment) : undefined, children: lightbox && _jsxs(Box, { sx: { position: 'relative', minWidth: 240, minHeight: lightbox.status === 'ready' ? undefined : 160, display: 'flex', alignItems: 'center', justifyContent: 'center' }, children: [_jsxs(Stack, { direction: "row", spacing: 0.5, sx: { position: 'absolute', top: 4, right: 4 }, children: [lightbox.status === 'ready' && _jsx(IconButton, { "aria-label": t('MessagingAttachments.DOWNLOAD_ACTION'), onClick: () => download(lightbox.attachment), sx: { bgcolor: 'background.paper', boxShadow: 1 }, children: _jsx(DownloadIcon, {}) }), _jsx(IconButton, { "aria-label": t('MessagingAttachments.CLOSE_PREVIEW'), onClick: () => setLightbox(null), sx: { bgcolor: 'background.paper', boxShadow: 1 }, children: _jsx(CloseIcon, {}) })] }), lightbox.status === 'loading' && _jsx(CircularProgress, { "aria-label": t('MessagingAttachments.LOADING_PREVIEW'), sx: { m: 4 } }), lightbox.status === 'error' && _jsx(Alert, { severity: "error", role: "alert", sx: { m: 2 }, children: t('MessagingAttachments.PREVIEW_ERROR') }), lightbox.status === 'ready' && _jsx(Box, { component: "img", src: lightbox.url, alt: nameOf(lightbox.attachment), sx: { display: 'block', maxWidth: '90vw', maxHeight: '90vh' } })] }) }), _jsx(Menu, { open: Boolean(menu), onClose: closeMenu, anchorReference: "anchorPosition", anchorPosition: menu === null || menu === void 0 ? void 0 : menu.anchorPosition, children: _jsxs(MenuItem, { onClick: () => { const attachment = menu === null || menu === void 0 ? void 0 : menu.attachment; closeMenu(); if (attachment)
102
+ download(attachment); }, children: [_jsx(DownloadIcon, { fontSize: "small", sx: { mr: 1 } }), t('MessagingAttachments.DOWNLOAD_ACTION')] }) })] });
43
103
  }
@@ -134,5 +134,5 @@ export function MessageBubble({ message, replyTo, conversation, onReply, onJumpT
134
134
  overflowWrap: 'anywhere',
135
135
  '& .message-actions': { opacity: 0, pointerEvents: 'none', transition: 'opacity 120ms ease-in-out' },
136
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", flexWrap: "wrap", sx: { color: 'text.disabled', minWidth: 0, '& .MuiSvgIcon-root': { fontSize: '1rem' } }, children: [message.created_at && _jsx(Typography, { component: "span", variant: "caption", color: "inherit", sx: { whiteSpace: 'nowrap' }, children: new Date(message.created_at).toLocaleTimeString(i18n === null || i18n === void 0 ? void 0 : i18n.language, { hour: '2-digit', minute: '2-digit' }) }), 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') })] })] })] }));
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", flexWrap: "wrap", sx: { color: 'text.disabled', minWidth: 0, '& .MuiSvgIcon-root': { fontSize: '1rem' } }, children: [message.created_at && _jsx(Typography, { component: "span", variant: "caption", color: "inherit", sx: { whiteSpace: 'nowrap' }, children: new Date(message.created_at).toLocaleTimeString(i18n === null || i18n === void 0 ? void 0 : i18n.language, { hour: '2-digit', minute: '2-digit' }) }), 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 } }), _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') })] })] })] }));
138
138
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "2.23.0",
3
+ "version": "2.24.0",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "repository": {
@@ -70,6 +70,11 @@ export const messagingTranslations = {
70
70
  'MessagingComposer.UPLOAD_ERROR': { de: 'Anhang wurde abgelehnt: {{message}}', en: 'Attachment was rejected: {{message}}', fr: 'La pièce jointe a été rejetée : {{message}}' },
71
71
  'MessagingAttachments.LABEL': { de: 'Anhänge', en: 'Attachments', fr: 'Pièces jointes' },
72
72
  'MessagingAttachments.DOWNLOAD': { de: '{{name}} herunterladen', en: 'Download {{name}}', fr: 'Télécharger {{name}}' },
73
+ 'MessagingAttachments.PREVIEW': { de: '{{name}} als Vorschau öffnen', en: 'Preview {{name}}', fr: 'Prévisualiser {{name}}' },
74
+ 'MessagingAttachments.CLOSE_PREVIEW': { de: 'Vorschau schliessen', en: 'Close preview', fr: 'Fermer l’aperçu' },
75
+ 'MessagingAttachments.DOWNLOAD_ACTION': { de: 'Herunterladen', en: 'Download', fr: 'Télécharger' },
76
+ 'MessagingAttachments.LOADING_PREVIEW': { de: 'Vorschau wird geladen', en: 'Loading preview', fr: 'Chargement de l’aperçu' },
77
+ 'MessagingAttachments.PREVIEW_ERROR': { de: 'Vorschau konnte nicht geladen werden.', en: 'Preview could not be loaded.', fr: 'Impossible de charger l’aperçu.' },
73
78
  'MessagingReactions.LABEL': { de: 'Reaktionen', en: 'Reactions', fr: 'Réactions' },
74
79
  'MessagingReactions.ADD': { de: 'Reaktion hinzufügen', en: 'Add reaction', fr: 'Ajouter une réaction' },
75
80
  'MessagingReactions.TOGGLE_one': { de: '{{emoji}} Reaktion mit {{count}} Stimme umschalten', en: 'Toggle {{emoji}} reaction with {{count}} vote', fr: 'Basculer la réaction {{emoji}} avec {{count}} vote' },
@@ -1,17 +1,40 @@
1
- import { Box, Button, Stack } from '@mui/material';
2
- import { useEffect, useState } from 'react';
1
+ import CloseIcon from '@mui/icons-material/Close';
2
+ import DownloadIcon from '@mui/icons-material/DownloadOutlined';
3
+ import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined';
4
+ import { Alert, Box, ButtonBase, CircularProgress, Dialog, IconButton, Menu, MenuItem, Stack, Typography } from '@mui/material';
5
+ import { useEffect, useRef, useState } from 'react';
3
6
  import { useTranslation } from 'react-i18next';
4
7
 
5
8
  import { useMessaging } from './MessagingProvider';
6
9
 
7
10
  function isImage(attachment) { return attachment.content_type?.startsWith('image/'); }
11
+ // `filename` is dcm's real, sanitized upload name (MSG-12) -- `id` is the
12
+ // last-resort fallback for interop with a host still on a pre-MSG-12 pin.
8
13
  function nameOf(attachment) { return attachment.filename || attachment.name || attachment.id; }
9
14
 
10
- /** Download-only attachment renderer; all bytes remain behind the authenticated REST endpoints. */
15
+ /**
16
+ * WhatsApp-style attachment gallery (MSG-6h): a small thumbnail per image
17
+ * attachment; click opens a full-size lightbox, which itself carries its own
18
+ * Download action (reachable by mouse, touch, or keyboard -- right-click is a
19
+ * shortcut, never the only path). A non-image attachment has no preview to
20
+ * open -- click downloads directly. Right-click still opens the same context
21
+ * menu on either kind, for the mouse-user shortcut. Bytes only ever move
22
+ * through the existing authenticated `getAttachment`/`getAttachmentThumbnail`
23
+ * blob endpoints -- no new byte-handling path.
24
+ */
11
25
  export function AttachmentList({ attachments = [] }) {
12
26
  const { t } = useTranslation();
13
27
  const { getAttachment, getAttachmentThumbnail } = useMessaging();
14
28
  const [previews, setPreviews] = useState({});
29
+ // { attachment, url: string|null, status: 'loading'|'ready'|'error' } | null
30
+ const [lightbox, setLightbox] = useState(null);
31
+ const [menu, setMenu] = useState(null); // { attachment, anchorPosition } | null
32
+ // Full-resolution object URLs, fetched once per attachment on first
33
+ // lightbox open and reused for the rest of this mount (a re-open must not
34
+ // re-fetch what's already sitting in memory). Only ever revoked on
35
+ // unmount -- never while the Dialog might still be displaying one.
36
+ const fullUrlCacheRef = useRef({});
37
+
15
38
  useEffect(() => {
16
39
  let live = true; const urls = [];
17
40
  attachments.filter(isImage).forEach(async (attachment) => {
@@ -23,16 +46,91 @@ export function AttachmentList({ attachments = [] }) {
23
46
  });
24
47
  return () => { live = false; urls.forEach((url) => URL.revokeObjectURL(url)); };
25
48
  }, [attachments, getAttachmentThumbnail]);
49
+
50
+ useEffect(() => () => { Object.values(fullUrlCacheRef.current).forEach((url) => URL.revokeObjectURL(url)); }, []);
51
+
26
52
  if (!attachments.length) return null;
53
+
27
54
  const download = async (attachment) => {
28
55
  const blob = await getAttachment(attachment.id);
29
56
  if (!(blob instanceof Blob)) return;
30
57
  const url = URL.createObjectURL(blob); const link = document.createElement('a');
31
58
  link.href = url; link.download = nameOf(attachment); link.click(); URL.revokeObjectURL(url);
32
59
  };
33
- return <Stack direction="row" spacing={1} flexWrap="wrap" aria-label={t('MessagingAttachments.LABEL')}>
34
- {attachments.map((attachment) => <Button key={attachment.id} type="button" size="small" onClick={() => download(attachment)}>
35
- {previews[attachment.id] && <Box component="img" src={previews[attachment.id]} alt="" width={36} height={36} sx={{ objectFit: 'cover', mr: 0.75 }} />}{t('MessagingAttachments.DOWNLOAD', { name: nameOf(attachment) })}
36
- </Button>)}
37
- </Stack>;
60
+
61
+ const openLightbox = async (attachment) => {
62
+ const cachedUrl = fullUrlCacheRef.current[attachment.id];
63
+ if (cachedUrl) { setLightbox({ attachment, url: cachedUrl, status: 'ready' }); return; }
64
+ setLightbox({ attachment, url: null, status: 'loading' });
65
+ try {
66
+ const blob = await getAttachment(attachment.id);
67
+ if (!(blob instanceof Blob)) throw new Error('Attachment response was not a Blob');
68
+ const url = URL.createObjectURL(blob);
69
+ fullUrlCacheRef.current[attachment.id] = url;
70
+ // A newer open (a different attachment) may have superseded this one
71
+ // while the fetch was in flight -- don't stomp on it with a stale result.
72
+ setLightbox((current) => (current?.attachment.id === attachment.id ? { attachment, url, status: 'ready' } : current));
73
+ } catch {
74
+ setLightbox((current) => (current?.attachment.id === attachment.id ? { ...current, status: 'error' } : current));
75
+ }
76
+ };
77
+
78
+ const openMenu = (event, attachment) => {
79
+ event.preventDefault();
80
+ // Attachments live inside a message bubble that has its own right-click
81
+ // menu (MessageBubble.jsx's onContextMenu) -- this must not also open.
82
+ event.stopPropagation();
83
+ setMenu({ attachment, anchorPosition: { top: event.clientY, left: event.clientX } });
84
+ };
85
+ const closeMenu = () => setMenu(null);
86
+
87
+ return <>
88
+ <Stack direction="row" spacing={1} flexWrap="wrap" aria-label={t('MessagingAttachments.LABEL')}>
89
+ {attachments.map((attachment) => {
90
+ const image = isImage(attachment);
91
+ const preview = previews[attachment.id];
92
+ const label = image ? t('MessagingAttachments.PREVIEW', { name: nameOf(attachment) }) : t('MessagingAttachments.DOWNLOAD', { name: nameOf(attachment) });
93
+ return (
94
+ <ButtonBase
95
+ key={attachment.id}
96
+ type="button"
97
+ title={nameOf(attachment)}
98
+ onClick={() => (image ? openLightbox(attachment) : download(attachment))}
99
+ onContextMenu={(event) => openMenu(event, attachment)}
100
+ aria-label={label}
101
+ sx={{ width: 64, height: 64, borderRadius: 1, overflow: 'hidden', border: '1px solid', borderColor: 'divider', display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'action.hover' }}
102
+ >
103
+ {image && preview
104
+ ? <Box component="img" src={preview} alt="" sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
105
+ : <Stack alignItems="center" spacing={0.25} sx={{ px: 0.5, maxWidth: '100%' }}>
106
+ <InsertDriveFileOutlinedIcon fontSize="small" />
107
+ <Typography variant="caption" noWrap sx={{ maxWidth: '100%' }}>{nameOf(attachment)}</Typography>
108
+ </Stack>}
109
+ </ButtonBase>
110
+ );
111
+ })}
112
+ </Stack>
113
+
114
+ <Dialog open={Boolean(lightbox)} onClose={() => setLightbox(null)} maxWidth="lg" aria-label={lightbox ? nameOf(lightbox.attachment) : undefined}>
115
+ {lightbox && <Box sx={{ position: 'relative', minWidth: 240, minHeight: lightbox.status === 'ready' ? undefined : 160, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
116
+ <Stack direction="row" spacing={0.5} sx={{ position: 'absolute', top: 4, right: 4 }}>
117
+ {lightbox.status === 'ready' && <IconButton aria-label={t('MessagingAttachments.DOWNLOAD_ACTION')} onClick={() => download(lightbox.attachment)} sx={{ bgcolor: 'background.paper', boxShadow: 1 }}>
118
+ <DownloadIcon />
119
+ </IconButton>}
120
+ <IconButton aria-label={t('MessagingAttachments.CLOSE_PREVIEW')} onClick={() => setLightbox(null)} sx={{ bgcolor: 'background.paper', boxShadow: 1 }}>
121
+ <CloseIcon />
122
+ </IconButton>
123
+ </Stack>
124
+ {lightbox.status === 'loading' && <CircularProgress aria-label={t('MessagingAttachments.LOADING_PREVIEW')} sx={{ m: 4 }} />}
125
+ {lightbox.status === 'error' && <Alert severity="error" role="alert" sx={{ m: 2 }}>{t('MessagingAttachments.PREVIEW_ERROR')}</Alert>}
126
+ {lightbox.status === 'ready' && <Box component="img" src={lightbox.url} alt={nameOf(lightbox.attachment)} sx={{ display: 'block', maxWidth: '90vw', maxHeight: '90vh' }} />}
127
+ </Box>}
128
+ </Dialog>
129
+
130
+ <Menu open={Boolean(menu)} onClose={closeMenu} anchorReference="anchorPosition" anchorPosition={menu?.anchorPosition}>
131
+ <MenuItem onClick={() => { const attachment = menu?.attachment; closeMenu(); if (attachment) download(attachment); }}>
132
+ <DownloadIcon fontSize="small" sx={{ mr: 1 }} />{t('MessagingAttachments.DOWNLOAD_ACTION')}
133
+ </MenuItem>
134
+ </Menu>
135
+ </>;
38
136
  }
@@ -136,7 +136,13 @@ export function MessageBubble({ message, replyTo, conversation, onReply, onJumpT
136
136
  </Stack>}
137
137
  </Stack>
138
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 }} />}
139
+ {/* A small positive gap, not the negative-margin "peek from the bottom
140
+ edge" trick this used to have -- that pull was tuned against a
141
+ plain text line and read as an unnatural collision once the
142
+ bubble's actual bottom content became variable (an attachment
143
+ row's bordered Button, or MSG-6g's poll bars), both visually
144
+ boxier than text and closer to the bubble's edge. */}
145
+ {!deleted && (reactions.length > 0 || reactionPickerVisible) && <ReactionBar message={message} expanded={reactionPickerVisible} onExpandedChange={setReactionPickerVisible} sx={{ mt: 0.5, ml: 0.75 }} />}
140
146
  <Menu anchorEl={menuAnchor} open={Boolean(menuAnchor)} onClose={closeMenu}>
141
147
  <MenuItem onClick={startReply}>{t('MessagingThread.REPLY')}</MenuItem>
142
148
  <MenuItem onClick={startReact}>{t('MessagingReactions.ADD')}</MenuItem>
@@ -56,7 +56,9 @@ describe('compact message bubbles', () => {
56
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
57
  expect(container.querySelector('.MuiPaper-root').contains(screen.getByText('Where?'))).toBe(true);
58
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);
59
+ // MSG-6h: a non-image attachment's visible content is now its filename +
60
+ // a file icon (the download semantics live in the accessible name).
61
+ await waitFor(() => expect(screen.getByText('notes.pdf')).toBeTruthy());
62
+ expect(container.querySelector('.MuiPaper-root').contains(screen.getByText('notes.pdf'))).toBe(true);
61
63
  });
62
64
  });
@@ -0,0 +1,136 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
5
+
6
+ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options && Object.keys(options).length ? `${key}:${JSON.stringify(options)}` : key }) }));
7
+ vi.mock('../src/notifications/realtime', () => ({ useRealtime: () => ({ subscribe: () => () => {}, onReconnect: () => () => {} }) }));
8
+
9
+ import { AttachmentList } from '../src/messaging/AttachmentList';
10
+ import { AuthContext } from '../src/auth/AuthContext';
11
+ import { MessageBubble } from '../src/messaging/MessageBubble';
12
+ import { MessagingProvider } from '../src/messaging/MessagingProvider';
13
+
14
+ const imageAttachment = { id: 'att-1', filename: 'vacation-photo.jpg', content_type: 'image/jpeg' };
15
+ const fileAttachment = { id: 'att-2', filename: 'Report.pdf', content_type: 'application/pdf' };
16
+ const fallbackAttachment = { id: 'att-3', content_type: 'application/pdf' }; // no filename -- pre-MSG-12 dcm pin
17
+
18
+ function makeApi(overrides = {}) {
19
+ return {
20
+ listConversations: vi.fn().mockResolvedValue({ results: [] }), listMessages: vi.fn().mockResolvedValue({ results: [] }),
21
+ getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
22
+ getAttachment: vi.fn().mockResolvedValue(new Blob(['full-bytes'], { type: 'image/jpeg' })),
23
+ getAttachmentThumbnail: vi.fn().mockResolvedValue(new Blob(['thumb-bytes'], { type: 'image/jpeg' })),
24
+ ...overrides,
25
+ };
26
+ }
27
+
28
+ afterEach(cleanup);
29
+
30
+ describe('MSG-6h: attachment gallery (thumbnail/lightbox/right-click download)', () => {
31
+ it('1. an image attachment renders a thumbnail; clicking it opens a lightbox showing the full image', async () => {
32
+ const api = makeApi();
33
+ render(<MessagingProvider api={api} active={false}><AttachmentList attachments={[imageAttachment]} /></MessagingProvider>);
34
+ const thumbnail = await screen.findByLabelText(/MessagingAttachments\.PREVIEW/);
35
+ await waitFor(() => expect(api.getAttachmentThumbnail).toHaveBeenCalledWith('att-1'));
36
+
37
+ fireEvent.click(thumbnail);
38
+ await waitFor(() => expect(api.getAttachment).toHaveBeenCalledWith('att-1'));
39
+ const lightboxImage = await screen.findByAltText('vacation-photo.jpg');
40
+ expect(lightboxImage.tagName).toBe('IMG');
41
+ });
42
+
43
+ it('2. right-clicking an image attachment opens a context menu with Download; selecting it downloads', async () => {
44
+ const api = makeApi();
45
+ const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
46
+ render(<MessagingProvider api={api} active={false}><AttachmentList attachments={[imageAttachment]} /></MessagingProvider>);
47
+ const thumbnail = await screen.findByLabelText(/MessagingAttachments\.PREVIEW/);
48
+
49
+ fireEvent.contextMenu(thumbnail);
50
+ const downloadItem = await screen.findByText('MessagingAttachments.DOWNLOAD_ACTION');
51
+ fireEvent.click(downloadItem);
52
+ await waitFor(() => expect(api.getAttachment).toHaveBeenCalledWith('att-1'));
53
+ expect(clickSpy).toHaveBeenCalled();
54
+ clickSpy.mockRestore();
55
+ });
56
+
57
+ it('3. a non-image attachment renders a file indicator, not a broken image; clicking it downloads directly', async () => {
58
+ const api = makeApi();
59
+ const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
60
+ render(<MessagingProvider api={api} active={false}><AttachmentList attachments={[fileAttachment]} /></MessagingProvider>);
61
+ const target = screen.getByLabelText('MessagingAttachments.DOWNLOAD:{"name":"Report.pdf"}');
62
+ expect(target.querySelector('img')).toBeNull();
63
+ expect(screen.getByText('Report.pdf')).toBeTruthy();
64
+
65
+ fireEvent.click(target);
66
+ await waitFor(() => expect(api.getAttachment).toHaveBeenCalledWith('att-2'));
67
+ expect(clickSpy).toHaveBeenCalled();
68
+ clickSpy.mockRestore();
69
+ // No lightbox for a non-image -- nothing to preview.
70
+ expect(screen.queryByRole('dialog')).toBeNull();
71
+ });
72
+
73
+ it('4. nameOf prefers attachment.filename when present, falls back to attachment.id when absent (pre-MSG-12 dcm interop)', () => {
74
+ render(<MessagingProvider api={makeApi()} active={false}><AttachmentList attachments={[fileAttachment, fallbackAttachment]} /></MessagingProvider>);
75
+ expect(screen.getByText('Report.pdf')).toBeTruthy();
76
+ expect(screen.getByText('att-3')).toBeTruthy();
77
+ });
78
+
79
+ it('6. the lightbox itself has a Download action -- reachable without a right-click, closing an accessibility regression (touch/keyboard users could not download an image otherwise)', async () => {
80
+ const api = makeApi();
81
+ const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
82
+ render(<MessagingProvider api={api} active={false}><AttachmentList attachments={[imageAttachment]} /></MessagingProvider>);
83
+ const thumbnail = await screen.findByLabelText(/MessagingAttachments\.PREVIEW/);
84
+ fireEvent.click(thumbnail);
85
+ await screen.findByAltText('vacation-photo.jpg');
86
+ api.getAttachment.mockClear();
87
+
88
+ const downloadButton = screen.getByLabelText('MessagingAttachments.DOWNLOAD_ACTION');
89
+ fireEvent.click(downloadButton);
90
+ await waitFor(() => expect(api.getAttachment).toHaveBeenCalledWith('att-1'));
91
+ expect(clickSpy).toHaveBeenCalled();
92
+ clickSpy.mockRestore();
93
+ });
94
+
95
+ it('7. the lightbox shows a loading state while the full image fetches, and an error state if the fetch fails', async () => {
96
+ let resolveFetch;
97
+ const api = makeApi({ getAttachment: vi.fn(() => new Promise((resolve) => { resolveFetch = resolve; })) });
98
+ render(<MessagingProvider api={api} active={false}><AttachmentList attachments={[imageAttachment]} /></MessagingProvider>);
99
+ const thumbnail = await screen.findByLabelText(/MessagingAttachments\.PREVIEW/);
100
+
101
+ fireEvent.click(thumbnail);
102
+ await screen.findByLabelText('MessagingAttachments.LOADING_PREVIEW');
103
+ expect(screen.queryByAltText('vacation-photo.jpg')).toBeNull();
104
+
105
+ resolveFetch(new Blob(['bytes'], { type: 'image/jpeg' }));
106
+ await screen.findByAltText('vacation-photo.jpg');
107
+ expect(screen.queryByLabelText('MessagingAttachments.LOADING_PREVIEW')).toBeNull();
108
+ });
109
+
110
+ it('8. a failed full-image fetch shows a visible error, not a silent no-op', async () => {
111
+ const api = makeApi({ getAttachment: vi.fn().mockRejectedValue(new Error('network error')) });
112
+ render(<MessagingProvider api={api} active={false}><AttachmentList attachments={[imageAttachment]} /></MessagingProvider>);
113
+ const thumbnail = await screen.findByLabelText(/MessagingAttachments\.PREVIEW/);
114
+
115
+ fireEvent.click(thumbnail);
116
+ await screen.findByRole('alert');
117
+ expect(screen.getByRole('alert').textContent).toBe('MessagingAttachments.PREVIEW_ERROR');
118
+ });
119
+
120
+ it('5. the attachment context menu does not also trigger the message-level action menu', async () => {
121
+ const api = makeApi();
122
+ render(
123
+ <AuthContext.Provider value={{ user: { id: 1 } }}>
124
+ <MessagingProvider api={api} active={false}>
125
+ <MessageBubble message={{ id: 'm1', body: 'see attached', sender: { id: 1 }, attachments: [imageAttachment] }} conversation={{ kind: 'group' }} canModerateMessages />
126
+ </MessagingProvider>
127
+ </AuthContext.Provider>,
128
+ );
129
+ const thumbnail = await screen.findByLabelText(/MessagingAttachments\.PREVIEW/);
130
+
131
+ fireEvent.contextMenu(thumbnail);
132
+ await screen.findByText('MessagingAttachments.DOWNLOAD_ACTION');
133
+ // The message-level menu's own items (e.g. Reply) must not also be open.
134
+ expect(screen.queryByText('MessagingThread.REPLY')).toBeNull();
135
+ });
136
+ });
@@ -0,0 +1,238 @@
1
+ # WORK ORDER MSG-6h (ui-core-micha) — attachment gallery (thumbnail/lightbox/right-click download), reaction overlap
2
+
3
+ **EXECUTION DIRECTIVE.** If you are the implementer reading this as your own spec, this section is not
4
+ addressed to you — it tells the Orchestrator how to invoke you; you ARE that invocation, do not shell
5
+ out to `codex exec`. Orchestrator: implement through `codex exec` in the background, invoked **directly
6
+ via Bash** (never the `debugger`/`*_coder` Agent wrappers) with **both** flags `--skip-git-repo-check`
7
+ and `--dangerously-bypass-approvals-and-sandbox`, prompt passed as a positional argument from this
8
+ file. Fall back to direct Claude implementation only on Codex quota / rate-limit / non-zero exit.
9
+
10
+ Feature-level WO (Expertenchat envelope). Operator report 2026-08-03 from a staging screenshot, seen
11
+ now for the first time end-to-end because `django-core-micha` `MSG-10` just fixed attachment uploads
12
+ (they were unconditionally rejected before — this display code has effectively never been exercised
13
+ against a real upload on the platform path).
14
+
15
+ ## TIER
16
+ Tier 2 — shared-core UI consumed by every app. Independent `reviewer` + `ui_reviewer` mandatory.
17
+
18
+ ## SCOPE
19
+
20
+ **A. Attachment gallery.**
21
+
22
+ Current state (`AttachmentList.jsx`): each attachment renders as a `Button` containing a 36×36px
23
+ thumbnail (if the file is an image and its thumbnail loaded) plus a text label
24
+ `t('MessagingAttachments.DOWNLOAD', { name: nameOf(attachment) })`. `nameOf` is
25
+ `attachment.filename || attachment.name || attachment.id` — since dcm's `serialize_attachment` has
26
+ never returned `filename` (see `django-core-micha` `work-orders/MSG-12.md`, its companion, fixing
27
+ that), this **always** fell through to the raw UUID `id`. Clicking anywhere on the button downloads
28
+ the file — there is no preview step.
29
+
30
+ Operator's target shape, verbatim: *"bitte kleines Vorschaubild, bei Klick darauf grosse Vorschau,
31
+ rechtsklick zum Kontextmenu mit Download (analog Whatsapp)"* — a small thumbnail; clicking it opens a
32
+ large preview (lightbox); right-click opens a context menu with a download action.
33
+
34
+ Build this:
35
+ - **Image attachments**: small thumbnail (reuse the existing `getAttachmentThumbnail` fetch — already
36
+ wired). Click → a lightbox (MUI `Dialog` or `Modal`) showing the full-size image, fetched via the
37
+ existing `getAttachment` blob endpoint. Right-click (`onContextMenu`) → a small context menu (MUI
38
+ `Menu`, matching the pattern already used for message actions in `MessageBubble.jsx`) with a
39
+ Download action, calling the same download logic `AttachmentList` already has.
40
+ - **Non-image attachments** (no thumbnail available): keep a compact file-type indicator (icon +
41
+ filename, once `MSG-12` supplies a real one) as the tap target; single click downloads directly
42
+ (there is nothing to preview) — right-click still offers the same context menu for consistency, even
43
+ though its one item duplicates the click action.
44
+ - Filename: once `MSG-12` ships, `nameOf` should read the real `filename` first; keep the existing
45
+ `|| attachment.id` fallback for interop with a not-yet-bumped dcm pin (an older host may still be on
46
+ a dcm version that omits it) — never let a missing filename break rendering, just fall back visibly.
47
+ - Keep the download-only design principle: bytes never touch anything but the existing authenticated
48
+ `getAttachment`/`getAttachmentThumbnail` blob fetches already in `MessagingProvider`. No new
49
+ endpoints, no new byte-handling path.
50
+
51
+ **B. Reaction bar overlap.**
52
+
53
+ `MessageBubble.jsx:139` renders `ReactionBar` as a sibling *after* the bubble's `Paper`, with
54
+ `sx={{ mt: -0.5, ml: 0.75, position: 'relative', zIndex: 1 }}` — a small negative top margin
55
+ deliberately pulls it up to overlap the bubble's bottom edge (a common "chip peeking out" treatment).
56
+ Operator report: this overlap now reads as broken/unnatural, screenshotted on a message that has
57
+ attachments — worth checking specifically whether the fixed `-0.5` offset was tuned against a
58
+ plain-text bubble and no longer sits right against the bottom of an attachment row or (from `MSG-6g`)
59
+ a poll's result bars, both of which changed the bubble's actual bottom content since this offset was
60
+ last touched.
61
+
62
+ **Live-verify against the operator's original screenshot before considering this closed** — the
63
+ Orchestrator does not have staging credentials this round; if a local repro isn't conclusive, say so
64
+ and ask the operator to confirm the fix visually rather than guessing further.
65
+
66
+ ## NON-GOALS / DO NOT TOUCH
67
+ - Do not touch `PollCard.jsx`, `ReadTicks.jsx`, or conversation titles — unrelated recent WOs.
68
+ - Do not add any new backend endpoint — scope A only consumes what already exists (plus `MSG-12`'s
69
+ additive `filename` field).
70
+ - Do not change the reaction *toggle* logic (`ReactionBar.jsx`'s `react`/`pending` mechanics) — only
71
+ its positioning relative to the bubble, scope B.
72
+ - Do not implement a generic lightbox/gallery component library dependency — build directly with MUI
73
+ primitives already used elsewhere in this codebase (`Dialog`, `Menu`), matching existing patterns.
74
+
75
+ ## RISKS
76
+ - A right-click context menu on a message bubble already exists for message-level actions
77
+ (`MessageBubble.jsx`'s `openMenu`/`canOpenMenu`, triggered by `onContextMenu`). An attachment's own
78
+ right-click context menu must not fight with or accidentally trigger the message-level one when the
79
+ attachment sits inside the bubble — `event.stopPropagation()` where appropriate.
80
+ - Lightbox must be keyboard-dismissable (Escape) and trap focus reasonably — it is a modal.
81
+ - The thumbnail fetch already exists and works (`AttachmentList.jsx`'s current `useEffect`); reuse it,
82
+ don't re-invent — but the full-size lightbox fetch (`getAttachment`) is a new call path, verify it
83
+ doesn't double-fetch what the thumbnail effect already has cached.
84
+
85
+ ## REQUIRED TESTS TO WRITE
86
+ Narrow and behavioural. Do NOT run the full suite.
87
+
88
+ 1. An image attachment renders a thumbnail; clicking it opens a lightbox showing the full image
89
+ (fetched via `getAttachment`).
90
+ 2. Right-clicking an image attachment opens a context menu with a Download action; selecting it
91
+ downloads (same assertion shape as the existing pre-redesign download test, if one exists — check).
92
+ 3. A non-image attachment (no `thumbnail_url`) renders a file indicator, not a broken image; clicking
93
+ it downloads directly.
94
+ 4. `nameOf` prefers `attachment.filename` when present, falls back to `attachment.id` when absent
95
+ (interop with a dcm pin that predates `MSG-12`) — assert both cases render without crashing.
96
+ 5. The attachment's own context menu does not also trigger the message-level action menu (no
97
+ double-open, no propagation leak) — regression guard given `MessageBubble.jsx` already has its own
98
+ `onContextMenu`.
99
+
100
+ ## TEST SCOPE FOR THE GATE (orchestrator)
101
+ Attachment- and reaction-bar-adjacent messaging test files. Not the full suite.
102
+
103
+ ## TARGET REPO
104
+ `C:\Users\biglmi\Documents\webapps\ui-core-micha`. Branch `main`. Publish per the repo's release flow;
105
+ consuming pin bumps (jg-ferien, and `django-core-micha` `MSG-12` for the filename) are separate,
106
+ subsequent steps — **but per standing feedback this session, do not report this WO as "done" until
107
+ those consuming steps have actually landed**, not merely published.
108
+
109
+ ## MINI-HANDOVER (pastable)
110
+
111
+ > Repo: `C:\Users\biglmi\Documents\webapps\ui-core-micha` (branch `main`). Work order:
112
+ > `work-orders/MSG-6h.md` — read it fully, then follow the `orchestrate-codex` skill.
113
+ >
114
+ > Two independent scopes, both operator-reported live on staging: (A) `AttachmentList.jsx` needs a
115
+ > real thumbnail-click-lightbox / right-click-download-menu gallery, WhatsApp-style, instead of a bare
116
+ > download button showing a raw UUID (the UUID-fallback root cause is fixed separately in
117
+ > `django-core-micha` `MSG-12`, a companion WO — consume its new `filename` field with a graceful
118
+ > fallback for an older pin). (B) `MessageBubble.jsx:139`'s `ReactionBar` has a fixed `mt: -0.5`
119
+ > negative-margin overlap trick that now reads as visually broken against certain bubble content
120
+ > (attachments, and possibly `MSG-6g`'s poll bars) — investigate and fix, but flag if it needs a live
121
+ > staging screenshot to confirm rather than guessing blind.
122
+
123
+ ## PROGRESS CONTRACT
124
+ Emit `PLAN: <steps>` up front, then a single-line `PROGRESS: [<n>/<total>] <action>` before every
125
+ relevant action and `PROGRESS: [<n>/<total>] done` on completion, spaced so no gap exceeds ~2 min,
126
+ stdout unbuffered, and exactly one final `RESULT: DONE|BLOCKED <reason>`.
127
+
128
+ ---
129
+
130
+ ## Part B — Implementation map (Orchestrator) / as-built record
131
+
132
+ **Codex was unavailable (out of credits) — implemented directly by the Orchestrator.**
133
+
134
+ ### Scope A — `src/messaging/AttachmentList.jsx` (full rewrite)
135
+
136
+ - Each attachment is now a 64×64 `ButtonBase` tile (not a full-width text button): an image
137
+ attachment shows its existing thumbnail fetch (unchanged `getAttachmentThumbnail` `useEffect`,
138
+ reused verbatim); a non-image attachment shows a generic file icon +
139
+ filename (`InsertDriveFileOutlinedIcon`).
140
+ - Click: image → `openLightbox` (fetches the full-resolution blob via the existing `getAttachment`,
141
+ cached per-attachment in a `fullUrlCacheRef` so a re-open within the same mount doesn't re-fetch);
142
+ non-image → `download` (unchanged logic, just relocated).
143
+ - Right-click (`onContextMenu`): opens a MUI `Menu` (anchored to click position) with one Download
144
+ item, for both image and non-image attachments. `event.stopPropagation()` inside the handler stops
145
+ it from also reaching `MessageBubble.jsx`'s own bubble-level `onContextMenu` (verified with a
146
+ dedicated regression test, item 5 below).
147
+ - Lightbox: a MUI `Dialog` with the full image + a close `IconButton` (also closes via the Dialog's
148
+ own backdrop-click/Escape handling, native to MUI).
149
+ - `nameOf(attachment)` unchanged in shape (`filename || name || id`), now actually resolves to a real
150
+ name once the host's dcm pin includes `MSG-12`; falls back to `id` for an older pin (test 4).
151
+ - 3 new i18n keys: `MessagingAttachments.PREVIEW`, `.CLOSE_PREVIEW`, `.DOWNLOAD_ACTION`. Existing
152
+ `.LABEL`/`.DOWNLOAD` kept — `.DOWNLOAD` is now also the accessible name for a non-image tile (no
153
+ visible "Download X" text on it any more, per the new compact-tile design — updated one pre-existing
154
+ test in `messagingCompactBubble.test.jsx` that asserted on the old visible text).
155
+
156
+ ### Scope B — `src/messaging/MessageBubble.jsx:139`
157
+
158
+ - `ReactionBar`'s `sx` changed from `{ mt: -0.5, ml: 0.75, position: 'relative', zIndex: 1 }` to
159
+ `{ mt: 0.5, ml: 0.75 }` — a small **positive** gap instead of the negative-margin "peek from the
160
+ bubble's bottom edge" trick. That trick pulled the chip up into whatever sits immediately above it
161
+ in normal document flow, which used to always be plain text or the compact meta row; since MSG-6g
162
+ added poll bars and this WO's own gallery redesign changed the attachment row's height/borders, the
163
+ fixed offset started visually cutting into bordered, boxier content instead of text. Also dropped
164
+ `position: relative` / `zIndex: 1`, no longer needed without the overlap.
165
+ - **Not independently visually re-verified against the operator's original staging screenshot** — no
166
+ staging credentials this round (same limitation noted when the ucm pin bump was last reported). The
167
+ fix is a conservative, low-risk "stop overlapping anything" change; flagging per the WO's own
168
+ instruction to say so rather than claim a live-confirmed fix.
169
+
170
+ ### Tests
171
+ `tests/messagingMsg6h.test.jsx` (new) — the 5 WO-required cases. `tests/messagingCompactBubble.test.jsx`
172
+ updated (pre-existing test asserted on now-removed visible DOWNLOAD text). No other existing test
173
+ needed a change (`messagingComposer.test.jsx`'s AttachmentList smoke test only checked the outer
174
+ `aria-label`, unaffected).
175
+
176
+ ### Verification (pre-review)
177
+ Full suite 241 → 246 (5 new), `tsc` clean.
178
+
179
+ ### Independent review: `reviewer` + `ui_reviewer`, both mandatory (author = Orchestrator, not Codex)
180
+
181
+ Both reviews converged on the same core gap from different angles — fixed together in one pass.
182
+
183
+ **`reviewer` — 3 findings:**
184
+ - **R1 (P2, real regression):** the pre-diff design let every attachment (image or not) download via a
185
+ direct click/Enter/Space on a plain `Button`. The redesign made an image's click open the lightbox
186
+ instead, which had only a Close button — the *only* way left to download an image was a real mouse
187
+ right-click, unreachable by keyboard or (with no long-press) touch. **Fixed**: the lightbox now has
188
+ its own Download `IconButton`, so download is reachable via click, Tab+Enter, or touch, without ever
189
+ needing the context menu.
190
+ - **R2 (P2):** the new lightbox fetch had no loading or error state — a click gave no feedback until
191
+ the network round trip resolved, and a failure silently no-opped. **Fixed**: `lightbox.status`
192
+ (`'loading' | 'ready' | 'error'`) drives a `CircularProgress` while fetching and an `Alert` on
193
+ failure; a stale in-flight fetch (opened, then a *different* attachment opened before the first
194
+ resolves) is guarded by comparing the resolved attachment's id against the current lightbox state
195
+ before applying the result.
196
+ - **R3 (P3):** the lightbox `Dialog` had no accessible name. **Fixed**: `aria-label` on the `Dialog`
197
+ itself, set to the previewed attachment's name.
198
+
199
+ Verified correct, no bug: object-URL lifecycle (no leak/double-revoke/revoke-while-displayed), the
200
+ context-menu `stopPropagation` (traced against React's synthetic event dispatch + the dedicated test),
201
+ no stale-closure risk in the menu's download handler, `fullUrlCacheRef` can't go stale across messages
202
+ (`Thread.jsx` keys `MessageBubble` by `message.id`, always unmounts on swap), i18n coverage.
203
+
204
+ **`ui_reviewer` — 6 findings:**
205
+ - **U1 (P1, same root cause as R1):** touch users had no way to download an image at all (no
206
+ long-press implemented, lightbox had no download control). **Fixed by the same lightbox Download
207
+ button as R1** — one fix closes both findings.
208
+ - **U2 (P2):** no visual affordance signals a tile is right-clickable, and right-click-for-download is
209
+ a non-obvious pattern without a fallback for a mouse user who doesn't know it. **Resolved by the R1/U1
210
+ fix, not a separate change**: right-click is no longer the *only* path to anything — an image is
211
+ fully reachable via primary click → lightbox → visible Download button, and a non-image already
212
+ downloads on primary click. Right-click remains a discoverable-if-you-know-it shortcut, not a
213
+ required, hidden one; adding a second, redundant hover-reveal affordance on top of an already-fully-
214
+ reachable primary interaction would be scope creep past what the finding's own root concern requires.
215
+ - **U3 (P2):** a non-image tile's filename truncates in a 64px box with no way to recover the full name
216
+ before downloading. **Fixed**: `title={nameOf(attachment)}` on the tile (native browser tooltip on
217
+ hover).
218
+ - **U4 (P3):** the French `PREVIEW` translation (`'Aperçu de {{name}}'`, noun-first) broke grammatical
219
+ parallelism with the sibling `DOWNLOAD`/`DOWNLOAD_ACTION` (verb-first). **Fixed**: `'Prévisualiser
220
+ {{name}}'`.
221
+ - **U5 (P3):** the lightbox close button omitted the `boxShadow` the codebase's analogous floating-icon
222
+ pattern (`MessageBubble.jsx`'s hover-revealed `MoreVertIcon`) uses for contrast. **Fixed**: both the
223
+ new Download button and the Close button now carry `boxShadow: 1`.
224
+ - **U6 (P3, not fixed, disclosed):** `openLightbox`'s failure path is a silent no-op if `getAttachment`
225
+ is bypassed some other way — the reviewer themselves noted this mirrors `download()`'s own
226
+ pre-existing silent-failure shape, not a new defect class. Left as-is, consistent with that existing
227
+ convention elsewhere in this component; R2's loading/error state above already covers the actual
228
+ reachable failure path (a real fetch rejection).
229
+
230
+ ### Not independently visually re-verified
231
+ Scope B's `ReactionBar` spacing fix (`MessageBubble.jsx:145`) was not confirmed against the operator's
232
+ original staging screenshot — no staging credentials this round. Both reviewers examined it by static
233
+ code reading only and found the fix low-risk (a strict `mt` sign flip, no z-stacking or other side
234
+ effect traced), but this is a judgment call, not a live-rendered confirmation. Flagging per the WO's
235
+ own instruction rather than claiming a visual fix I have not actually seen.
236
+
237
+ ### Re-verification after all fixes
238
+ Full suite 241 → 249 (8 new: 5 original + 3 closing R1/R2), `tsc` clean.