@micha.bigler/ui-core-micha 2.24.0 → 2.25.1

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
@@ -40,4 +40,7 @@ prefixed ID; never reuse a bare flat number across workstreams.
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
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.
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 | 5d6ba6f | 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.
44
+ | MSG-6i | Attachment tile 120px, poll spacing "luftiger", poll % denominator = respondents not sum(votes), wider Composer emoji picker | `AttachmentList.jsx` tiles are 64px. `PollCard.jsx` reads cramped per an operator screenshot. Poll percentages currently divide by `sum(vote_count)`, which for multi-select can exceed respondent count. Composer reuses `ReactionBar`s 5-emoji `QUICK_EMOJIS` for free-form text composition. | 2026-08-03 | done | a6ef578 | Envelope + Part B as-built: [work-orders/MSG-6i.md](work-orders/MSG-6i.md). Operator requirements round after MSG-6h shipped. Scope C deliberately reverses an explicit MSG-6g non-goal ("denominator = votes cast, not participant count") -- operator-confirmed correction, not an oversight. No backend change needed for scope C (union of existing `voters` arrays, client-side). **Implemented directly by the Orchestrator** (Codex unavailable). Also fixed a companion bug found live while verifying jg-ferien's MSG-18: `Thread.jsx`'s root Stack/scroll Box had no flex participation and a hardcoded `maxHeight:560`, so it never shrank to fit a short mobile viewport regardless of host sizing — added `flex:1, minHeight:0` to both (kept `maxHeight:560` as a no-op fallback outside a flex context). **Independent `reviewer` + `ui_reviewer`, run in parallel, 7 findings total, 5 fixed + 2 disclosed-not-fixed** (full detail in the WO's own record): headline finding, confirmed independently by both reviewers — scope D's emoji grid broke `Menu`'s built-in arrow-key roving focus (IconButtons nested under an intervening `role="group"` Box aren't direct children of `MenuList`, so keyboard nav went dead); fixed by switching `Menu`→`Popover` (a grid of buttons was never really a linear menu). `reviewer` also found the optimistic vote projection could desync `vote_count` from `voters` when `userId` is momentarily unknown (the new respondent-count denominator reads only `voters`) — fixed with a sentinel voter id so both always move together; and that the `Thread.jsx` companion fix had zero regression coverage — added a `getComputedStyle`-based test. `ui_reviewer` P3s: the emoji grid's `aria-label` duplicated the trigger button's own label (fixed with a dedicated `EMOJI_PICKER` key); a hardcoded `fontSize` on the emoji buttons (no theme token exists for this, left as-is, disclosed). Full suite (single-forked, an unrelated worker-pool timeout under the default parallel pool made this necessary) 249 → 256 (7 new), `tsc` clean. Publishing as npm 2.25.0. |
45
+ | NOTIF-14 | Push-preview toggle in NotificationSettings | Companion to `django-core-micha` MSG-13 (landed `39135e9`), which added a per-user `NotificationPreference.push_preview_opt_in` backend field (default on) that hides sender/text from a push body when off, already exposed on the `notifications/preferences/` endpoint — but nothing in ucm reads or writes it yet, so there is no UI path to reach it. | 2026-08-04 | done | 72e6697 | Envelope: [work-orders/NOTIF-14.md](work-orders/NOTIF-14.md). **Codex → Claude fallback**: Codex exited immediately on "workspace out of credits" (twice) before writing any code, so this session implemented directly — author-flip rule applied, independent `reviewer` ran before commit. Adds a third `Switch` to `NotificationSettings.jsx`, mirroring the existing `email_opt_in` toggle shape exactly (not the more involved device-subscription push toggle). **Load-bearing subtlety from the envelope, confirmed correct by `reviewer`:** `push_preview_opt_in` defaults `True` on the backend (opposite of `push_opt_in`'s `False` default) — checked as `!== false`, not `Boolean(...)`, with a non-vacuous test (field absent from the mocked API response) proving it. UX call from the Expertenchat, implemented as specified: the toggle is disabled unless `push_opt_in` is already on. Two new i18n keys across all four languages (de/fr/en/sw). While writing the new tests, surfaced and fixed two latent, pre-existing gaps in `tests/NotificationSettings.test.jsx` that had simply never been exercised before (the file had only ever rendered the component twice total): no `afterEach(cleanup)` between tests (added, matches the convention already used in ~33 other test files in this repo), and the file's mocked `useTranslation` returned a fresh `t` function reference on every call, unlike real react-i18next (which memoizes it) — this re-triggered the component's `useEffect(..., [t])` on every re-render in tests and clobbered a toggle's own state update with a stale refetch; fixed by hoisting `t` to a module-level stable reference in the test file only, production code untouched. `reviewer` independently confirmed both are legitimate test-infra fixes, not a production defect. Gate: `tests/NotificationSettings.test.jsx` 2→8 passed; full suite (measured baseline via `git stash -u`) 256 passed/0 failed → after 262 passed/0 failed, 0 regressions; `tsc -p tsconfig.build.json --noEmit` clean. Tier 2 (ui-core-micha is shared-core, forces Tier 2 regardless of size per AGENTS.md) — `sec_reviewer` not needed (the content-in-push security question was already ruled in dcm MSG-13; this WO only wires an already-shipped, already-reviewed backend preference into the settings UI). |
46
+ | SHELL-1 | A shared user menu: avatar, profile, logout | Measured 2026-08-04: **seven render sites across two apps** for two adjacent actions — spesix has an account link (`:184`) plus a separate logout (`:224`), and the same pair again at `:269`/`:307` for mobile; survey_app has account nav (`:67`), logout desktop (`:152`) and logout mobile (`:90`); jg-ferien has an avatar linking to `/account` and **no header logout at all** (it sits at the bottom of `/more`). The duplication is worse *within* each app than between them. ucm exports nothing header-shaped today and has no avatar component, though it has owned `logout` in `auth/AuthContext.jsx:132` all along. | 2026-08-04 | planned | | Envelope: [work-orders/SHELL-1.md](work-orders/SHELL-1.md). New `SHELL-*` prefix for shared application-shell surfaces, distinct from `MSG-*`/`NOTIF-*`. **Design settled with the operator against an interactive prototype — the boundary is not to be renegotiated:** ucm owns the avatar, the menu, Profil (`/account`, verified identical in all four apps) and Abmelden; the host passes only account-related entries, already permission-filtered and already translated; **the context/structure picker never enters the menu.** That last point is measured, not assumed — spesix's `activeSchoolId` is consumed by **13 files** (Approvals/Budgets/Claims/AppLayout) and jg's `selectedEventId` by **18**; it re-scopes the entire application's data, so it stays a permanently visible control of its own. Operator 2026-08-04: org/structure pickers are app-specific and already solved where they live; a second route to them here would be the very duplication this WO removes. **Expected integration for spesix and survey_app is `<UserMenu resolveLink={navigate} />` and nothing else** — only jg passes an entry. Two traps carried over from elsewhere in this estate: an earlier prototype had Benachrichtigungen as a ucm built-in, but **only jg** embeds `NotificationSettings` and `?tab=notifications` exists only there, so three apps would have got a menu entry pointing at a route they lack — verify every built-in against every app; and **labels arrive as finished strings, never i18n keys**, which is exactly why `django-core-micha` MSG-13 is currently broken. Stated cost: Profil goes from one tap to two in every app. hram is **unverified** (no header file under `components/`) — do not claim four apps benefit until someone looks. Scope E folds in MSG-13's preview `Switch`, which is not deliverable from dcm; ship A-D without it if the dcm preference has not landed. Host header rewires are four separate small WOs, one per app, including removing jg's now-redundant local `UserAvatar`. |
@@ -1,5 +1,6 @@
1
1
  export const messagingTranslations = {
2
2
  'MessagingComposer.ADD_EMOJI': { de: 'Emoji hinzufügen', en: 'Add emoji', fr: 'Ajouter un emoji' },
3
+ 'MessagingComposer.EMOJI_PICKER': { de: 'Emoji auswählen', en: 'Choose an emoji', fr: 'Choisir un emoji' },
3
4
  'MessagingComposer.STAGED_FILES': { de: 'Vorgemerkte Anhänge', en: 'Staged attachments', fr: 'Pièces jointes préparées' },
4
5
  'MessagingComposer.REMOVE_ATTACHMENT': { de: '{{name}} entfernen', en: 'Remove {{name}}', fr: 'Supprimer {{name}}' },
5
6
  'MessagingComposer.UPLOADING': { de: 'Anhänge werden hochgeladen…', en: 'Uploading attachments…', fr: 'Téléversement des pièces jointes…' },
@@ -10,6 +10,8 @@ export const notificationsTranslations = {
10
10
  'NotificationSettings.EMAIL_HINT': { de: 'Erhalte wichtige Informationen per E-Mail.', fr: 'Recevez les informations importantes par e-mail.', en: 'Receive important information by email.', sw: 'Pokea taarifa muhimu kwa barua pepe.' },
11
11
  'NotificationSettings.PUSH_LABEL': { de: 'Push-Benachrichtigungen', fr: 'Notifications push', en: 'Push notifications', sw: 'Arifa za push' },
12
12
  'NotificationSettings.PUSH_HINT': { de: 'Aktiviere Benachrichtigungen für dieses Gerät.', fr: 'Activez les notifications pour cet appareil.', en: 'Enable notifications for this device.', sw: 'Washa arifa kwa kifaa hiki.' },
13
+ 'NotificationSettings.PUSH_PREVIEW_LABEL': { de: 'Vorschau in Push-Benachrichtigungen', fr: 'Aperçu dans les notifications push', en: 'Preview in push notifications', sw: 'Muhtasari katika arifa za push' },
14
+ 'NotificationSettings.PUSH_PREVIEW_HINT': { de: 'Zeigt Absender und Nachrichtentext in der Push-Benachrichtigung. Bei Deaktivierung erhältst du weiterhin Benachrichtigungen, aber ohne Inhalt.', fr: 'Affiche l’expéditeur et le texte du message dans la notification push. Si désactivé, vous recevez toujours les notifications, mais sans contenu.', en: 'Shows the sender and message text in the push notification. When off, you still get notified, just without the content.', sw: 'Inaonyesha mtumaji na maandishi ya ujumbe kwenye arifa ya push. Ikizimwa, bado utaarifiwa, lakini bila maudhui.' },
13
15
  'NotificationSettings.PUSH_ENABLE': { de: 'Push aktivieren', fr: 'Activer les notifications push', en: 'Enable push', sw: 'Washa push' },
14
16
  'NotificationSettings.PUSH_DISABLE': { de: 'Push auf diesem Gerät deaktivieren', fr: 'Désactiver les notifications push sur cet appareil', en: 'Disable push on this device', sw: 'Zima push kwenye kifaa hiki' },
15
17
  'NotificationSettings.PUSH_DENIED': { de: 'Die Browserberechtigung für Push-Benachrichtigungen wurde verweigert.', fr: 'L’autorisation du navigateur pour les notifications push a été refusée.', en: 'Browser permission for push notifications was denied.', sw: 'Ruhusa ya kivinjari kwa arifa za push imekataliwa.' },
@@ -95,9 +95,9 @@ export function AttachmentList({ attachments = [] }) {
95
95
  const image = isImage(attachment);
96
96
  const preview = previews[attachment.id];
97
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
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: 120, height: 120, borderRadius: 1, overflow: 'hidden', border: '1px solid', borderColor: 'divider', display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'action.hover' }, children: image && preview
99
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));
100
+ : _jsxs(Stack, { alignItems: "center", spacing: 0.75, sx: { px: 1, maxWidth: '100%' }, children: [_jsx(InsertDriveFileOutlinedIcon, { fontSize: "large" }), _jsx(Typography, { variant: "caption", noWrap: true, sx: { maxWidth: '100%' }, children: nameOf(attachment) })] }) }, attachment.id));
101
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
102
  download(attachment); }, children: [_jsx(DownloadIcon, { fontSize: "small", sx: { mr: 1 } }), t('MessagingAttachments.DOWNLOAD_ACTION')] }) })] });
103
103
  }
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Alert, Box, Button, Checkbox, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, IconButton, LinearProgress, Menu, MenuItem, Stack, TextField, Typography } from '@mui/material';
2
+ import { Alert, Box, Button, Checkbox, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, IconButton, LinearProgress, Popover, Stack, TextField, Typography } from '@mui/material';
3
3
  import AttachFileIcon from '@mui/icons-material/AttachFile';
4
4
  import CampaignOutlinedIcon from '@mui/icons-material/CampaignOutlined';
5
5
  import CloseIcon from '@mui/icons-material/Close';
@@ -9,7 +9,17 @@ import { useEffect, useRef, useState } from 'react';
9
9
  import { useTranslation } from 'react-i18next';
10
10
  import { compressImageForUpload } from './composerImageCompression';
11
11
  import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
12
- import { QUICK_EMOJIS } from './ReactionBar';
12
+ // A wider, Composer-only set (MSG-6i) -- deliberately separate from
13
+ // ReactionBar's own QUICK_EMOJIS (5, curated for fast reactions). Mobile
14
+ // users already have a full native emoji keyboard for free-form text; this
15
+ // exists for desktop, where that's far less discoverable. Not a search
16
+ // picker -- a fixed curated grid, easy to extend later.
17
+ const COMPOSER_EMOJIS = [
18
+ '😀', '😂', '😊', '😍', '😉', '😢', '😮', '😡',
19
+ '👍', '👎', '👏', '🙏', '💪', '🤝', '✌️', '👋',
20
+ '❤️', '💕', '🔥', '🎉', '🎈', '⭐', '✅', '❌',
21
+ '☀️', '🌧️', '⚽', '🍕',
22
+ ];
13
23
  function newRequestId() { var _a, _b; return ((_b = (_a = globalThis.crypto) === null || _a === void 0 ? void 0 : _a.randomUUID) === null || _b === void 0 ? void 0 : _b.call(_a)) || `message-${Date.now()}-${Math.random().toString(36).slice(2)}`; }
14
24
  function isImage(file) { var _a; return (_a = file.type) === null || _a === void 0 ? void 0 : _a.startsWith('image/'); }
15
25
  /**
@@ -157,5 +167,5 @@ export function Composer({ conversationId, conversation, replyTarget = null, onR
157
167
  return _jsxs(Stack, { component: "form", spacing: 1, onSubmit: (event) => { event.preventDefault(); submit(); }, "aria-label": t('MessagingComposer.LABEL'), children: [replyTarget && _jsx(Alert, { severity: "info", onClose: () => onReplyTargetChange === null || onReplyTargetChange === void 0 ? void 0 : onReplyTargetChange(null), children: t('MessagingComposer.REPLYING_TO', { sender: ((_a = replyTarget.sender) === null || _a === void 0 ? void 0 : _a.display_name) || t('MessagingThread.UNKNOWN_SENDER') }) }), error && _jsx(Alert, { severity: "error", role: "alert", children: error }), files.length > 0 && _jsx(Stack, { direction: "row", spacing: 0.5, flexWrap: "wrap", "aria-label": t('MessagingComposer.STAGED_FILES'), children: files.map((file, index) => _jsxs(Stack, { direction: "row", alignItems: "center", spacing: 0.25, sx: { border: 1, borderColor: 'divider', borderRadius: 1, p: 0.25 }, children: [previews[index] && _jsx(Box, { component: "img", src: previews[index], alt: file.name, width: 36, height: 36, sx: { objectFit: 'cover' } }), _jsx(Typography, { variant: "caption", children: file.name }), _jsx(IconButton, { type: "button", size: "small", "aria-label": t('MessagingComposer.REMOVE_ATTACHMENT', { name: file.name }), onClick: () => removeFile(index), children: _jsx(CloseIcon, { fontSize: "inherit" }) })] }, `${file.name}-${index}`)) }), files.length > 0 && _jsx(Typography, { variant: "caption", children: t('MessagingComposer.FILES_SELECTED', { count: files.length }) }), _jsxs(Stack, { direction: "row", spacing: 1, alignItems: "flex-end", children: [_jsx("input", { ref: inputRef, hidden: true, type: "file", multiple: true, onChange: (event) => setFiles(Array.from(event.target.files || [])) }), _jsx(IconButton, { type: "button", "aria-label": t('MessagingComposer.ADD_ATTACHMENT'), onClick: () => { var _a; return (_a = inputRef.current) === null || _a === void 0 ? void 0 : _a.click(); }, children: _jsx(AttachFileIcon, {}) }), _jsx(IconButton, { type: "button", "aria-label": t('MessagingComposer.ADD_EMOJI'), onClick: (event) => setEmojiAnchor(event.currentTarget), children: _jsx(EmojiEmotionsOutlinedIcon, {}) }), _jsx(IconButton, { type: "button", "aria-label": t('MessagingPoll.CREATE'), disabled: pollOpen || files.some(isImage), onClick: () => setPollOpen(true), children: _jsx(PollOutlinedIcon, {}) }), canAnnounce && _jsx(IconButton, { type: "button", "aria-label": t('MessagingAnnouncement.CREATE'), onClick: () => setAnnouncementOpen(true), children: _jsx(CampaignOutlinedIcon, {}) }), _jsx(TextField, { inputRef: messageInputRef, fullWidth: true, multiline: true, minRows: 2, value: body, onChange: (event) => setBody(event.target.value), onKeyDown: (event) => { if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
158
168
  event.preventDefault();
159
169
  submit();
160
- } }, label: t('MessagingComposer.MESSAGE'), disabled: sending }), _jsx(Button, { type: "submit", variant: "contained", disabled: sending || (!body.trim() && !files.length), children: t('MessagingComposer.SEND') })] }), sending && files.length > 0 && _jsxs(Stack, { spacing: 0.5, role: "status", children: [_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [uploadProgress == null ? _jsx(CircularProgress, { size: 16 }) : _jsx(CircularProgress, { size: 16, variant: "determinate", value: uploadProgress }), _jsx(Typography, { variant: "caption", children: t('MessagingComposer.UPLOADING') })] }), uploadProgress != null && _jsx(LinearProgress, { variant: "determinate", value: uploadProgress })] }), failed && _jsx(Button, { type: "button", onClick: () => submit(failed.requestId, true), disabled: sending, children: t('MessagingComposer.RETRY') }), _jsx(Menu, { anchorEl: emojiAnchor, open: Boolean(emojiAnchor), onClose: () => setEmojiAnchor(null), children: QUICK_EMOJIS.map((emoji) => _jsx(MenuItem, { onClick: () => insertEmoji(emoji), children: emoji }, emoji)) }), _jsxs(Dialog, { open: pollOpen, onClose: () => !sending && setPollOpen(false), fullWidth: true, maxWidth: "sm", children: [_jsx(DialogTitle, { children: t('MessagingPoll.CREATE') }), _jsx(DialogContent, { children: _jsxs(Stack, { spacing: 1, sx: { pt: 1 }, children: [_jsx(TextField, { label: t('MessagingPoll.QUESTION'), value: pollQuestion, onChange: (event) => setPollQuestion(event.target.value), autoFocus: true }), pollOptions.map((option, index) => _jsx(TextField, { label: t('MessagingPoll.OPTION', { count: index + 1 }), value: option, onChange: (event) => setPollOptions((current) => current.map((value, optionIndex) => optionIndex === index ? event.target.value : value)) }, index)), _jsx(Button, { type: "button", disabled: pollOptions.length >= 10, onClick: () => setPollOptions((current) => [...current, '']), children: t('MessagingPoll.ADD_OPTION') }), _jsx(FormControlLabel, { control: _jsx(Checkbox, { checked: allowMultiple, onChange: (event) => setAllowMultiple(event.target.checked) }), label: t('MessagingPoll.ALLOW_MULTIPLE') })] }) }), _jsxs(DialogActions, { children: [_jsx(Button, { onClick: () => setPollOpen(false), children: t('MessagingPoll.CANCEL') }), _jsx(Button, { variant: "contained", onClick: submitPoll, disabled: sending, children: t('MessagingPoll.CREATE') })] })] }), canAnnounce && _jsxs(Dialog, { open: announcementOpen, onClose: () => !sending && setAnnouncementOpen(false), fullWidth: true, maxWidth: "sm", children: [_jsx(DialogTitle, { children: t('MessagingAnnouncement.CREATE') }), _jsx(DialogContent, { children: _jsxs(Stack, { spacing: 1, sx: { pt: 1 }, children: [_jsx(TextField, { label: t('MessagingAnnouncement.TITLE'), value: announcementTitle, onChange: (event) => setAnnouncementTitle(event.target.value), autoFocus: true }), _jsx(TextField, { label: t('MessagingAnnouncement.BODY'), value: announcementBody, onChange: (event) => setAnnouncementBody(event.target.value), multiline: true, minRows: 2 })] }) }), _jsxs(DialogActions, { children: [_jsx(Button, { onClick: () => setAnnouncementOpen(false), children: t('MessagingAnnouncement.CANCEL') }), _jsx(Button, { variant: "contained", onClick: submitAnnouncement, disabled: sending, children: t('MessagingAnnouncement.SEND') })] })] })] });
170
+ } }, label: t('MessagingComposer.MESSAGE'), disabled: sending }), _jsx(Button, { type: "submit", variant: "contained", disabled: sending || (!body.trim() && !files.length), children: t('MessagingComposer.SEND') })] }), sending && files.length > 0 && _jsxs(Stack, { spacing: 0.5, role: "status", children: [_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [uploadProgress == null ? _jsx(CircularProgress, { size: 16 }) : _jsx(CircularProgress, { size: 16, variant: "determinate", value: uploadProgress }), _jsx(Typography, { variant: "caption", children: t('MessagingComposer.UPLOADING') })] }), uploadProgress != null && _jsx(LinearProgress, { variant: "determinate", value: uploadProgress })] }), failed && _jsx(Button, { type: "button", onClick: () => submit(failed.requestId, true), disabled: sending, children: t('MessagingComposer.RETRY') }), _jsx(Popover, { anchorEl: emojiAnchor, open: Boolean(emojiAnchor), onClose: () => setEmojiAnchor(null), anchorOrigin: { vertical: 'bottom', horizontal: 'left' }, transformOrigin: { vertical: 'top', horizontal: 'left' }, children: _jsx(Box, { role: "group", "aria-label": t('MessagingComposer.EMOJI_PICKER'), sx: { display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 0.25, p: 0.5, maxWidth: 280 }, children: COMPOSER_EMOJIS.map((emoji) => (_jsx(IconButton, { type: "button", size: "small", onClick: () => insertEmoji(emoji), sx: { fontSize: '1.15rem' }, children: emoji }, emoji))) }) }), _jsxs(Dialog, { open: pollOpen, onClose: () => !sending && setPollOpen(false), fullWidth: true, maxWidth: "sm", children: [_jsx(DialogTitle, { children: t('MessagingPoll.CREATE') }), _jsx(DialogContent, { children: _jsxs(Stack, { spacing: 1, sx: { pt: 1 }, children: [_jsx(TextField, { label: t('MessagingPoll.QUESTION'), value: pollQuestion, onChange: (event) => setPollQuestion(event.target.value), autoFocus: true }), pollOptions.map((option, index) => _jsx(TextField, { label: t('MessagingPoll.OPTION', { count: index + 1 }), value: option, onChange: (event) => setPollOptions((current) => current.map((value, optionIndex) => optionIndex === index ? event.target.value : value)) }, index)), _jsx(Button, { type: "button", disabled: pollOptions.length >= 10, onClick: () => setPollOptions((current) => [...current, '']), children: t('MessagingPoll.ADD_OPTION') }), _jsx(FormControlLabel, { control: _jsx(Checkbox, { checked: allowMultiple, onChange: (event) => setAllowMultiple(event.target.checked) }), label: t('MessagingPoll.ALLOW_MULTIPLE') })] }) }), _jsxs(DialogActions, { children: [_jsx(Button, { onClick: () => setPollOpen(false), children: t('MessagingPoll.CANCEL') }), _jsx(Button, { variant: "contained", onClick: submitPoll, disabled: sending, children: t('MessagingPoll.CREATE') })] })] }), canAnnounce && _jsxs(Dialog, { open: announcementOpen, onClose: () => !sending && setAnnouncementOpen(false), fullWidth: true, maxWidth: "sm", children: [_jsx(DialogTitle, { children: t('MessagingAnnouncement.CREATE') }), _jsx(DialogContent, { children: _jsxs(Stack, { spacing: 1, sx: { pt: 1 }, children: [_jsx(TextField, { label: t('MessagingAnnouncement.TITLE'), value: announcementTitle, onChange: (event) => setAnnouncementTitle(event.target.value), autoFocus: true }), _jsx(TextField, { label: t('MessagingAnnouncement.BODY'), value: announcementBody, onChange: (event) => setAnnouncementBody(event.target.value), multiline: true, minRows: 2 })] }) }), _jsxs(DialogActions, { children: [_jsx(Button, { onClick: () => setAnnouncementOpen(false), children: t('MessagingAnnouncement.CANCEL') }), _jsx(Button, { variant: "contained", onClick: submitAnnouncement, disabled: sending, children: t('MessagingAnnouncement.SEND') })] })] })] });
161
171
  }
Binary file
@@ -46,7 +46,18 @@ export function PollCard({ message, canClose: canCloseProp }) {
46
46
  const closed = Boolean(poll.closed_at);
47
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;
48
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));
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);
49
+ // Percentage denominator (MSG-6i): the number of distinct people who
50
+ // answered this poll, not sum(vote_count) across options. For a
51
+ // multi-select poll one person can vote for several options, so summing
52
+ // vote_count can exceed the actual respondent count -- dividing by that
53
+ // inflated sum understated everyone's percentage and the bars didn't sum
54
+ // to a sensible 100%. dcm's serialize_poll always includes each option's
55
+ // `voters` (a list of user ids, ungated -- unlike messaging read
56
+ // receipts); the union across all options is exactly "everyone who
57
+ // answered", computed client-side, no backend change. This deliberately
58
+ // reverses MSG-6g's own explicit non-goal ("denominator = votes cast, not
59
+ // participant count") -- an operator-confirmed correction, not a revert.
60
+ const respondentCount = new Set((poll.options || []).flatMap((option) => option.voters || [])).size;
50
61
  const tap = async (id) => {
51
62
  if (closed)
52
63
  return;
@@ -100,18 +111,22 @@ export function PollCard({ message, canClose: canCloseProp }) {
100
111
  // is no user-directory endpoint (same structural limit already
101
112
  // established for the DM candidate list).
102
113
  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;
114
+ const percent = respondentCount > 0 ? Math.round((voteCount / respondentCount) * 100) : 0;
104
115
  const votedByMe = selected.includes(option.id);
105
116
  const resultText = t('MessagingPoll.OPTION_RESULT', { count: voteCount, percent });
106
117
  // The bar is decorative; count/percent live in the accessible name (via
107
118
  // aria-label on the control itself) as well as in adjacent visible text —
108
119
  // width alone must never be the only signal (WO risk note).
109
120
  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' })] });
121
+ // MSG-6i scope B: "airier, not wider" (operator, after seeing a
122
+ // screenshot of the checkbox/text/percent squeezed onto one tight line
123
+ // with a thin bar close to the text) -- more internal spacing/a taller
124
+ // bar, not a wider bubble (MessageBubble.jsx's maxWidth is untouched).
125
+ const richLabel = _jsxs(Stack, { sx: { flex: 1, minWidth: 0, opacity: pending.has(option.id) ? 0.6 : 1 }, spacing: 0.75, 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: 8 }, color: votedByMe ? 'primary' : 'inherit' })] });
111
126
  return { accessibleLabel, richLabel };
112
127
  };
113
128
  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
129
+ return _jsx(Paper, { variant: "outlined", sx: { p: 1.5 }, "aria-label": t('MessagingPoll.LABEL'), children: _jsxs(Stack, { spacing: 1.5, children: [_jsx(Typography, { variant: "subtitle2", children: poll.question }), poll.allow_multiple
115
130
  ? (poll.options || []).map((option) => {
116
131
  const { accessibleLabel, richLabel } = optionLabel(option);
117
132
  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);
@@ -170,7 +170,7 @@ export function Thread({ conversationId, onReplyTargetChange, canModerateMessage
170
170
  }
171
171
  setOpenThreads((current) => (Object.assign(Object.assign({}, current), { [root.id]: !current[root.id] })));
172
172
  };
173
- return (_jsxs(Stack, { spacing: 1, sx: { minWidth: 0 }, children: [error && _jsx(Alert, { severity: "error", role: "alert", children: error }), replyingTo && _jsx(Alert, { severity: "info", onClose: () => { setReplyingTo(null); onReplyTargetChange === null || onReplyTargetChange === void 0 ? void 0 : onReplyTargetChange(null); }, children: t('MessagingThread.REPLYING_TO', { sender: ((_a = replyingTo.sender) === null || _a === void 0 ? void 0 : _a.display_name) || t('MessagingThread.UNKNOWN_SENDER') }) }), _jsxs(Box, { ref: scrollRef, onScroll: onScroll, sx: { maxHeight: 560, overflowY: 'auto', overflowX: 'hidden', px: 0.5 }, "aria-label": t('MessagingThread.TIMELINE'), children: [cache.cursors.messages[conversationId] && _jsx(Box, { textAlign: "center", py: 1, children: _jsx(Button, { onClick: loadOlder, disabled: loadingOlder, children: loadingOlder ? _jsx(CircularProgress, { size: 18 }) : t('MessagingThread.LOAD_OLDER') }) }), loadingInitial && !roots.length ? _jsx(Box, { textAlign: "center", py: 4, children: _jsx(CircularProgress, { size: 20 }) }) : (!roots.length && _jsx(Typography, { color: "text.secondary", textAlign: "center", py: 4, children: t('MessagingThread.EMPTY') })), _jsx(Stack, { spacing: 1, sx: { pr: 2 }, children: datedRoots.map(({ message, showDateSeparator }) => {
173
+ return (_jsxs(Stack, { spacing: 1, sx: { minWidth: 0, flex: 1, minHeight: 0 }, children: [error && _jsx(Alert, { severity: "error", role: "alert", children: error }), replyingTo && _jsx(Alert, { severity: "info", onClose: () => { setReplyingTo(null); onReplyTargetChange === null || onReplyTargetChange === void 0 ? void 0 : onReplyTargetChange(null); }, children: t('MessagingThread.REPLYING_TO', { sender: ((_a = replyingTo.sender) === null || _a === void 0 ? void 0 : _a.display_name) || t('MessagingThread.UNKNOWN_SENDER') }) }), _jsxs(Box, { ref: scrollRef, onScroll: onScroll, sx: { flex: 1, minHeight: 0, maxHeight: 560, overflowY: 'auto', overflowX: 'hidden', px: 0.5 }, "aria-label": t('MessagingThread.TIMELINE'), children: [cache.cursors.messages[conversationId] && _jsx(Box, { textAlign: "center", py: 1, children: _jsx(Button, { onClick: loadOlder, disabled: loadingOlder, children: loadingOlder ? _jsx(CircularProgress, { size: 18 }) : t('MessagingThread.LOAD_OLDER') }) }), loadingInitial && !roots.length ? _jsx(Box, { textAlign: "center", py: 4, children: _jsx(CircularProgress, { size: 20 }) }) : (!roots.length && _jsx(Typography, { color: "text.secondary", textAlign: "center", py: 4, children: t('MessagingThread.EMPTY') })), _jsx(Stack, { spacing: 1, sx: { pr: 2 }, children: datedRoots.map(({ message, showDateSeparator }) => {
174
174
  const replies = chronological(Object.values(cache.messages).filter((item) => String(replyToId(item)) === String(message.id)));
175
175
  return _jsxs(Stack, { spacing: 0.75, children: [showDateSeparator && _jsxs(Stack, { "data-testid": "day-separator", direction: "row", alignItems: "center", spacing: 1, children: [_jsx(Divider, { sx: { flex: 1 } }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: new Date(message.created_at).toLocaleDateString(i18n === null || i18n === void 0 ? void 0 : i18n.language) }), _jsx(Divider, { sx: { flex: 1 } })] }), _jsx(MessageBubble, { message: message, conversation: conversation, onReply: reply, onJumpToMessage: jumpToMessage, onAnnouncementLink: onAnnouncementLink, canModerateMessages: canModerateMessages, children: canShowReadTicks(message, user) && _jsx(ReadTicks, { messageId: message.id, conversation: conversation }) }), (message.reply_count || replies.length) > 0 && (() => {
176
176
  const unread = hasUnreadReplies(message, cache.receipts, user === null || user === void 0 ? void 0 : user.id);
@@ -31,6 +31,7 @@ export function NotificationSettings() {
31
31
  const [loading, setLoading] = useState(true);
32
32
  const [savingEmail, setSavingEmail] = useState(false);
33
33
  const [savingPush, setSavingPush] = useState(false);
34
+ const [savingPreview, setSavingPreview] = useState(false);
34
35
  const [pushSubscribed, setPushSubscribed] = useState(false);
35
36
  const [error, setError] = useState('');
36
37
  const [conflict, setConflict] = useState('');
@@ -118,6 +119,21 @@ export function NotificationSettings() {
118
119
  setSavingPush(false);
119
120
  }
120
121
  };
122
+ const handlePreviewToggle = async (event) => {
123
+ const push_preview_opt_in = event.target.checked;
124
+ setSavingPreview(true);
125
+ setError('');
126
+ try {
127
+ const updated = await patchNotificationPreferences({ push_preview_opt_in });
128
+ setPreferences(updated);
129
+ }
130
+ catch (_a) {
131
+ setError(t('NotificationSettings.SAVE_ERROR'));
132
+ }
133
+ finally {
134
+ setSavingPreview(false);
135
+ }
136
+ };
121
137
  const handleDisablePush = async () => {
122
138
  setSavingPush(true);
123
139
  setError('');
@@ -142,6 +158,6 @@ export function NotificationSettings() {
142
158
  if (loading) {
143
159
  return _jsx(Box, { sx: { display: 'flex', justifyContent: 'center', py: 4 }, children: _jsx(CircularProgress, {}) });
144
160
  }
145
- return (_jsxs(Box, { sx: { maxWidth: 520 }, children: [_jsxs(Typography, { variant: "h6", gutterBottom: true, sx: { display: 'flex', alignItems: 'center', gap: 1 }, children: [_jsx(NotificationsIcon, {}), t('NotificationSettings.TITLE')] }), _jsx(Typography, { variant: "body2", color: "text.secondary", gutterBottom: true, children: t('NotificationSettings.SUBTITLE') }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, onClose: () => setError(''), children: error }), conflict && _jsx(Alert, { severity: "warning", sx: { mb: 2 }, onClose: () => setConflict(''), children: conflict }), _jsx(Box, { sx: { py: 2 }, children: _jsx(FormControlLabel, { control: _jsx(Switch, { checked: Boolean(preferences === null || preferences === void 0 ? void 0 : preferences.email_opt_in), onChange: handleEmailToggle, disabled: savingEmail }), label: _jsxs(Box, { children: [_jsx(Typography, { variant: "body1", children: t('NotificationSettings.EMAIL_LABEL') }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: t('NotificationSettings.EMAIL_HINT') })] }), labelPlacement: "end", sx: { alignItems: 'flex-start', ml: 0 } }) }), _jsx(Divider, {}), _jsxs(Box, { sx: { py: 2 }, children: [iosNeedsInstall && _jsx(Alert, { severity: "info", sx: { mb: 1.5 }, children: t('NotificationSettings.IOS_HINT') }), !pushSupported && !iosNeedsInstall && _jsx(Alert, { severity: "warning", sx: { mb: 1.5 }, children: t('NotificationSettings.PUSH_NOT_SUPPORTED') }), _jsx(FormControlLabel, { control: (_jsx(Switch, { checked: pushSubscribed, onChange: (event) => (event.target.checked ? handleEnablePush() : handleDisablePush()), disabled: savingPush || !pushSupported || iosNeedsInstall })), label: _jsxs(Box, { children: [_jsx(Typography, { variant: "body1", children: t('NotificationSettings.PUSH_LABEL') }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: t('NotificationSettings.PUSH_HINT') })] }), labelPlacement: "end", sx: { alignItems: 'flex-start', ml: 0 } })] })] }));
161
+ return (_jsxs(Box, { sx: { maxWidth: 520 }, children: [_jsxs(Typography, { variant: "h6", gutterBottom: true, sx: { display: 'flex', alignItems: 'center', gap: 1 }, children: [_jsx(NotificationsIcon, {}), t('NotificationSettings.TITLE')] }), _jsx(Typography, { variant: "body2", color: "text.secondary", gutterBottom: true, children: t('NotificationSettings.SUBTITLE') }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, onClose: () => setError(''), children: error }), conflict && _jsx(Alert, { severity: "warning", sx: { mb: 2 }, onClose: () => setConflict(''), children: conflict }), _jsx(Box, { sx: { py: 2 }, children: _jsx(FormControlLabel, { control: _jsx(Switch, { checked: Boolean(preferences === null || preferences === void 0 ? void 0 : preferences.email_opt_in), onChange: handleEmailToggle, disabled: savingEmail }), label: _jsxs(Box, { children: [_jsx(Typography, { variant: "body1", children: t('NotificationSettings.EMAIL_LABEL') }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: t('NotificationSettings.EMAIL_HINT') })] }), labelPlacement: "end", sx: { alignItems: 'flex-start', ml: 0 } }) }), _jsx(Divider, {}), _jsxs(Box, { sx: { py: 2 }, children: [iosNeedsInstall && _jsx(Alert, { severity: "info", sx: { mb: 1.5 }, children: t('NotificationSettings.IOS_HINT') }), !pushSupported && !iosNeedsInstall && _jsx(Alert, { severity: "warning", sx: { mb: 1.5 }, children: t('NotificationSettings.PUSH_NOT_SUPPORTED') }), _jsx(FormControlLabel, { control: (_jsx(Switch, { checked: pushSubscribed, onChange: (event) => (event.target.checked ? handleEnablePush() : handleDisablePush()), disabled: savingPush || !pushSupported || iosNeedsInstall })), label: _jsxs(Box, { children: [_jsx(Typography, { variant: "body1", children: t('NotificationSettings.PUSH_LABEL') }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: t('NotificationSettings.PUSH_HINT') })] }), labelPlacement: "end", sx: { alignItems: 'flex-start', ml: 0 } }), _jsx(FormControlLabel, { control: (_jsx(Switch, { checked: (preferences === null || preferences === void 0 ? void 0 : preferences.push_preview_opt_in) !== false, onChange: handlePreviewToggle, disabled: savingPreview || !(preferences === null || preferences === void 0 ? void 0 : preferences.push_opt_in) })), label: _jsxs(Box, { children: [_jsx(Typography, { variant: "body1", children: t('NotificationSettings.PUSH_PREVIEW_LABEL') }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: t('NotificationSettings.PUSH_PREVIEW_HINT') })] }), labelPlacement: "end", sx: { alignItems: 'flex-start', ml: 0, mt: 1.5 } })] })] }));
146
162
  }
147
163
  export default NotificationSettings;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "2.24.0",
3
+ "version": "2.25.1",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "repository": {
@@ -1,5 +1,6 @@
1
1
  export const messagingTranslations = {
2
2
  'MessagingComposer.ADD_EMOJI': { de: 'Emoji hinzufügen', en: 'Add emoji', fr: 'Ajouter un emoji' },
3
+ 'MessagingComposer.EMOJI_PICKER': { de: 'Emoji auswählen', en: 'Choose an emoji', fr: 'Choisir un emoji' },
3
4
  'MessagingComposer.STAGED_FILES': { de: 'Vorgemerkte Anhänge', en: 'Staged attachments', fr: 'Pièces jointes préparées' },
4
5
  'MessagingComposer.REMOVE_ATTACHMENT': { de: '{{name}} entfernen', en: 'Remove {{name}}', fr: 'Supprimer {{name}}' },
5
6
  'MessagingComposer.UPLOADING': { de: 'Anhänge werden hochgeladen…', en: 'Uploading attachments…', fr: 'Téléversement des pièces jointes…' },
@@ -10,6 +10,8 @@ export const notificationsTranslations = {
10
10
  'NotificationSettings.EMAIL_HINT': { de: 'Erhalte wichtige Informationen per E-Mail.', fr: 'Recevez les informations importantes par e-mail.', en: 'Receive important information by email.', sw: 'Pokea taarifa muhimu kwa barua pepe.' },
11
11
  'NotificationSettings.PUSH_LABEL': { de: 'Push-Benachrichtigungen', fr: 'Notifications push', en: 'Push notifications', sw: 'Arifa za push' },
12
12
  'NotificationSettings.PUSH_HINT': { de: 'Aktiviere Benachrichtigungen für dieses Gerät.', fr: 'Activez les notifications pour cet appareil.', en: 'Enable notifications for this device.', sw: 'Washa arifa kwa kifaa hiki.' },
13
+ 'NotificationSettings.PUSH_PREVIEW_LABEL': { de: 'Vorschau in Push-Benachrichtigungen', fr: 'Aperçu dans les notifications push', en: 'Preview in push notifications', sw: 'Muhtasari katika arifa za push' },
14
+ 'NotificationSettings.PUSH_PREVIEW_HINT': { de: 'Zeigt Absender und Nachrichtentext in der Push-Benachrichtigung. Bei Deaktivierung erhältst du weiterhin Benachrichtigungen, aber ohne Inhalt.', fr: 'Affiche l’expéditeur et le texte du message dans la notification push. Si désactivé, vous recevez toujours les notifications, mais sans contenu.', en: 'Shows the sender and message text in the push notification. When off, you still get notified, just without the content.', sw: 'Inaonyesha mtumaji na maandishi ya ujumbe kwenye arifa ya push. Ikizimwa, bado utaarifiwa, lakini bila maudhui.' },
13
15
  'NotificationSettings.PUSH_ENABLE': { de: 'Push aktivieren', fr: 'Activer les notifications push', en: 'Enable push', sw: 'Washa push' },
14
16
  'NotificationSettings.PUSH_DISABLE': { de: 'Push auf diesem Gerät deaktivieren', fr: 'Désactiver les notifications push sur cet appareil', en: 'Disable push on this device', sw: 'Zima push kwenye kifaa hiki' },
15
17
  'NotificationSettings.PUSH_DENIED': { de: 'Die Browserberechtigung für Push-Benachrichtigungen wurde verweigert.', fr: 'L’autorisation du navigateur pour les notifications push a été refusée.', en: 'Browser permission for push notifications was denied.', sw: 'Ruhusa ya kivinjari kwa arifa za push imekataliwa.' },
@@ -98,12 +98,15 @@ export function AttachmentList({ attachments = [] }) {
98
98
  onClick={() => (image ? openLightbox(attachment) : download(attachment))}
99
99
  onContextMenu={(event) => openMenu(event, attachment)}
100
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' }}
101
+ sx={{ width: 120, height: 120, borderRadius: 1, overflow: 'hidden', border: '1px solid', borderColor: 'divider', display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'action.hover' }}
102
102
  >
103
103
  {image && preview
104
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" />
105
+ : <Stack alignItems="center" spacing={0.75} sx={{ px: 1, maxWidth: '100%' }}>
106
+ {/* Icon scaled up from `small` -- 120px tile (was 64px) made
107
+ the original small icon+caption pairing read sparse/
108
+ undersized against the much larger tile (MSG-6i). */}
109
+ <InsertDriveFileOutlinedIcon fontSize="large" />
107
110
  <Typography variant="caption" noWrap sx={{ maxWidth: '100%' }}>{nameOf(attachment)}</Typography>
108
111
  </Stack>}
109
112
  </ButtonBase>
@@ -1,4 +1,4 @@
1
- import { Alert, Box, Button, Checkbox, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, IconButton, LinearProgress, Menu, MenuItem, Stack, TextField, Typography } from '@mui/material';
1
+ import { Alert, Box, Button, Checkbox, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, IconButton, LinearProgress, Popover, Stack, TextField, Typography } from '@mui/material';
2
2
  import AttachFileIcon from '@mui/icons-material/AttachFile';
3
3
  import CampaignOutlinedIcon from '@mui/icons-material/CampaignOutlined';
4
4
  import CloseIcon from '@mui/icons-material/Close';
@@ -9,7 +9,18 @@ import { useTranslation } from 'react-i18next';
9
9
 
10
10
  import { compressImageForUpload } from './composerImageCompression';
11
11
  import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
12
- import { QUICK_EMOJIS } from './ReactionBar';
12
+
13
+ // A wider, Composer-only set (MSG-6i) -- deliberately separate from
14
+ // ReactionBar's own QUICK_EMOJIS (5, curated for fast reactions). Mobile
15
+ // users already have a full native emoji keyboard for free-form text; this
16
+ // exists for desktop, where that's far less discoverable. Not a search
17
+ // picker -- a fixed curated grid, easy to extend later.
18
+ const COMPOSER_EMOJIS = [
19
+ '😀', '😂', '😊', '😍', '😉', '😢', '😮', '😡',
20
+ '👍', '👎', '👏', '🙏', '💪', '🤝', '✌️', '👋',
21
+ '❤️', '💕', '🔥', '🎉', '🎈', '⭐', '✅', '❌',
22
+ '☀️', '🌧️', '⚽', '🍕',
23
+ ];
13
24
 
14
25
  function newRequestId() { return globalThis.crypto?.randomUUID?.() || `message-${Date.now()}-${Math.random().toString(36).slice(2)}`; }
15
26
  function isImage(file) { return file.type?.startsWith('image/'); }
@@ -140,9 +151,28 @@ export function Composer({ conversationId, conversation, replyTarget = null, onR
140
151
  {uploadProgress != null && <LinearProgress variant="determinate" value={uploadProgress} />}
141
152
  </Stack>}
142
153
  {failed && <Button type="button" onClick={() => submit(failed.requestId, true)} disabled={sending}>{t('MessagingComposer.RETRY')}</Button>}
143
- <Menu anchorEl={emojiAnchor} open={Boolean(emojiAnchor)} onClose={() => setEmojiAnchor(null)}>
144
- {QUICK_EMOJIS.map((emoji) => <MenuItem key={emoji} onClick={() => insertEmoji(emoji)}>{emoji}</MenuItem>)}
145
- </Menu>
154
+ <Popover
155
+ anchorEl={emojiAnchor}
156
+ open={Boolean(emojiAnchor)}
157
+ onClose={() => setEmojiAnchor(null)}
158
+ anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
159
+ transformOrigin={{ vertical: 'top', horizontal: 'left' }}
160
+ >
161
+ {/*
162
+ A Popover, not a Menu: this is a grid of plain buttons, not a linear
163
+ list of menuitems, so MUI's Menu/MenuList roving-tabindex arrow-key
164
+ navigation does not apply here and (via role="menu") would advertise
165
+ semantics the grid can't deliver. Popover leaves keyboard traversal to
166
+ the browser's native Tab order, which does work for a button grid.
167
+ */}
168
+ <Box role="group" aria-label={t('MessagingComposer.EMOJI_PICKER')} sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 0.25, p: 0.5, maxWidth: 280 }}>
169
+ {COMPOSER_EMOJIS.map((emoji) => (
170
+ <IconButton key={emoji} type="button" size="small" onClick={() => insertEmoji(emoji)} sx={{ fontSize: '1.15rem' }}>
171
+ {emoji}
172
+ </IconButton>
173
+ ))}
174
+ </Box>
175
+ </Popover>
146
176
  <Dialog open={pollOpen} onClose={() => !sending && setPollOpen(false)} fullWidth maxWidth="sm">
147
177
  <DialogTitle>{t('MessagingPoll.CREATE')}</DialogTitle><DialogContent><Stack spacing={1} sx={{ pt: 1 }}>
148
178
  <TextField label={t('MessagingPoll.QUESTION')} value={pollQuestion} onChange={(event) => setPollQuestion(event.target.value)} autoFocus />
Binary file
@@ -45,7 +45,18 @@ export function PollCard({ message, canClose: canCloseProp }) {
45
45
  const closed = Boolean(poll.closed_at);
46
46
  const creatorId = poll.created_by_id ?? poll.created_by?.id ?? poll.created_by;
47
47
  const canClose = canCloseProp ?? poll.can_close ?? message?.can_close ?? message?.permissions?.can_close ?? (creatorId != null && creatorId === currentUser?.id);
48
- const totalVotes = (poll.options || []).reduce((sum, option) => sum + (option.vote_count ?? option.voters?.length ?? 0), 0);
48
+ // Percentage denominator (MSG-6i): the number of distinct people who
49
+ // answered this poll, not sum(vote_count) across options. For a
50
+ // multi-select poll one person can vote for several options, so summing
51
+ // vote_count can exceed the actual respondent count -- dividing by that
52
+ // inflated sum understated everyone's percentage and the bars didn't sum
53
+ // to a sensible 100%. dcm's serialize_poll always includes each option's
54
+ // `voters` (a list of user ids, ungated -- unlike messaging read
55
+ // receipts); the union across all options is exactly "everyone who
56
+ // answered", computed client-side, no backend change. This deliberately
57
+ // reverses MSG-6g's own explicit non-goal ("denominator = votes cast, not
58
+ // participant count") -- an operator-confirmed correction, not a revert.
59
+ const respondentCount = new Set((poll.options || []).flatMap((option) => option.voters || [])).size;
49
60
 
50
61
  const tap = async (id) => {
51
62
  if (closed) return;
@@ -87,25 +98,29 @@ export function PollCard({ message, canClose: canCloseProp }) {
87
98
  // is no user-directory endpoint (same structural limit already
88
99
  // established for the DM candidate list).
89
100
  const voteCount = option.vote_count ?? option.voters?.length ?? 0;
90
- const percent = totalVotes > 0 ? Math.round((voteCount / totalVotes) * 100) : 0;
101
+ const percent = respondentCount > 0 ? Math.round((voteCount / respondentCount) * 100) : 0;
91
102
  const votedByMe = selected.includes(option.id);
92
103
  const resultText = t('MessagingPoll.OPTION_RESULT', { count: voteCount, percent });
93
104
  // The bar is decorative; count/percent live in the accessible name (via
94
105
  // aria-label on the control itself) as well as in adjacent visible text —
95
106
  // width alone must never be the only signal (WO risk note).
96
107
  const accessibleLabel = `${option.text || option.label}, ${resultText}${votedByMe ? `, ${t('MessagingPoll.YOU_VOTED')}` : ''}`;
97
- const richLabel = <Stack sx={{ flex: 1, minWidth: 0, opacity: pending.has(option.id) ? 0.6 : 1 }} spacing={0.25}>
108
+ // MSG-6i scope B: "airier, not wider" (operator, after seeing a
109
+ // screenshot of the checkbox/text/percent squeezed onto one tight line
110
+ // with a thin bar close to the text) -- more internal spacing/a taller
111
+ // bar, not a wider bubble (MessageBubble.jsx's maxWidth is untouched).
112
+ const richLabel = <Stack sx={{ flex: 1, minWidth: 0, opacity: pending.has(option.id) ? 0.6 : 1 }} spacing={0.75}>
98
113
  <Stack direction="row" justifyContent="space-between">
99
114
  <Typography variant="body2" noWrap>{option.text || option.label}</Typography>
100
115
  <Typography variant="caption" color="text.secondary">{resultText}</Typography>
101
116
  </Stack>
102
- <LinearProgress variant="determinate" value={percent} sx={{ borderRadius: 1, height: 6 }} color={votedByMe ? 'primary' : 'inherit'} />
117
+ <LinearProgress variant="determinate" value={percent} sx={{ borderRadius: 1, height: 8 }} color={votedByMe ? 'primary' : 'inherit'} />
103
118
  </Stack>;
104
119
  return { accessibleLabel, richLabel };
105
120
  };
106
121
  const formControlSx = { width: '100%', m: 0, alignItems: 'flex-start' };
107
122
 
108
- return <Paper variant="outlined" sx={{ p: 1 }} aria-label={t('MessagingPoll.LABEL')}><Stack spacing={0.75}>
123
+ return <Paper variant="outlined" sx={{ p: 1.5 }} aria-label={t('MessagingPoll.LABEL')}><Stack spacing={1.5}>
109
124
  <Typography variant="subtitle2">{poll.question}</Typography>
110
125
  {poll.allow_multiple
111
126
  ? (poll.options || []).map((option) => {
@@ -139,10 +139,21 @@ export function Thread({ conversationId, onReplyTargetChange, canModerateMessage
139
139
  setOpenThreads((current) => ({ ...current, [root.id]: !current[root.id] }));
140
140
  };
141
141
  return (
142
- <Stack spacing={1} sx={{ minWidth: 0 }}>
142
+ // flex: 1, minHeight: 0 (here and on the scroll Box below): lets Thread
143
+ // shrink to fit when a host embeds it inside a fixed-height flex-column
144
+ // layout (e.g. header + Thread + Composer all fitting one screen, no
145
+ // page-level scroll) -- a flex item's default min-height is 'auto',
146
+ // which refuses to shrink below content size without this. Both
147
+ // properties are no-ops outside a flex context, so a host that doesn't
148
+ // flex-wrap Thread sees no behaviour change at all; maxHeight below
149
+ // still caps growth the same as before for that case (MSG-18, found
150
+ // while investigating why jg-ferien's Composer rendered behind its
151
+ // mobile bottom nav -- Thread's own fixed 560px cap never shrank for a
152
+ // short mobile viewport, no matter how the host's own layout was fixed).
153
+ <Stack spacing={1} sx={{ minWidth: 0, flex: 1, minHeight: 0 }}>
143
154
  {error && <Alert severity="error" role="alert">{error}</Alert>}
144
155
  {replyingTo && <Alert severity="info" onClose={() => { setReplyingTo(null); onReplyTargetChange?.(null); }}>{t('MessagingThread.REPLYING_TO', { sender: replyingTo.sender?.display_name || t('MessagingThread.UNKNOWN_SENDER') })}</Alert>}
145
- <Box ref={scrollRef} onScroll={onScroll} sx={{ maxHeight: 560, overflowY: 'auto', overflowX: 'hidden', px: 0.5 }} aria-label={t('MessagingThread.TIMELINE')}>
156
+ <Box ref={scrollRef} onScroll={onScroll} sx={{ flex: 1, minHeight: 0, maxHeight: 560, overflowY: 'auto', overflowX: 'hidden', px: 0.5 }} aria-label={t('MessagingThread.TIMELINE')}>
146
157
  {cache.cursors.messages[conversationId] && <Box textAlign="center" py={1}><Button onClick={loadOlder} disabled={loadingOlder}>{loadingOlder ? <CircularProgress size={18} /> : t('MessagingThread.LOAD_OLDER')}</Button></Box>}
147
158
  {/* Only show the initial-load spinner when there's nothing to show yet
148
159
  — re-opening a conversation whose messages are already cached (an
@@ -38,6 +38,7 @@ export function NotificationSettings() {
38
38
  const [loading, setLoading] = useState(true);
39
39
  const [savingEmail, setSavingEmail] = useState(false);
40
40
  const [savingPush, setSavingPush] = useState(false);
41
+ const [savingPreview, setSavingPreview] = useState(false);
41
42
  const [pushSubscribed, setPushSubscribed] = useState(false);
42
43
  const [error, setError] = useState('');
43
44
  const [conflict, setConflict] = useState('');
@@ -123,6 +124,20 @@ export function NotificationSettings() {
123
124
  }
124
125
  };
125
126
 
127
+ const handlePreviewToggle = async (event) => {
128
+ const push_preview_opt_in = event.target.checked;
129
+ setSavingPreview(true);
130
+ setError('');
131
+ try {
132
+ const updated = await patchNotificationPreferences({ push_preview_opt_in });
133
+ setPreferences(updated);
134
+ } catch {
135
+ setError(t('NotificationSettings.SAVE_ERROR'));
136
+ } finally {
137
+ setSavingPreview(false);
138
+ }
139
+ };
140
+
126
141
  const handleDisablePush = async () => {
127
142
  setSavingPush(true);
128
143
  setError('');
@@ -185,6 +200,18 @@ export function NotificationSettings() {
185
200
  labelPlacement="end"
186
201
  sx={{ alignItems: 'flex-start', ml: 0 }}
187
202
  />
203
+ <FormControlLabel
204
+ control={(
205
+ <Switch
206
+ checked={preferences?.push_preview_opt_in !== false}
207
+ onChange={handlePreviewToggle}
208
+ disabled={savingPreview || !preferences?.push_opt_in}
209
+ />
210
+ )}
211
+ label={<Box><Typography variant="body1">{t('NotificationSettings.PUSH_PREVIEW_LABEL')}</Typography><Typography variant="caption" color="text.secondary">{t('NotificationSettings.PUSH_PREVIEW_HINT')}</Typography></Box>}
212
+ labelPlacement="end"
213
+ sx={{ alignItems: 'flex-start', ml: 0, mt: 1.5 }}
214
+ />
188
215
  </Box>
189
216
  </Box>
190
217
  );