@micha.bigler/ui-core-micha 2.22.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 +3 -1
- package/dist/i18n/messagingTranslations.js +6 -2
- package/dist/messaging/AttachmentList.js +65 -5
- package/dist/messaging/MessageBubble.js +1 -1
- package/dist/messaging/MessagingProvider.js +63 -5
- package/dist/messaging/PollCard.js +83 -23
- package/package.json +1 -1
- package/src/i18n/messagingTranslations.ts +6 -2
- package/src/messaging/AttachmentList.jsx +106 -8
- package/src/messaging/MessageBubble.jsx +7 -1
- package/src/messaging/MessagingProvider.jsx +60 -5
- package/src/messaging/PollCard.jsx +103 -19
- package/tests/messagingCompactBubble.test.jsx +4 -2
- package/tests/messagingInteractions.test.jsx +10 -3
- package/tests/messagingMsg6g.test.jsx +166 -0
- package/tests/messagingMsg6h.test.jsx +136 -0
- package/tests/messagingPollRendering.test.jsx +24 -14
- package/work-orders/MSG-6g.md +244 -0
- package/work-orders/MSG-6h.md +238 -0
package/WORK_ORDERS.md
CHANGED
|
@@ -38,4 +38,6 @@ prefixed ID; never reuse a bare flat number across workstreams.
|
|
|
38
38
|
| MSG-6c | Three MSG-3f/MSG-6 follow-on bugs: scrollbar, DM announcement icon, direct-conversation names | Reported live on jg-ferien staging right after MSG-3f/MSG-6 shipped: (1) a horizontal scrollbar appeared under short bubbles; (2) the Composer showed a broadcast/announcement icon even in a 1:1 direct conversation; (3) direct conversations showed the generic "Unbenannte Unterhaltung" fallback instead of the counterpart's name. | 2026-08-02 | done | 50e966e | No WO file — three operator-reported bugs, fixed directly. **(1) Scrollbar**: `Thread.jsx`'s scroll `Box` had `overflowY: auto` only, leaving `overflowX` at its browser-promoted `auto` default — any few-px overflow anywhere produced a persistent horizontal scrollbar. Fixed with explicit `overflowX: hidden`, plus `MessageBubble.jsx`'s meta row got `flexWrap`/`minWidth: 0` so it can't force the shrink-to-content bubble wider than its `maxWidth` cap. `ui_reviewer` pass 1 found a real P1 regression risk in this fix: MSG-3f's overflow icon (`top: -12, right: -12`) could get clipped by the new `overflowX: hidden` for own/right-aligned bubbles in a short conversation with no vertical scrollbar (the exact scenario in the original bug screenshot) — fixed with extra right padding (moved onto the inner message `Stack`, not the outer scroll container, after `ui_reviewer` pass 2 flagged a P3 cosmetic side effect where the shared container's asymmetric padding skewed the centered "Load older" button; both re-verified live in the harness: no clipping with 8.8px margin, "Load older" back to 0px off-center). **(2) Announcement icon**: `Composer` now accepts a `conversation` prop; `canAnnounce = allowAnnouncement && conversation?.kind !== 'direct'` gates both the icon and the dialog — backward compatible (a host not passing `conversation` behaves exactly as before). **(3) Unnamed conversations**: `ConversationList.jsx`'s `titleOf` now tries a new optional `resolveDirectUserName(other_user_id)` host resolver before falling back to the generic label, consuming dcm's new `other_user_id` field (see django-core-micha `MSG-6b`) — bare id only, ucm still resolves no names itself. Independent `reviewer`: no findings (both passes). Scoped gate: 90/90 across 14 test files. Published as npm 2.21.1, verified live. jg-ferien pin bumped 2.21.0→2.21.1 and wired: `conversation` prop passed to `Composer`, `resolveDirectUserName` supplied from jg's existing DM-candidate data (best-effort — a counterpart no longer among current candidates, e.g. a lapsed shared event, falls back to the generic label; accepted gap, not chased). |
|
|
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
|
-
| 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 |
|
|
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 | 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,16 +70,20 @@ 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' },
|
|
76
81
|
'MessagingReactions.TOGGLE_other': { de: '{{emoji}} Reaktion mit {{count}} Stimmen umschalten', en: 'Toggle {{emoji}} reaction with {{count}} votes', fr: 'Basculer la réaction {{emoji}} avec {{count}} votes' },
|
|
77
82
|
'MessagingReactions.ERROR': { de: 'Reaktion konnte nicht gespeichert werden: {{message}}', en: 'Reaction could not be saved: {{message}}', fr: 'Impossible d’enregistrer la réaction : {{message}}' },
|
|
78
83
|
'MessagingPoll.LABEL': { de: 'Umfrage', en: 'Poll', fr: 'Sondage' },
|
|
79
|
-
'MessagingPoll.VOTE': { de: 'Abstimmen', en: 'Vote', fr: 'Voter' },
|
|
80
84
|
'MessagingPoll.CLOSE': { de: 'Umfrage schliessen', en: 'Close poll', fr: 'Clore le sondage' },
|
|
81
85
|
'MessagingPoll.CLOSED': { de: 'Geschlossen', en: 'Closed', fr: 'Clos' },
|
|
82
|
-
'MessagingPoll.
|
|
86
|
+
'MessagingPoll.OPTION_RESULT': { de: '{{count}} ({{percent}} %)', en: '{{count}} ({{percent}}%)', fr: '{{count}} ({{percent}} %)' },
|
|
83
87
|
'MessagingPoll.VOTE_ERROR': { de: 'Stimme konnte nicht gespeichert werden: {{message}}', en: 'Vote could not be saved: {{message}}', fr: 'Impossible d’enregistrer le vote : {{message}}' },
|
|
84
88
|
'MessagingPoll.CLOSE_ERROR': { de: 'Umfrage konnte nicht geschlossen werden: {{message}}', en: 'Poll could not be closed: {{message}}', fr: 'Impossible de clore le sondage : {{message}}' },
|
|
85
89
|
'MessagingPoll.CREATE': { de: 'Umfrage erstellen', en: 'Create poll', fr: 'Créer un sondage' },
|
|
@@ -1,15 +1,37 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import
|
|
3
|
-
import
|
|
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
|
-
/**
|
|
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
|
-
|
|
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:
|
|
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
|
}
|
|
@@ -18,6 +18,31 @@ function idOf(item) { var _a, _b, _c; return (_c = (_b = (_a = item === null ||
|
|
|
18
18
|
function mergeById(slice, item) { const id = idOf(item); return id == null ? slice : Object.assign(Object.assign({}, slice), { [id]: Object.assign(Object.assign({}, slice[id]), item) }); }
|
|
19
19
|
function resultsOf(response) { return (response === null || response === void 0 ? void 0 : response.results) || []; }
|
|
20
20
|
function clientRequestIdOf(message) { return message === null || message === void 0 ? void 0 : message.client_request_id; }
|
|
21
|
+
// Optimistic vote-count/voters projection for MSG-6g tap-to-vote -- only options whose
|
|
22
|
+
// membership in the vote actually changed get their count/voters adjusted, so an
|
|
23
|
+
// unrelated option's tally is never nudged by someone else's concurrent vote landing in
|
|
24
|
+
// cache between taps.
|
|
25
|
+
function applyOptimisticVote(poll, previousSelectedIds, nextSelectedIds, userId) {
|
|
26
|
+
const previousSet = new Set(previousSelectedIds || []);
|
|
27
|
+
const nextSet = new Set(nextSelectedIds || []);
|
|
28
|
+
const options = (poll.options || []).map((option) => {
|
|
29
|
+
var _a, _b, _c;
|
|
30
|
+
const wasSelected = previousSet.has(option.id);
|
|
31
|
+
const isSelected = nextSet.has(option.id);
|
|
32
|
+
if (wasSelected === isSelected)
|
|
33
|
+
return option;
|
|
34
|
+
const delta = isSelected ? 1 : -1;
|
|
35
|
+
// userId can be undefined for a brief window if AuthContext hasn't
|
|
36
|
+
// populated `user` yet -- never push a literal `undefined` into the
|
|
37
|
+
// voters array (the reconcile/revert below overwrites it regardless,
|
|
38
|
+
// but the optimistic window must not carry a bogus entry).
|
|
39
|
+
const voters = Array.isArray(option.voters) && userId != null
|
|
40
|
+
? (isSelected ? [...option.voters, userId] : option.voters.filter((voter) => voter !== userId))
|
|
41
|
+
: option.voters;
|
|
42
|
+
return Object.assign(Object.assign({}, option), { vote_count: Math.max(0, ((_c = (_a = option.vote_count) !== null && _a !== void 0 ? _a : (_b = option.voters) === null || _b === void 0 ? void 0 : _b.length) !== null && _c !== void 0 ? _c : 0) + delta), voters });
|
|
43
|
+
});
|
|
44
|
+
return Object.assign(Object.assign({}, poll), { options, voted_option_ids: nextSelectedIds });
|
|
45
|
+
}
|
|
21
46
|
/**
|
|
22
47
|
* dcm's DRF validation errors aren't always {detail}: ConversationAttachmentView
|
|
23
48
|
* translates a rejected upload into a field-keyed {"files": [...]} body (the
|
|
@@ -214,6 +239,10 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
|
|
|
214
239
|
const activeIdRef = useRef(activeConversationId);
|
|
215
240
|
const seenEventsRef = useRef(new Set());
|
|
216
241
|
const filtersRef = useRef(filters);
|
|
242
|
+
// Per-poll "latest tap wins" token: rapid successive taps race, and a stale
|
|
243
|
+
// response (revert on failure, or reconcile on success) must not clobber a
|
|
244
|
+
// newer optimistic state that has already superseded it.
|
|
245
|
+
const pollVoteTokensRef = useRef({});
|
|
217
246
|
activeIdRef.current = activeConversationId;
|
|
218
247
|
filtersRef.current = filters;
|
|
219
248
|
const refreshConversations = useCallback(async () => {
|
|
@@ -389,11 +418,40 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
|
|
|
389
418
|
return poll;
|
|
390
419
|
}, [api, patchMessage]);
|
|
391
420
|
const castPollVote = useCallback(async (messageId, poll, optionIds) => {
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
421
|
+
// Optimistic tap-to-vote (MSG-6g), following the same immediate-patch /
|
|
422
|
+
// reconcile-or-revert shape `toggleReaction` already uses. The previous poll
|
|
423
|
+
// (pre-tap) is captured up front so a failure can revert to it exactly, not to
|
|
424
|
+
// whatever `cache.messages` happens to hold when the error arrives.
|
|
425
|
+
const message = cache.messages[messageId] || { id: messageId };
|
|
426
|
+
const previousPoll = message.poll || poll;
|
|
427
|
+
// Project from `previousPoll` (the freshest known state in the shared cache),
|
|
428
|
+
// never from the caller-supplied `poll` param -- `PollCard` is a "dumb",
|
|
429
|
+
// prop-driven component that may still be holding the pre-first-tap `poll`
|
|
430
|
+
// it was originally passed when a second rapid tap fires, and projecting
|
|
431
|
+
// counts from that stale baseline would compute a wrong transient
|
|
432
|
+
// vote_count/voters in the SHARED cache (visible to every consumer, not
|
|
433
|
+
// just this component's own render).
|
|
434
|
+
const optimisticPoll = applyOptimisticVote(previousPoll, previousPoll.voted_option_ids, optionIds, user === null || user === void 0 ? void 0 : user.id);
|
|
435
|
+
const token = {};
|
|
436
|
+
pollVoteTokensRef.current[poll.id] = token;
|
|
437
|
+
patchMessage(Object.assign(Object.assign({}, message), { poll: optimisticPoll }));
|
|
438
|
+
try {
|
|
439
|
+
const result = await api.votePoll(poll.id, optionIds);
|
|
440
|
+
// A newer tap already started (and will reconcile/revert for itself) --
|
|
441
|
+
// applying this now-stale response would stomp on it.
|
|
442
|
+
if (pollVoteTokensRef.current[poll.id] !== token)
|
|
443
|
+
return result;
|
|
444
|
+
const nextPoll = Object.assign(Object.assign({}, poll), ((result === null || result === void 0 ? void 0 : result.poll) || result));
|
|
445
|
+
patchMessage(Object.assign(Object.assign({}, (cache.messages[messageId] || { id: messageId })), { poll: nextPoll }));
|
|
446
|
+
return result;
|
|
447
|
+
}
|
|
448
|
+
catch (error) {
|
|
449
|
+
if (pollVoteTokensRef.current[poll.id] === token) {
|
|
450
|
+
patchMessage(Object.assign(Object.assign({}, (cache.messages[messageId] || { id: messageId })), { poll: previousPoll }));
|
|
451
|
+
}
|
|
452
|
+
throw error;
|
|
453
|
+
}
|
|
454
|
+
}, [api, cache.messages, patchMessage, user]);
|
|
397
455
|
const closeConversationPoll = useCallback(async (messageId, poll) => {
|
|
398
456
|
var _a;
|
|
399
457
|
const result = await api.closePoll(poll.id);
|
|
@@ -1,17 +1,37 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
2
|
+
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
|
|
3
|
+
import { Alert, Button, Checkbox, Chip, FormControlLabel, LinearProgress, Paper, Radio, RadioGroup, Stack, Typography } from '@mui/material';
|
|
4
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
5
|
import { useTranslation } from 'react-i18next';
|
|
5
6
|
import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
|
|
6
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* Independently mountable live poll view. Server authorization remains authoritative.
|
|
9
|
+
*
|
|
10
|
+
* Tap-to-vote (MSG-6g): each option is its own control, cast immediately on tap — no
|
|
11
|
+
* separate submit step. Single-choice uses a real `RadioGroup` (native "tap a different
|
|
12
|
+
* option moves the vote, tap the same one is a no-op" semantics, plus the group's own
|
|
13
|
+
* arrow-key navigation and screen-reader group announcement — a bare ungrouped `Radio`
|
|
14
|
+
* per option, tried first, loses both); multi-select uses `Checkbox` (each tap
|
|
15
|
+
* independently toggles that option). `FormControlLabel` wraps each control, matching
|
|
16
|
+
* this repo's existing radio convention (`AuthFactorRequirementCard.jsx`) — clicking
|
|
17
|
+
* anywhere in the label natively activates the control, no manual click-handling needed.
|
|
18
|
+
* Every tap is optimistic (see `castPollVote` in `MessagingProvider`), dims while
|
|
19
|
+
* in flight (matching `ReactionBar`'s `pending` convention) so an optimistic vote is
|
|
20
|
+
* never visually indistinguishable from a confirmed one, and visibly reverts on a
|
|
21
|
+
* rejected vote.
|
|
22
|
+
*/
|
|
7
23
|
export function PollCard({ message, canClose: canCloseProp }) {
|
|
8
|
-
var _a, _b, _c, _d, _e, _f, _g;
|
|
24
|
+
var _a, _b, _c, _d, _e, _f, _g, _h;
|
|
9
25
|
const { t } = useTranslation();
|
|
10
26
|
const { castPollVote, closeConversationPoll, currentUser } = useMessaging();
|
|
11
27
|
const poll = message === null || message === void 0 ? void 0 : message.poll;
|
|
12
28
|
const [selected, setSelected] = useState([]);
|
|
13
29
|
const [error, setError] = useState(null);
|
|
14
30
|
const [busy, setBusy] = useState(false);
|
|
31
|
+
const [pending, setPending] = useState(() => new Set());
|
|
32
|
+
// Guards a rejected vote's revert against a newer tap that has since
|
|
33
|
+
// superseded it -- see the matching token in MessagingProvider.castPollVote.
|
|
34
|
+
const voteTokenRef = useRef(null);
|
|
15
35
|
// voted_option_ids is a view-level enrichment dcm adds ONLY to the
|
|
16
36
|
// create/vote/close poll REST responses (serialize_poll itself, every
|
|
17
37
|
// message-list/thread load, and every poll_updated realtime frame never
|
|
@@ -26,22 +46,39 @@ export function PollCard({ message, canClose: canCloseProp }) {
|
|
|
26
46
|
const closed = Boolean(poll.closed_at);
|
|
27
47
|
const creatorId = (_c = (_a = poll.created_by_id) !== null && _a !== void 0 ? _a : (_b = poll.created_by) === null || _b === void 0 ? void 0 : _b.id) !== null && _c !== void 0 ? _c : poll.created_by;
|
|
28
48
|
const canClose = (_g = (_e = (_d = canCloseProp !== null && canCloseProp !== void 0 ? canCloseProp : poll.can_close) !== null && _d !== void 0 ? _d : message === null || message === void 0 ? void 0 : message.can_close) !== null && _e !== void 0 ? _e : (_f = message === null || message === void 0 ? void 0 : message.permissions) === null || _f === void 0 ? void 0 : _f.can_close) !== null && _g !== void 0 ? _g : (creatorId != null && creatorId === (currentUser === null || currentUser === void 0 ? void 0 : currentUser.id));
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
if (
|
|
32
|
-
setError(t('MessagingPoll.SELECT_OPTION'));
|
|
49
|
+
const totalVotes = (poll.options || []).reduce((sum, option) => { var _a, _b, _c; return sum + ((_c = (_a = option.vote_count) !== null && _a !== void 0 ? _a : (_b = option.voters) === null || _b === void 0 ? void 0 : _b.length) !== null && _c !== void 0 ? _c : 0); }, 0);
|
|
50
|
+
const tap = async (id) => {
|
|
51
|
+
if (closed)
|
|
33
52
|
return;
|
|
34
|
-
|
|
35
|
-
|
|
53
|
+
const previousSelected = selected;
|
|
54
|
+
const nextSelected = poll.allow_multiple
|
|
55
|
+
? (selected.includes(id) ? selected.filter((value) => value !== id) : [...selected, id])
|
|
56
|
+
: [id];
|
|
57
|
+
// Belt-and-suspenders: native radios already don't fire a change event
|
|
58
|
+
// when re-clicking the checked one, but this guards any caller shape.
|
|
59
|
+
if (nextSelected.length === previousSelected.length && nextSelected.every((value) => previousSelected.includes(value)))
|
|
60
|
+
return;
|
|
61
|
+
const token = {};
|
|
62
|
+
voteTokenRef.current = token;
|
|
63
|
+
setSelected(nextSelected);
|
|
36
64
|
setError(null);
|
|
65
|
+
// Rapid re-taps are allowed and race deliberately (last write wins via
|
|
66
|
+
// the token above) -- `pending` is a visual signal only, never gates
|
|
67
|
+
// further taps, so it must not disable the control.
|
|
68
|
+
setPending((current) => new Set(current).add(id));
|
|
37
69
|
try {
|
|
38
|
-
await castPollVote(message.id, poll,
|
|
70
|
+
await castPollVote(message.id, poll, nextSelected);
|
|
39
71
|
}
|
|
40
72
|
catch (voteError) {
|
|
41
|
-
|
|
73
|
+
// A later tap already moved the selection on; a stale failure must not
|
|
74
|
+
// revert past it.
|
|
75
|
+
if (voteTokenRef.current === token) {
|
|
76
|
+
setSelected(previousSelected);
|
|
77
|
+
setError(t('MessagingPoll.VOTE_ERROR', { message: extractApiErrorMessage(voteError) }));
|
|
78
|
+
}
|
|
42
79
|
}
|
|
43
80
|
finally {
|
|
44
|
-
|
|
81
|
+
setPending((current) => { const next = new Set(current); next.delete(id); return next; });
|
|
45
82
|
}
|
|
46
83
|
};
|
|
47
84
|
const close = async () => {
|
|
@@ -57,14 +94,37 @@ export function PollCard({ message, canClose: canCloseProp }) {
|
|
|
57
94
|
setBusy(false);
|
|
58
95
|
}
|
|
59
96
|
};
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
97
|
+
const optionLabel = (option) => {
|
|
98
|
+
var _a, _b, _c;
|
|
99
|
+
// dcm's poll projection gives voter user ids only, never names — there
|
|
100
|
+
// is no user-directory endpoint (same structural limit already
|
|
101
|
+
// established for the DM candidate list).
|
|
102
|
+
const voteCount = (_c = (_a = option.vote_count) !== null && _a !== void 0 ? _a : (_b = option.voters) === null || _b === void 0 ? void 0 : _b.length) !== null && _c !== void 0 ? _c : 0;
|
|
103
|
+
const percent = totalVotes > 0 ? Math.round((voteCount / totalVotes) * 100) : 0;
|
|
104
|
+
const votedByMe = selected.includes(option.id);
|
|
105
|
+
const resultText = t('MessagingPoll.OPTION_RESULT', { count: voteCount, percent });
|
|
106
|
+
// The bar is decorative; count/percent live in the accessible name (via
|
|
107
|
+
// aria-label on the control itself) as well as in adjacent visible text —
|
|
108
|
+
// width alone must never be the only signal (WO risk note).
|
|
109
|
+
const accessibleLabel = `${option.text || option.label}, ${resultText}${votedByMe ? `, ${t('MessagingPoll.YOU_VOTED')}` : ''}`;
|
|
110
|
+
const richLabel = _jsxs(Stack, { sx: { flex: 1, minWidth: 0, opacity: pending.has(option.id) ? 0.6 : 1 }, spacing: 0.25, children: [_jsxs(Stack, { direction: "row", justifyContent: "space-between", children: [_jsx(Typography, { variant: "body2", noWrap: true, children: option.text || option.label }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: resultText })] }), _jsx(LinearProgress, { variant: "determinate", value: percent, sx: { borderRadius: 1, height: 6 }, color: votedByMe ? 'primary' : 'inherit' })] });
|
|
111
|
+
return { accessibleLabel, richLabel };
|
|
112
|
+
};
|
|
113
|
+
const formControlSx = { width: '100%', m: 0, alignItems: 'flex-start' };
|
|
114
|
+
return _jsx(Paper, { variant: "outlined", sx: { p: 1 }, "aria-label": t('MessagingPoll.LABEL'), children: _jsxs(Stack, { spacing: 0.75, children: [_jsx(Typography, { variant: "subtitle2", children: poll.question }), poll.allow_multiple
|
|
115
|
+
? (poll.options || []).map((option) => {
|
|
116
|
+
const { accessibleLabel, richLabel } = optionLabel(option);
|
|
117
|
+
return _jsx(FormControlLabel, { disabled: closed, sx: formControlSx, label: richLabel, control: _jsx(Checkbox, { checked: selected.includes(option.id), inputProps: { 'aria-label': accessibleLabel }, onChange: () => tap(option.id), sx: { pt: 0.25 } }) }, option.id);
|
|
118
|
+
})
|
|
119
|
+
// No RadioGroup-level onChange: it would only ever hand back
|
|
120
|
+
// `event.target.value`, a DOM string -- coercing a non-string option id
|
|
121
|
+
// (dcm's are UUID strings today, but the id type isn't a guaranteed
|
|
122
|
+
// contract). `value` alone still drives each Radio's `checked` via the
|
|
123
|
+
// group's own comparison; each Radio's own `onChange` below fires
|
|
124
|
+
// `tap` with the option's real, correctly-typed id, matching the
|
|
125
|
+
// Checkbox branch above exactly.
|
|
126
|
+
: _jsx(RadioGroup, { value: (_h = selected[0]) !== null && _h !== void 0 ? _h : '', name: `poll-${poll.id}-options`, children: (poll.options || []).map((option) => {
|
|
127
|
+
const { accessibleLabel, richLabel } = optionLabel(option);
|
|
128
|
+
return _jsx(FormControlLabel, { value: option.id, disabled: closed, sx: formControlSx, label: richLabel, control: _jsx(Radio, { inputProps: { 'aria-label': accessibleLabel }, onChange: () => tap(option.id), sx: { pt: 0.25 } }) }, option.id);
|
|
129
|
+
}) }), error && _jsx(Alert, { severity: "error", role: "alert", children: error }), _jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [canClose && !closed && _jsx(Button, { size: "small", color: "warning", onClick: close, disabled: busy, children: t('MessagingPoll.CLOSE') }), closed && _jsx(Chip, { size: "small", icon: _jsx(LockOutlinedIcon, { fontSize: "small" }), label: t('MessagingPoll.CLOSED') })] })] }) });
|
|
70
130
|
}
|
package/package.json
CHANGED
|
@@ -70,16 +70,20 @@ 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' },
|
|
76
81
|
'MessagingReactions.TOGGLE_other': { de: '{{emoji}} Reaktion mit {{count}} Stimmen umschalten', en: 'Toggle {{emoji}} reaction with {{count}} votes', fr: 'Basculer la réaction {{emoji}} avec {{count}} votes' },
|
|
77
82
|
'MessagingReactions.ERROR': { de: 'Reaktion konnte nicht gespeichert werden: {{message}}', en: 'Reaction could not be saved: {{message}}', fr: 'Impossible d’enregistrer la réaction : {{message}}' },
|
|
78
83
|
'MessagingPoll.LABEL': { de: 'Umfrage', en: 'Poll', fr: 'Sondage' },
|
|
79
|
-
'MessagingPoll.VOTE': { de: 'Abstimmen', en: 'Vote', fr: 'Voter' },
|
|
80
84
|
'MessagingPoll.CLOSE': { de: 'Umfrage schliessen', en: 'Close poll', fr: 'Clore le sondage' },
|
|
81
85
|
'MessagingPoll.CLOSED': { de: 'Geschlossen', en: 'Closed', fr: 'Clos' },
|
|
82
|
-
'MessagingPoll.
|
|
86
|
+
'MessagingPoll.OPTION_RESULT': { de: '{{count}} ({{percent}} %)', en: '{{count}} ({{percent}}%)', fr: '{{count}} ({{percent}} %)' },
|
|
83
87
|
'MessagingPoll.VOTE_ERROR': { de: 'Stimme konnte nicht gespeichert werden: {{message}}', en: 'Vote could not be saved: {{message}}', fr: 'Impossible d’enregistrer le vote : {{message}}' },
|
|
84
88
|
'MessagingPoll.CLOSE_ERROR': { de: 'Umfrage konnte nicht geschlossen werden: {{message}}', en: 'Poll could not be closed: {{message}}', fr: 'Impossible de clore le sondage : {{message}}' },
|
|
85
89
|
'MessagingPoll.CREATE': { de: 'Umfrage erstellen', en: 'Create poll', fr: 'Créer un sondage' },
|