@micha.bigler/ui-core-micha 2.24.0 → 2.25.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 +2 -1
- package/dist/i18n/messagingTranslations.js +1 -0
- package/dist/messaging/AttachmentList.js +2 -2
- package/dist/messaging/Composer.js +13 -3
- package/dist/messaging/MessagingProvider.js +0 -0
- package/dist/messaging/PollCard.js +19 -4
- package/dist/messaging/Thread.js +1 -1
- package/package.json +1 -1
- package/src/i18n/messagingTranslations.ts +1 -0
- package/src/messaging/AttachmentList.jsx +6 -3
- package/src/messaging/Composer.jsx +35 -5
- package/src/messaging/MessagingProvider.jsx +0 -0
- package/src/messaging/PollCard.jsx +20 -5
- package/src/messaging/Thread.jsx +13 -2
- package/tests/messagingComposerChunk5.test.jsx +4 -1
- package/tests/messagingMsg6i.test.jsx +129 -0
- package/work-orders/MSG-6i.md +271 -0
package/WORK_ORDERS.md
CHANGED
|
@@ -40,4 +40,5 @@ 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 |
|
|
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 | (pending push) | 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. |
|
|
@@ -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…' },
|
|
@@ -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:
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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);
|
package/dist/messaging/Thread.js
CHANGED
|
@@ -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);
|
package/package.json
CHANGED
|
@@ -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…' },
|
|
@@ -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:
|
|
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.
|
|
106
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
<
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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={
|
|
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) => {
|
package/src/messaging/Thread.jsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
@@ -90,10 +90,13 @@ describe('Composer chunk 5 behavior', () => {
|
|
|
90
90
|
});
|
|
91
91
|
|
|
92
92
|
it('inserts the selected emoji at the current cursor position', async () => {
|
|
93
|
+
// MSG-6i: the Composer's own picker is now a wider curated grid
|
|
94
|
+
// (IconButton per emoji), decoupled from ReactionBar's 5-entry
|
|
95
|
+
// QUICK_EMOJIS -- role is "button", not "menuitem".
|
|
93
96
|
renderComposer(); fireEvent.change(messageInput(), { target: { value: 'abCD' } });
|
|
94
97
|
messageInput().setSelectionRange(2, 2);
|
|
95
98
|
fireEvent.click(screen.getByRole('button', { name: 'MessagingComposer.ADD_EMOJI' }));
|
|
96
|
-
fireEvent.click(await screen.findByRole('
|
|
99
|
+
fireEvent.click(await screen.findByRole('button', { name: '👍' }));
|
|
97
100
|
await waitFor(() => expect(messageInput().value).toBe('ab👍CD'));
|
|
98
101
|
});
|
|
99
102
|
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options && Object.keys(options).length ? `${key}:${JSON.stringify(options)}` : key }) }));
|
|
7
|
+
vi.mock('../src/notifications/realtime', () => ({ useRealtime: () => ({ subscribe: () => () => {}, onReconnect: () => () => {} }) }));
|
|
8
|
+
|
|
9
|
+
import { AttachmentList } from '../src/messaging/AttachmentList';
|
|
10
|
+
import { AuthContext } from '../src/auth/AuthContext';
|
|
11
|
+
import { Composer } from '../src/messaging/Composer';
|
|
12
|
+
import { MessagingProvider } from '../src/messaging/MessagingProvider';
|
|
13
|
+
import { PollCard } from '../src/messaging/PollCard';
|
|
14
|
+
import { QUICK_EMOJIS } from '../src/messaging/ReactionBar';
|
|
15
|
+
import { Thread } from '../src/messaging/Thread';
|
|
16
|
+
|
|
17
|
+
function makeApi(overrides = {}) {
|
|
18
|
+
return {
|
|
19
|
+
listConversations: vi.fn().mockResolvedValue({ results: [] }), listMessages: vi.fn().mockResolvedValue({ results: [] }),
|
|
20
|
+
getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
|
|
21
|
+
getAttachment: vi.fn().mockResolvedValue(new Blob(['bytes'], { type: 'image/jpeg' })),
|
|
22
|
+
getAttachmentThumbnail: vi.fn().mockResolvedValue(new Blob(['thumb'], { type: 'image/jpeg' })),
|
|
23
|
+
createMessage: vi.fn(), uploadAttachments: vi.fn(),
|
|
24
|
+
...overrides,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
afterEach(cleanup);
|
|
29
|
+
|
|
30
|
+
describe('MSG-6i: attachment tile size, poll spacing/denominator, Composer emoji picker', () => {
|
|
31
|
+
it('1. an attachment tile renders at 120x120 (image and non-image)', async () => {
|
|
32
|
+
const image = { id: 'a1', filename: 'photo.jpg', content_type: 'image/jpeg' };
|
|
33
|
+
const file = { id: 'a2', filename: 'doc.pdf', content_type: 'application/pdf' };
|
|
34
|
+
render(<MessagingProvider api={makeApi()} active={false}><AttachmentList attachments={[image, file]} /></MessagingProvider>);
|
|
35
|
+
const tiles = await screen.findAllByRole('button');
|
|
36
|
+
const gallery = tiles.filter((tile) => tile.getAttribute('title') === 'photo.jpg' || tile.getAttribute('title') === 'doc.pdf');
|
|
37
|
+
expect(gallery).toHaveLength(2);
|
|
38
|
+
gallery.forEach((tile) => {
|
|
39
|
+
// MUI applies `sx={{ width, height }}` via a generated CSS class, not
|
|
40
|
+
// an inline style attribute -- read the resolved value instead.
|
|
41
|
+
expect(getComputedStyle(tile).width).toBe('120px');
|
|
42
|
+
expect(getComputedStyle(tile).height).toBe('120px');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('2. poll percentage denominator is the count of distinct respondents, not sum(vote_count) -- must fail against the old sum(vote_count) code for a multi-select poll where one voter picked two options', () => {
|
|
47
|
+
// One respondent (voter-1) voted for both options. sum(vote_count) = 2,
|
|
48
|
+
// respondent count = 1. Old code: 1/2 = 50% each. New code: 1/1 = 100% each.
|
|
49
|
+
const poll = {
|
|
50
|
+
id: 'poll-1', question: 'Q', allow_multiple: true, closed_at: null,
|
|
51
|
+
options: [
|
|
52
|
+
{ id: 'opt-a', text: 'A', order: 0, vote_count: 1, voters: ['voter-1'] },
|
|
53
|
+
{ id: 'opt-b', text: 'B', order: 1, vote_count: 1, voters: ['voter-1'] },
|
|
54
|
+
],
|
|
55
|
+
};
|
|
56
|
+
render(<MessagingProvider api={makeApi()} active={false}><PollCard message={{ id: 'msg-1', poll }} /></MessagingProvider>);
|
|
57
|
+
const results = screen.getAllByText(/MessagingPoll\.OPTION_RESULT/);
|
|
58
|
+
expect(results).toHaveLength(2);
|
|
59
|
+
results.forEach((node) => {
|
|
60
|
+
expect(node.textContent).toMatch(/"count":1/);
|
|
61
|
+
expect(node.textContent).toMatch(/"percent":100/);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('3. poll percentage denominator: zero respondents still renders 0%, never NaN (no regression)', () => {
|
|
66
|
+
const poll = {
|
|
67
|
+
id: 'poll-2', question: 'Q', allow_multiple: false, closed_at: null,
|
|
68
|
+
options: [
|
|
69
|
+
{ id: 'opt-a', text: 'A', order: 0, vote_count: 0, voters: [] },
|
|
70
|
+
{ id: 'opt-b', text: 'B', order: 1, vote_count: 0, voters: [] },
|
|
71
|
+
],
|
|
72
|
+
};
|
|
73
|
+
render(<MessagingProvider api={makeApi()} active={false}><PollCard message={{ id: 'msg-2', poll }} /></MessagingProvider>);
|
|
74
|
+
const results = screen.getAllByText(/MessagingPoll\.OPTION_RESULT/);
|
|
75
|
+
results.forEach((node) => {
|
|
76
|
+
expect(node.textContent).toMatch(/"count":0/);
|
|
77
|
+
expect(node.textContent).toMatch(/"percent":0/);
|
|
78
|
+
expect(node.textContent).not.toMatch(/NaN/);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('4. the Composer emoji picker shows more than 5 options (the wider curated grid, not ReactionBar\'s QUICK_EMOJIS)', async () => {
|
|
83
|
+
const api = makeApi();
|
|
84
|
+
render(<MessagingProvider api={api} active={false}><Composer conversationId={4} /></MessagingProvider>);
|
|
85
|
+
fireEvent.click(screen.getByRole('button', { name: 'MessagingComposer.ADD_EMOJI' }));
|
|
86
|
+
const grid = await screen.findByRole('group', { name: 'MessagingComposer.EMOJI_PICKER' });
|
|
87
|
+
const emojiButtons = grid.querySelectorAll('button');
|
|
88
|
+
expect(emojiButtons.length).toBeGreaterThan(5);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('4b. selecting an emoji from the Composer grid inserts it at the cursor position, not appended', async () => {
|
|
92
|
+
const api = makeApi();
|
|
93
|
+
render(<MessagingProvider api={api} active={false}><Composer conversationId={4} /></MessagingProvider>);
|
|
94
|
+
const input = screen.getByLabelText('MessagingComposer.MESSAGE');
|
|
95
|
+
fireEvent.change(input, { target: { value: 'abCD' } });
|
|
96
|
+
input.setSelectionRange(2, 2);
|
|
97
|
+
fireEvent.click(screen.getByRole('button', { name: 'MessagingComposer.ADD_EMOJI' }));
|
|
98
|
+
const grid = await screen.findByRole('group', { name: 'MessagingComposer.EMOJI_PICKER' });
|
|
99
|
+
fireEvent.click(within(grid).getByRole('button', { name: '😀' }));
|
|
100
|
+
await waitFor(() => expect(input.value).toBe('ab😀CD'));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('5. ReactionBar\'s own QUICK_EMOJIS stays exactly 5, unaffected by the wider Composer picker', () => {
|
|
104
|
+
expect(QUICK_EMOJIS).toHaveLength(5);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('6. Thread\'s root Stack and scroll Box carry flex:1/minHeight:0 (MSG-18 companion fix), so it can shrink inside a host\'s flex-column layout', async () => {
|
|
108
|
+
const api = makeApi({
|
|
109
|
+
listConversations: vi.fn().mockResolvedValue({ results: [{ id: 1, kind: 'group' }] }),
|
|
110
|
+
listMessages: vi.fn().mockResolvedValue({ results: [{ id: 2, conversation_id: 1, body: 'hi' }], next_cursor: null }),
|
|
111
|
+
listThread: vi.fn().mockResolvedValue({ results: [] }),
|
|
112
|
+
getReadStatus: vi.fn().mockResolvedValue({ all_read: true }),
|
|
113
|
+
});
|
|
114
|
+
render(
|
|
115
|
+
<AuthContext.Provider value={{ user: { id: 9 } }}>
|
|
116
|
+
<MessagingProvider api={api}><Thread conversationId={1} /></MessagingProvider>
|
|
117
|
+
</AuthContext.Provider>,
|
|
118
|
+
);
|
|
119
|
+
const timeline = await screen.findByLabelText('MessagingThread.TIMELINE');
|
|
120
|
+
// The scroll Box is a direct child of Thread's root Stack.
|
|
121
|
+
const rootStack = timeline.parentElement;
|
|
122
|
+
[timeline, rootStack].forEach((el) => {
|
|
123
|
+
expect(getComputedStyle(el).flexGrow).toBe('1');
|
|
124
|
+
expect(getComputedStyle(el).minHeight).toBe('0px');
|
|
125
|
+
});
|
|
126
|
+
// maxHeight stays as the fallback cap for a host that doesn't flex-wrap Thread.
|
|
127
|
+
expect(getComputedStyle(timeline).maxHeight).toBe('560px');
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# WORK ORDER MSG-6i (ui-core-micha) — attachment tile size, poll spacing/denominator, wider emoji picker
|
|
2
|
+
|
|
3
|
+
**EXECUTION DIRECTIVE.** If you are the implementer reading this as your own spec, this section is not
|
|
4
|
+
addressed to you — it tells the Orchestrator how to invoke you; you ARE that invocation, do not shell
|
|
5
|
+
out to `codex exec`. Orchestrator: implement through `codex exec` in the background, invoked **directly
|
|
6
|
+
via Bash** (never the `debugger`/`*_coder` Agent wrappers) with **both** flags `--skip-git-repo-check`
|
|
7
|
+
and `--dangerously-bypass-approvals-and-sandbox`, prompt passed as a positional argument from this
|
|
8
|
+
file. Fall back to direct Claude implementation only on Codex quota / rate-limit / non-zero exit.
|
|
9
|
+
|
|
10
|
+
Feature-level WO (Expertenchat envelope). Operator polish requests 2026-08-03, gathered in a
|
|
11
|
+
requirements round after MSG-6h shipped; four independent, small scopes bundled the same way
|
|
12
|
+
MSG-6f bundled A/B/C.
|
|
13
|
+
|
|
14
|
+
## TIER
|
|
15
|
+
Tier 2 — shared-core UI consumed by every host app. Independent `reviewer` + `ui_reviewer` mandatory
|
|
16
|
+
(scope C changes poll math, not just styling).
|
|
17
|
+
|
|
18
|
+
## SCOPE
|
|
19
|
+
|
|
20
|
+
**A. Attachment tile: 64px → 120px.**
|
|
21
|
+
|
|
22
|
+
`AttachmentList.jsx`'s tile `sx={{ width: 64, height: 64, ... }}` (both the `ButtonBase` and its
|
|
23
|
+
internal thumbnail `Box component="img"`, which is `width: '100%', height: '100%'` and inherits).
|
|
24
|
+
Change both dimensions to `120`. Check the non-image tile's icon/caption layout still reads well at
|
|
25
|
+
the larger size (currently sized for 64px — `InsertDriveFileOutlinedIcon fontSize="small"` and a
|
|
26
|
+
`caption` typography may look sparse/small inside a 120px box; consider `fontSize="medium"` or larger
|
|
27
|
+
if it looks disproportionate — operator did not specify, use judgement, flag if genuinely unsure).
|
|
28
|
+
`Stack direction="row" flexWrap="wrap"` spacing stays as-is unless the larger tiles visibly break
|
|
29
|
+
wrapping at typical bubble widths.
|
|
30
|
+
|
|
31
|
+
**B. Poll card: more breathing room, not a wider bubble.**
|
|
32
|
+
|
|
33
|
+
Operator, verbatim (after seeing a screenshot of the current cramped layout — checkbox, option text,
|
|
34
|
+
and percentage squeezed onto one tight line, thin bars close to the text, minimal gap between options):
|
|
35
|
+
*"Nicht generell breiter, aber luftiger."* (Not generally wider, but airier.) This is **not** scope A
|
|
36
|
+
of a width change — the poll's outer bubble width stays exactly as-is (still shares
|
|
37
|
+
`MessageBubble.jsx`'s `maxWidth: min(75%, 680px)` with every other message type). Increase internal
|
|
38
|
+
spacing in `PollCard.jsx`: the `Stack spacing={0.75}` wrapping the whole card, the per-option
|
|
39
|
+
`Stack direction="row" spacing={0.5}` (control + label), and the `LinearProgress`'s `height: 6` are
|
|
40
|
+
the likely levers — raise spacing values and/or bar height/thickness so the card reads as roomier.
|
|
41
|
+
No prescribed exact values; operator did not send further numeric guidance beyond the "airier, not
|
|
42
|
+
wider" direction — use reasonable judgement (e.g. spacing bumped by ~50%, bar height 8-10px) and
|
|
43
|
+
**flag this specific change for a live screenshot check before considering it closed** — same
|
|
44
|
+
disclosed-limitation pattern as MSG-6h's reaction-spacing fix (no staging credentials this round).
|
|
45
|
+
|
|
46
|
+
**C. Poll percentage denominator: respondents of this poll, not sum of votes cast.**
|
|
47
|
+
|
|
48
|
+
**This reverses an explicit non-goal from `MSG-6g`'s own envelope**, confirmed deliberately by the
|
|
49
|
+
operator in this round (not an oversight): MSG-6g stated *"Denominator: total votes cast, not
|
|
50
|
+
participant count. A poll's turnout relative to group size is a different question and is not in
|
|
51
|
+
scope."* — operator's correction: for a **multi-select** poll, summing `vote_count` across options can
|
|
52
|
+
exceed the number of people who actually answered (one person can vote for several options), so a
|
|
53
|
+
per-option percentage computed against that inflated sum reads as misleadingly low and the bars
|
|
54
|
+
don't sum to a sensible 100%. The new denominator: **the number of distinct people who cast at least
|
|
55
|
+
one vote in this poll** — not total conversation participants (an un-started, never-voting group
|
|
56
|
+
member should not silently deflate everyone else's percentages further).
|
|
57
|
+
|
|
58
|
+
Compute this **client-side, no backend change**: `serialize_poll`'s existing `option.voters` arrays
|
|
59
|
+
(already present, already used for the viewer's-own-vote marking) already carry every voter's id per
|
|
60
|
+
option — the union of all options' `voters` arrays, deduplicated, is exactly "everyone who answered
|
|
61
|
+
this poll." Replace `PollCard.jsx`'s current
|
|
62
|
+
`totalVotes = options.reduce((sum, option) => sum + (option.vote_count ?? option.voters?.length ?? 0), 0)`
|
|
63
|
+
with a respondent count derived from the union of `option.voters` across all options (a `Set` of voter
|
|
64
|
+
ids). **Zero-vote rendering must stay `NaN`-free** (existing guard, do not regress it) — zero
|
|
65
|
+
respondents still means 0% for every option, not a division error.
|
|
66
|
+
|
|
67
|
+
**D. Composer emoji picker: its own, wider curated list, decoupled from reaction quick-picks.**
|
|
68
|
+
|
|
69
|
+
`Composer.jsx:144` currently reuses `QUICK_EMOJIS` from `ReactionBar.jsx` (`['👍', '❤️', '😂', '🎉',
|
|
70
|
+
'👀']`, 5 entries) — the same tiny set meant for quick message reactions, not for composing free-form
|
|
71
|
+
text. Operator confirmed direction after discussion: mobile users already have a full native emoji
|
|
72
|
+
keyboard (no in-app work needed there); desktop users lack an obvious equivalent, so a **wider curated
|
|
73
|
+
grid (~20-30 common emojis)** in the Composer's own picker adds real value. **Not** a full
|
|
74
|
+
search-based picker (out of scope — meaningfully more engineering for a need mobile already covers).
|
|
75
|
+
|
|
76
|
+
Define a new, separate constant (e.g. `COMPOSER_EMOJIS` in `Composer.jsx` itself, or a shared
|
|
77
|
+
`emojiSets.js` if that reads cleaner) with ~20-30 common emojis, grouped loosely by theme (smileys,
|
|
78
|
+
gestures, hearts, objects/travel — operator did not specify an exact list; use a reasonable, common
|
|
79
|
+
default and flag it as easily adjustable). Render as a grid inside the existing `Menu` (currently a
|
|
80
|
+
plain vertical `MenuItem` list — at 25-30 entries that would be a very long scroll; switch to a
|
|
81
|
+
wrapping grid layout, e.g. a `Box` with `display: grid` or `flexWrap`, inside the `Menu`). Keep
|
|
82
|
+
`ReactionBar.jsx`'s own `QUICK_EMOJIS` untouched (5, curated, unchanged) — reactions stay small and
|
|
83
|
+
fast, this is a Composer-only change.
|
|
84
|
+
|
|
85
|
+
## NON-GOALS / DO NOT TOUCH
|
|
86
|
+
- Do not touch `ReactionBar.jsx`'s own reaction UI or its 5-emoji `QUICK_EMOJIS` — scope D adds a
|
|
87
|
+
second, separate, larger set for the Composer only.
|
|
88
|
+
- Do not widen the message bubble's `maxWidth` (`MessageBubble.jsx`) — scope B is internal spacing
|
|
89
|
+
only, confirmed explicitly by the operator.
|
|
90
|
+
- Do not touch `MessageBubble.jsx`'s reaction-overlap fix from MSG-6h, or the lightbox/context-menu
|
|
91
|
+
logic from MSG-6h — this WO only resizes the tile (scope A), doesn't change its interaction model.
|
|
92
|
+
- No backend change for scope C — the data needed already exists in the current API response.
|
|
93
|
+
- Do not build a full emoji search/picker component (scope D) — a curated grid only.
|
|
94
|
+
|
|
95
|
+
## RISKS
|
|
96
|
+
- Scope C is a **behaviour change visible to every host app** the moment this version is consumed —
|
|
97
|
+
a poll's displayed percentages will differ from before for any multi-select poll where the same
|
|
98
|
+
person voted for multiple options. State this plainly; it is the operator's own explicit correction,
|
|
99
|
+
not a silent reinterpretation.
|
|
100
|
+
- Scope A's larger tile changes vertical rhythm inside a message bubble containing attachments —
|
|
101
|
+
check it doesn't visually crowd the meta row (timestamp/read-ticks) directly below it.
|
|
102
|
+
- Scope B and D have no prescribed exact values — implement a reasonable default, disclose the
|
|
103
|
+
specific choices made (spacing multipliers, the exact emoji list) in the completion note so the
|
|
104
|
+
operator can request a numeric tweak without re-deriving what was chosen.
|
|
105
|
+
|
|
106
|
+
## REQUIRED TESTS TO WRITE
|
|
107
|
+
Narrow and behavioural. Do NOT run the full suite.
|
|
108
|
+
|
|
109
|
+
1. Attachment tile renders at 120×120 (image and non-image cases).
|
|
110
|
+
2. Poll respondent-count denominator: a multi-select poll where one voter voted for two options must
|
|
111
|
+
show percentages based on the **respondent count** (e.g. 1 respondent → both their chosen options
|
|
112
|
+
show 100%, not summed-and-diluted), not `sum(vote_count)`. This must **fail against the current
|
|
113
|
+
`sum(vote_count)` code** — prove it with a case where the two differ (multi-select, one voter,
|
|
114
|
+
two options voted).
|
|
115
|
+
3. Poll respondent-count denominator: the existing single-choice zero-vote case still shows 0%, no
|
|
116
|
+
`NaN`, no regression (reuse/extend the existing MSG-6g zero-vote test's assertion shape).
|
|
117
|
+
4. Composer emoji picker: opening it shows more than 5 emoji options (the expanded set, not
|
|
118
|
+
`ReactionBar`'s `QUICK_EMOJIS`); selecting one still inserts it at the cursor position (existing
|
|
119
|
+
`insertEmoji` behaviour, unchanged — just fed from the new list).
|
|
120
|
+
5. `ReactionBar`'s own picker is unaffected — still exactly its original 5 `QUICK_EMOJIS` (regression
|
|
121
|
+
guard that scope D didn't leak into scope-unrelated reaction UI).
|
|
122
|
+
|
|
123
|
+
## TEST SCOPE FOR THE GATE (orchestrator)
|
|
124
|
+
Attachment-, poll-, and Composer-adjacent messaging test files. Not the full suite.
|
|
125
|
+
|
|
126
|
+
## TARGET REPO
|
|
127
|
+
`C:\Users\biglmi\Documents\webapps\ui-core-micha`. Branch `main`. Publish per the repo's release flow;
|
|
128
|
+
the jg-ferien pin bump is part of THIS WO's completion, not a deferred step (standing correction this
|
|
129
|
+
session: publish alone is not delivery).
|
|
130
|
+
|
|
131
|
+
## MINI-HANDOVER (pastable)
|
|
132
|
+
|
|
133
|
+
> Repo: `C:\Users\biglmi\Documents\webapps\ui-core-micha` (branch `main`). Work order:
|
|
134
|
+
> `work-orders/MSG-6i.md` — read it fully, then follow the `orchestrate-codex` skill.
|
|
135
|
+
>
|
|
136
|
+
> Four independent scopes: (A) attachment tile 64px→120px in `AttachmentList.jsx`. (B) `PollCard.jsx`
|
|
137
|
+
> internal spacing increased ("airier, not wider" — bubble maxWidth unchanged) — no exact numbers
|
|
138
|
+
> given, use judgement, flag for a live screenshot check. (C) poll percentage denominator changes from
|
|
139
|
+
> `sum(vote_count)` to the count of distinct respondents (union of all options' `voters` arrays,
|
|
140
|
+
> client-side, no backend change) — **this deliberately reverses an explicit MSG-6g non-goal**, operator
|
|
141
|
+
> re-confirmed. (D) Composer's emoji picker gets its own ~20-30-entry curated grid, decoupled from
|
|
142
|
+
> `ReactionBar`'s 5-entry `QUICK_EMOJIS` (untouched) — no full search picker, mobile already has a
|
|
143
|
+
> native one.
|
|
144
|
+
|
|
145
|
+
## PROGRESS CONTRACT
|
|
146
|
+
Emit `PLAN: <steps>` up front, then a single-line `PROGRESS: [<n>/<total>] <action>` before every
|
|
147
|
+
relevant action and `PROGRESS: [<n>/<total>] done` on completion, spaced so no gap exceeds ~2 min,
|
|
148
|
+
stdout unbuffered, and exactly one final `RESULT: DONE|BLOCKED <reason>`.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## Part B — Implementation map (Orchestrator) / as-built record
|
|
153
|
+
|
|
154
|
+
**Codex was unavailable (out of credits) — implemented directly by the Orchestrator.**
|
|
155
|
+
|
|
156
|
+
### Scope A — `AttachmentList.jsx`
|
|
157
|
+
Tile `sx={{ width: 64, height: 64, ... }}` → `120, 120`. Non-image tile's icon bumped
|
|
158
|
+
`fontSize="small"` → `"large"` and its `Stack` spacing `0.25` → `0.75` — the original
|
|
159
|
+
small icon+caption pairing read sparse against the much larger tile.
|
|
160
|
+
|
|
161
|
+
### Scope B — `PollCard.jsx` spacing
|
|
162
|
+
Outer `Paper` padding `p: 1` → `1.5`; outer `Stack` (question/options/close-row) spacing
|
|
163
|
+
`0.75` → `1.5`; per-option `richLabel` `Stack` spacing `0.25` → `0.75`; `LinearProgress`
|
|
164
|
+
`height: 6` → `8`. No exact values were specified by the operator beyond "airier, not
|
|
165
|
+
wider" (confirmed via a screenshot of the cramped original) — **not independently
|
|
166
|
+
visually re-verified against a fresh screenshot before closing** (no staging/live
|
|
167
|
+
credentials for this specific check this round); flagging per the same disclosed-
|
|
168
|
+
limitation pattern as MSG-6h's reaction-spacing fix.
|
|
169
|
+
|
|
170
|
+
### Scope C — `PollCard.jsx` percentage denominator
|
|
171
|
+
`totalVotes = sum(option.vote_count)` replaced with
|
|
172
|
+
`respondentCount = new Set(options.flatMap(option => option.voters || [])).size` — the
|
|
173
|
+
count of distinct people who voted for *any* option, computed client-side from data
|
|
174
|
+
already present in the response (`voters` arrays, already used for the viewer's-own-vote
|
|
175
|
+
marking). `percent` now divides by `respondentCount`.
|
|
176
|
+
|
|
177
|
+
### Scope D — `Composer.jsx` emoji picker
|
|
178
|
+
New `COMPOSER_EMOJIS` (28 entries) defined locally in `Composer.jsx`, rendered as a
|
|
179
|
+
7-column CSS grid of `IconButton`s inside the existing `Menu` (replacing the old
|
|
180
|
+
`QUICK_EMOJIS.map(... <MenuItem>...)` vertical list — `MenuItem` import removed, now
|
|
181
|
+
unused). `ReactionBar.jsx`'s own `QUICK_EMOJIS` (5, curated) untouched.
|
|
182
|
+
|
|
183
|
+
### Companion fix found and fixed — `Thread.jsx` (not in this WO's original scope)
|
|
184
|
+
Discovered while live-verifying jg-ferien's `MSG-18` (mobile Composer hidden behind the
|
|
185
|
+
fixed bottom nav) in the *same* browser session as this WO's own work: after fixing every
|
|
186
|
+
jg-side layout offset, the Composer was **still** rendering behind the nav. Root cause
|
|
187
|
+
traced into this package: `Thread.jsx`'s root `<Stack>` had no `flex`/`minHeight`, and its
|
|
188
|
+
scroll `Box` used a **hardcoded `maxHeight: 560`** — neither participates in a host's
|
|
189
|
+
flex-column layout at all, so on a short mobile viewport the thread never shrinks to fit,
|
|
190
|
+
regardless of how correctly the *host* sizes its own containers. Fixed: both the root
|
|
191
|
+
`Stack` and the scroll `Box` gained `flex: 1, minHeight: 0` (the classic flex-item
|
|
192
|
+
`min-height: auto` trap — a flex item won't shrink below its content's natural size
|
|
193
|
+
without this). `maxHeight: 560` kept as an upper-bound fallback, so a host that does
|
|
194
|
+
**not** embed `Thread` in a flex-column context sees **zero behaviour change** (both new
|
|
195
|
+
properties are no-ops outside a flex context). Live-verified end-to-end (see MSG-18's own
|
|
196
|
+
as-built record in jg-ferien for the full trace): the composer moved from `top: 742`
|
|
197
|
+
(inside the fixed nav's own `740–812` range) to `top: 629.5` (a clean 64px gap above the
|
|
198
|
+
nav at `740`), by locally building this package's `dist/` and patching it into jg's
|
|
199
|
+
`node_modules` for verification — no publish needed to prove the fix, but IS needed to
|
|
200
|
+
actually ship it (bundled into this same release, not deferred).
|
|
201
|
+
|
|
202
|
+
### Tests
|
|
203
|
+
`tests/messagingMsg6i.test.jsx` (new) — 7 tests: all 5 WO-required cases, one extra
|
|
204
|
+
(emoji-insertion behaviour still works from the new grid), plus a 6th added post-review
|
|
205
|
+
(see R3 below) asserting `Thread.jsx`'s `flex`/`minHeight` are actually applied.
|
|
206
|
+
|
|
207
|
+
### Verification (pre-review)
|
|
208
|
+
Full suite 249 → 255 (6 new), `tsc` clean.
|
|
209
|
+
|
|
210
|
+
### Independent review — `reviewer` + `ui_reviewer`, both run in parallel, findings + fixes
|
|
211
|
+
|
|
212
|
+
Both reviewers independently converged on the same P2 keyboard-navigation regression
|
|
213
|
+
(scope D) — treated as one finding below (R1/U1).
|
|
214
|
+
|
|
215
|
+
- **R1/U1 (P2, confirmed by both reviewers) — Composer's emoji grid broke MUI `Menu`'s
|
|
216
|
+
arrow-key roving-focus.** Nesting the 28 `IconButton`s inside an intervening
|
|
217
|
+
`<Box role="group">` meant `MenuList`'s `moveFocus` (which only traverses **direct**
|
|
218
|
+
children) saw a single non-tabbable child, so ArrowDown/Up/Home/End all became dead —
|
|
219
|
+
none of the 28 buttons reachable via the menu's own keyboard model, and nesting
|
|
220
|
+
`role="button"` descendants under `role="menu"` isn't a conformant ARIA pattern either.
|
|
221
|
+
**Fixed**: swapped `<Menu>` for `<Popover>` (`Composer.jsx`) — a grid of plain buttons
|
|
222
|
+
was never actually a linear menu, so this stops advertising semantics it can't deliver
|
|
223
|
+
and leaves keyboard traversal to the browser's native Tab order, which does reach every
|
|
224
|
+
button. Replicated Menu's default `anchorOrigin`/`transformOrigin`
|
|
225
|
+
(`{vertical:'bottom',horizontal:'left'}` / `{vertical:'top',horizontal:'left'}`) so the
|
|
226
|
+
picker's on-screen position is unchanged.
|
|
227
|
+
- **R2 (P2, `reviewer`) — optimistic vote could desync `vote_count` from `voters`.**
|
|
228
|
+
`MessagingProvider.applyOptimisticVote` bumped `vote_count` unconditionally but only
|
|
229
|
+
updated `voters` when `userId` was known (a documented "brief window" before
|
|
230
|
+
`AuthContext` populates `user` — in practice also the default state for any host/test
|
|
231
|
+
that doesn't wrap `MessagingProvider` in `AuthContext` at all, confirmed while fixing
|
|
232
|
+
this). Scope C's new `respondentCount` denominator reads only from `voters`, so that
|
|
233
|
+
window could show a transiently inconsistent/>-100% percentage. **Fixed**: added a
|
|
234
|
+
sentinel (`OPTIMISTIC_ANONYMOUS_VOTER`, a space-prefixed string no real UUID can match)
|
|
235
|
+
used in place of `userId` in `voters` when it's unknown, so `vote_count` and `voters`
|
|
236
|
+
move together in every case — the real vote still overwrites this on
|
|
237
|
+
reconcile/revert. Re-ran every existing poll-vote test
|
|
238
|
+
(`messagingMsg6g/Interactions/PollRendering/CompactBubble/Api`) to confirm the
|
|
239
|
+
already-tested optimistic-count behaviour (which relies on the no-`AuthContext` test
|
|
240
|
+
shape) still holds — it does.
|
|
241
|
+
- **R3 (P2, `reviewer`) — `Thread.jsx`'s flex/minHeight companion fix had zero regression
|
|
242
|
+
coverage**, and the "not meaningfully assertable via jsdom" justification didn't hold up
|
|
243
|
+
(this WO's own tile-size test already proves resolved `sx` values ARE readable via
|
|
244
|
+
`getComputedStyle` without real layout). **Fixed**: added test 6 to
|
|
245
|
+
`tests/messagingMsg6i.test.jsx`, asserting `flexGrow`/`minHeight` on both `Thread`'s
|
|
246
|
+
root `Stack` and scroll `Box`, plus that `maxHeight: 560` is still present as the
|
|
247
|
+
fallback cap.
|
|
248
|
+
- **U2 (P3, `ui_reviewer`) — hardcoded `fontSize: '1.15rem'`** on the per-emoji
|
|
249
|
+
`IconButton`. No existing theme/typography token covers emoji-glyph sizing (checked
|
|
250
|
+
`ReactionBar.jsx`'s own emoji rendering for precedent — none exists there either).
|
|
251
|
+
**Not fixed** — P3, no clear correct token to switch to; noted here rather than
|
|
252
|
+
silently dropped.
|
|
253
|
+
- **U3 (P3, `ui_reviewer`) — the emoji grid reused `MessagingComposer.ADD_EMOJI`** as its
|
|
254
|
+
`role="group"` `aria-label`, identical to the trigger button's own label, so a screen
|
|
255
|
+
reader announces "Add emoji" twice back-to-back. **Fixed**: added a dedicated
|
|
256
|
+
`MessagingComposer.EMOJI_PICKER` key ("Choose an emoji" / "Emoji auswählen" / "Choisir
|
|
257
|
+
un emoji") for the grid.
|
|
258
|
+
- **R4 (P3, `reviewer`) — test 4b's assertion (`input.value.length > 2`) didn't prove
|
|
259
|
+
cursor-position insertion**, only that something got appended. **Fixed**: rewrote it to
|
|
260
|
+
match the exact expected string at a mid-string cursor position (same shape as the
|
|
261
|
+
pre-existing `messagingComposerChunk5.test.jsx` assertion).
|
|
262
|
+
- **R5 (P3, `reviewer`) — residual, not fixed**: the 120px tile stretches whatever
|
|
263
|
+
`getAttachmentThumbnail` returns via `objectFit: cover`; if that thumbnail is
|
|
264
|
+
server-sized for the old 64px target, the larger tile could show blur. Not verifiable
|
|
265
|
+
from this repo (backend-controlled, untouched by this WO) — flagged as disclosed
|
|
266
|
+
residual risk, consistent with scope B's own "no live screenshot check this round" note.
|
|
267
|
+
|
|
268
|
+
### Verification (post-review)
|
|
269
|
+
Full suite (single-forked to avoid an unrelated vitest worker-pool timeout seen under the
|
|
270
|
+
default parallel pool): **45 test files, 256/256 passed** (255 + 1 new). `tsc -p
|
|
271
|
+
tsconfig.build.json --noEmit` clean.
|