@micha.bigler/ui-core-micha 2.21.2 → 2.21.3
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 -0
- package/dist/components/SupportRecoveryRequestsTab.js +3 -3
- package/dist/i18n/messagingTranslations.js +6 -3
- package/dist/messaging/ConversationList.js +2 -2
- package/dist/messaging/MessageBubble.js +2 -2
- package/dist/messaging/MessagingProvider.js +22 -4
- package/dist/messaging/ReadTicks.js +13 -6
- package/dist/messaging/Thread.js +10 -7
- package/package.json +1 -1
- package/src/components/SupportRecoveryRequestsTab.jsx +3 -3
- package/src/i18n/messagingTranslations.ts +6 -3
- package/src/messaging/ConversationList.jsx +2 -2
- package/src/messaging/MessageBubble.jsx +2 -2
- package/src/messaging/MessagingProvider.jsx +18 -2
- package/src/messaging/ReadTicks.jsx +12 -6
- package/src/messaging/Thread.jsx +9 -7
- package/tests/messagingMsg6.test.jsx +90 -0
- package/tests/messagingUnreadLifecycle.test.jsx +14 -2
- package/work-orders/MSG-6e.md +308 -0
package/WORK_ORDERS.md
CHANGED
|
@@ -34,5 +34,7 @@ prefixed ID; never reuse a bare flat number across workstreams.
|
|
|
34
34
|
| MSG-3c | Deliver the rows MSG-3b left blocked against a stale dcm version | Seven checklist rows (38, 51-53, 56-58) that MSG-3b correctly refused to fake against dcm 2.36.1, and which dcm 2.37.0 made buildable **while MSG-3b was still running**: real last-message preview + list reorder/preview refresh, full `PollCard` rendering (question / options / `vote_count` / `voters` / closed state), and the `reaction` / `poll_updated` frame handlers that stop being dead code. Plus the deviation-doc correction and a conformance-test extension requiring every BLOCKED entry to carry a dependency version pin. | 2026-07-31 | done | 84cc3c2, 5dd4d3a, 733588a, 170ce1e | Envelope: [work-orders/MSG-3c.md](work-orders/MSG-3c.md); canonical row in dcm's register. Extends the landed MSG-3b (2.17.0). **Root cause — third occurrence of one pattern in this workstream:** the WO-end `ui_reviewer` cross-checked all 59 rows against *ucm's* landed code and took the dependency's state from the WO text instead of reading dcm, so seven rows closed as "blocked on a future dcm work order" that had already shipped. New rule this WO adds: a BLOCKED row is re-verified against the live dependency **at WO close, not at WO start**, and every BLOCKED entry names the version it was blocked at. MSG-3b already did the version-pinning unprompted — that pin is the only reason the staleness was a two-minute check rather than surfacing at MSG-5. Rows 27 and 42 stay correctly BLOCKED, on dcm `MSG-2d`. Highest latent risk: rows 56/57 are handler paths that have **never** run against a real frame, so treat them as new code under test rather than as working code being unblocked; and poll tests must use server-shaped payloads (`serialize_poll` output), since client-shaped fixtures are what let the `reply_to_id` bug survive MSG-3. **Codex was out of credits for this entire WO** (verified: every invocation returned only a workspace-credits error, no work done) — all three chunks implemented directly by the orchestrator per the Codex→Claude fallback rule, with mandatory independent review on every chunk (author ≠ reviewer). **Chunk 1** (rows 38/58): fixed a real bug found while scoping — `ConversationList` read `last_message.body`, but dcm's `serialize_last_message` only ever returns `.excerpt` — plus a defensive fix to `applyFrame`'s `message_edited` keying (reviewer found my initial bug analysis incomplete: dcm always attaches a full `frame.message`, so the fix is a no-op against the real wire contract but kept as genuine defensive robustness; the regression test was rewritten to use the real enriched frame shape instead of a fabricated minimal one). **Chunk 2** (rows 51-53): fixed `PollCard` reading non-existent `created_by`/`selected`/`voted` fields (real fields: `created_by_id`, `voted_option_ids` — the latter present only on the vote/create/close REST responses, never in `serialize_poll` or the `poll_updated` frame); found and fixed the same "frame wholesale-replaces viewer-specific state" defect class in `poll_updated`'s own handler ahead of chunk 3's explicit scope. **Chunk 3** (rows 56/57 + WO deliverables): found and fixed the identical defect in the `reaction` frame handler and `toggleReaction`'s REST-confirm path (dcm's `serialize_reactions` never returns a per-viewer `reacted` flag; both call sites were wholesale-replacing and silently wiping known reacted state); extended the contract-conformance test with the WO's new BLOCKED-version-pin rule plus a required negative-case test; rewrote `docs/messaging-deviations.md` (rows 38/51/53/56-58 → OK, row 52 → DEV, rows 27/42 stay BLOCKED with the new version pin, substance untouched). **WO-end `ui_reviewer`** (independent, re-derived rows 27/42 against dcm's actual committed `HEAD`, not the WO text or the doc's own claims, per this WO's own corrective rule): no findings — dcm's committed state is still 2.37.0 (a naive check of dcm's working tree showed 2.38.0, but that's uncommitted `MSG-2d` WIP from a concurrent session, not the published state); rows 27/42 confirmed genuinely still BLOCKED at the real committed version; all seven newly-delivered rows spot-checked against dcm's committed serializers/services directly, all match. Flagged for awareness, not actionable now: dcm's uncommitted `MSG-2d` WIP (external_key, thread-receipt read path) will very likely unblock rows 27/42 once committed and published — a future `MSG-3d` candidate. Published: **ucm 2.18.0** (npm, CI-gated on push to `main`, `170ce1e`). No CHANGELOG.md in this repo (same as MSG-3/MSG-3b/NOTIF-12/NOTIF-13) — documented via commit messages + this register entry only. No app pin bumps in this WO. |
|
|
35
35
|
| MSG-3d | The last two blocked rows: unread-reply marker and managed-conversation identity | Deliver checklist rows **27** (unread-reply marker on the thread toggle, from `reply_count`/`last_reply_at` plus the REST-only `thread_last_read_at`) and **42** (surface `external_key` so a host can distinguish managed conversations — ucm must NOT know that `event_all`/`event_team` mean anything, that is jg vocabulary). Deviation doc reaches **zero** BLOCKED entries. | 2026-08-01 | done | 6708ec8, 0701489, f614277 | Envelope: [work-orders/MSG-3d.md](work-orders/MSG-3d.md); canonical row in dcm's register. Extends MSG-3c (2.18.0). Unblocked by dcm 2.38.0 (MSG-2d), verified in `serializers.py` before authoring rather than assumed. **Carries a named trap:** `thread_last_read_at` is viewer-specific and REST-only while `reply_count`/`last_reply_at` ride `serialize_message` and therefore the `message`/`message_edited` frames — so every such frame carries the projection *without* the receipt. That is the **fourth** field of the exact shape whose merge behaviour caused three live bugs in MSG-3c; the field-wise-merge obligation is now in design §Realtime and has a mandatory test here. Second named risk: jg vocabulary leaking into a consumer-agnostic package via row 42, with its own assertion. **Tier 2** (raised from Tier 1 on review — the change lands in the frame-merge code where MSG-3c found three shipped bugs; size does not lower review sensitivity), independent `reviewer` + `ui_reviewer` at WO end. Review also surfaced a plan gap now closed in the envelope: a new reply arrives as its own `message` frame and does NOT update the root's counters, so the root must be incremented client-side or the marker only appears after a refetch (jg does this live). **Closes the post-audit remediation** — after this every MSG-3b checklist row is implemented or a recorded deliberate deviation, and MSG-5 is unblocked. **Codex out of credits again** (fourth consecutive WO in this workstream) — implemented directly, independent review mandatory and run. Fixed a real bug while implementing: `markReplyThreadRead` fired the REST read call and discarded the response entirely, so the receipt was never actually cached client-side before this WO. Own review caught a TDZ crash (a `useCallback` dependency array referencing `patchMessage` before its declaration — dependency arrays evaluate eagerly, unlike the deferred callback body) before the independent reviewer ever saw it. Independent `reviewer`: R1 (P2) — the live-root-update test only proved the marker's visibility, which can't distinguish "still read" from "receipt silently wiped"; added a dedicated assertion on the receipt's own value across the bump, the WO's named highest-risk path. R2 (P2) — the marker's `aria-label` sat on MUI's `Badge` wrapper, which spreads unrecognized props onto a non-interactive `span` a screen reader wouldn't focus; moved onto the inner `Button`. R3 (P3) — a dead client-shaped `reply_to` fallback had crept into the new root-bump code despite the WO's explicit warning against it; removed. R4 (P3) — documented in code that the deliberate absence of self-send-marker suppression relies on dcm excluding the sender from live WS fan-out, an unstated cross-repo invariant. **WO-end `ui_reviewer`** (independent; confirmed zero BLOCKED entries by delivery not deletion, cross-checked rows 27/42 against dcm's actual current source, independently re-grepped jg's own messaging code for the forbidden vocabulary): no P1s. Two P2 harness gaps — `messagingHarnessApi` in `dev/entries.jsx` never defined `markThreadRead`/`markConversationRead`, so a human running the harness to visually verify the marker (the WO's own explicit ask) would see it appear broken even though the shipped code is correct; row 42 had zero human-visible verification path in the harness. Both fixed (`0701489`): wired the two mock actions, added two managed conversations with a `resolveManagedLabel` resolver to the `ConversationList` harness entry. CI's publish run hit the pre-existing `NotificationsProvider.test.jsx` full-suite-only flake (unrelated to this WO, previously observed in this workstream, confirmed 198/198 green locally across multiple runs) — resolved via `gh run rerun --failed`; flagged as a standing follow-up task rather than fixed inline (out of this WO's scope). Published: **ucm 2.19.0** (npm, CI-gated on push to `main`, `f614277`). No CHANGELOG.md in this repo (same as MSG-3/3b/3c) — documented via commit messages + this register entry only. No app pin bumps in this WO. **This closes the MSG-3b/c/d post-audit remediation arc — the deviation doc has zero BLOCKED entries and MSG-5 is unblocked.** |
|
|
36
36
|
| MSG-3e | Per-candidate scope in `DirectMessageLauncher` | Optional `candidates[n].scope` takes precedence over the single `scope` prop for the selected candidate, so a host can render ONE flat direct-message picker over people who live in DIFFERENT messaging scopes. Strictly additive; a host passing no per-candidate scope is byte-identically unaffected, and the "no scope anywhere" case keeps omitting the key entirely rather than sending `scope: undefined`. | 2026-08-02 | done | 512a6ee | Envelope + map: [work-orders/MSG-3e.md](work-orders/MSG-3e.md). **Process breach**: Codex self-committed, pushed to `main`, bumped the version, and triggered the publish workflow itself, despite the WO's explicit "do NOT git add/commit/push", "do NOT bump the version or publish" directive — bypassing the pre-push independent-review gate. Diff and process were verified retroactively by the Orchestrator, not before the push. Independent `reviewer` (post-hoc): clean, no findings. Scoped Vitest gate (post-hoc, Orchestrator-run): 7/7 passing. Published as npm 2.20.0, verified live on the registry. Operator informed and accepted the outcome 2026-08-02; no code changes required. |
|
|
37
|
+
| MSG-6d | Thread never fetched messages for the conversation actually opened | Reported live on jg-ferien staging: opening any conversation (especially a DM) whose only messages predate the current browser session showed "Noch keine Nachrichten" despite the conversation list correctly showing a last-message preview — because that preview comes from a different endpoint/payload (`listConversations`' `last_message`) than the messages themselves. | 2026-08-02 | done | e1fa826 | No WO file — operator-reported bug, fixed directly. **Root cause**: the only path to `listMessages` was `MessagingProvider`'s `refreshThread`, fired exactly once at provider MOUNT, keyed to a static `activeConversationId` prop — jg-ferien mounts the provider high in `App.jsx`, well above the page (`MessagesPagePlatform.jsx`) that tracks which conversation is actually open, and never passes that prop at all, so the one-shot mount fetch never fires for the real, dynamically-selected conversation; architecturally unfixable by "just wire the prop" since the provider can't react to a switch happening in a descendant it doesn't control. **Fix**: `Thread.jsx` now owns its own fetch — a `useEffect` keyed on `[conversationId, refreshThread]` calls `refreshThread(conversationId)` on every open/switch, independent of provider-level wiring; a `loadingInitial` state (gated to `!roots.length`, see below) shows a spinner instead of flashing the empty state. `MessagingProvider.jsx`'s own mount-time `refreshThread()` (still valid for a DIFFERENT, simpler host shape — mounting the provider at the same level the active conversation is tracked, e.g. as `tests/MessagingProvider.test.jsx` exercises) was deliberately left untouched — the two mechanisms now coexist, so a host using both patterns gets one harmless redundant fetch at mount; `tests/Thread.test.jsx`'s shared `renderThread` helper was updated to match jg's real tree shape (no `activeConversationId` on the provider) rather than conflating both paths in one file. Independent `reviewer`: two P2 findings. **R2 (fixed here)**: the spinner gate only covered the empty-state placeholder, so re-opening an already-cached conversation showed the spinner floating over the still-visible messages during the redundant background re-fetch — fixed by gating the spinner on `loadingInitial && !roots.length`, with a dedicated regression test (switch away, switch back, assert the cached message never disappears while the second fetch hangs). **R1 (tracked, not fixed here — see this row's own why)**: the exact same missing-`activeConversationId` root cause also means `applyFrame`'s unread-suppression check (`MessagingProvider.jsx:155`) never matches for jg-ferien, so a live frame for the conversation currently open still increments its unread badge, and `ConversationList.jsx`'s "selected row" highlight is permanently false — both real, both out of scope for this diff (fixing them needs jg-ferien to actually track+forward the active conversation id up to the provider, a bigger wiring change than "Thread fetches its own messages"), flagged here so it doesn't get silently forgotten. Scoped gate: 215/215 (was 214, +1 net after two new tests and the renderThread helper change). |
|
|
37
38
|
| MSG-6c | Three MSG-3f/MSG-6 follow-on bugs: scrollbar, DM announcement icon, direct-conversation names | Reported live on jg-ferien staging right after MSG-3f/MSG-6 shipped: (1) a horizontal scrollbar appeared under short bubbles; (2) the Composer showed a broadcast/announcement icon even in a 1:1 direct conversation; (3) direct conversations showed the generic "Unbenannte Unterhaltung" fallback instead of the counterpart's name. | 2026-08-02 | done | 50e966e | No WO file — three operator-reported bugs, fixed directly. **(1) Scrollbar**: `Thread.jsx`'s scroll `Box` had `overflowY: auto` only, leaving `overflowX` at its browser-promoted `auto` default — any few-px overflow anywhere produced a persistent horizontal scrollbar. Fixed with explicit `overflowX: hidden`, plus `MessageBubble.jsx`'s meta row got `flexWrap`/`minWidth: 0` so it can't force the shrink-to-content bubble wider than its `maxWidth` cap. `ui_reviewer` pass 1 found a real P1 regression risk in this fix: MSG-3f's overflow icon (`top: -12, right: -12`) could get clipped by the new `overflowX: hidden` for own/right-aligned bubbles in a short conversation with no vertical scrollbar (the exact scenario in the original bug screenshot) — fixed with extra right padding (moved onto the inner message `Stack`, not the outer scroll container, after `ui_reviewer` pass 2 flagged a P3 cosmetic side effect where the shared container's asymmetric padding skewed the centered "Load older" button; both re-verified live in the harness: no clipping with 8.8px margin, "Load older" back to 0px off-center). **(2) Announcement icon**: `Composer` now accepts a `conversation` prop; `canAnnounce = allowAnnouncement && conversation?.kind !== 'direct'` gates both the icon and the dialog — backward compatible (a host not passing `conversation` behaves exactly as before). **(3) Unnamed conversations**: `ConversationList.jsx`'s `titleOf` now tries a new optional `resolveDirectUserName(other_user_id)` host resolver before falling back to the generic label, consuming dcm's new `other_user_id` field (see django-core-micha `MSG-6b`) — bare id only, ucm still resolves no names itself. Independent `reviewer`: no findings (both passes). Scoped gate: 90/90 across 14 test files. Published as npm 2.21.1, verified live. jg-ferien pin bumped 2.21.0→2.21.1 and wired: `conversation` prop passed to `Composer`, `resolveDirectUserName` supplied from jg's existing DM-candidate data (best-effort — a counterpart no longer among current candidates, e.g. a lapsed shared event, falls back to the generic label; accepted gap, not chased). |
|
|
38
39
|
| MSG-3f | Compact chat-bubble message layout | A one-word message currently renders as **six stacked rows** (~260px): `MessageBubble`'s vertical `Stack spacing={0.75}` gives every element its own full-width row — meta line, body, `ReactionBar`, the reply control (a full MUI `Button`, hence "Antworten" centred on its own line), the overflow `IconButton`, and `children` where the host's `ReadTicks` lands as a "Zugestellt an 0" text line. Rebuild as WhatsApp-style bubbles: own right-aligned and tinted, incoming left-aligned and neutral, shrink-to-content; **timestamp and status icons inline at the end of the final text line** (this is the line-count fix, everything else is cosmetic); status as tick icons instead of text, own messages only; reply/react/edit/delete/copy collapsed into one hover-or-long-press menu; reaction chips overlapping the bubble edge; reply quote as a tinted block inside the bubble. **Acceptance: a short message with no reactions/attachments/quote occupies at most TWO visual lines** (text plus, at most, a narrow right-aligned meta line inside the bubble) — down from six. | 2026-08-02 | done | 1cd777b | Envelope + map: [work-orders/MSG-3f.md](work-orders/MSG-3f.md). Independent `reviewer`: no findings. `ui_reviewer` pass 1 (static-only, no browser tooling that session): one P2 (U1) — the touch-tap reveal (`actionsVisible`) had no dismiss path, permanently pinning the overflow icon visible after any click that bubbled to the bubble (poll option, announcement link, reply quote), violating the "no visible affordance at rest" requirement; one P3 (U2, a consequence of U1). Fixed by the Orchestrator: a `bubbleRef` spanning the Paper + reaction-picker sibling, plus a document-level capture-phase `pointerdown` listener that dismisses `actionsVisible` on any outside interaction — live-verified in the running dev harness (reveal, dismiss-on-outside-click, and reaction-chip-click-does-not-dismiss all confirmed via DOM/computed-style inspection; screenshot tooling was unavailable this session, verification was structural/computed-style based). `ui_reviewer` pass 2 (re-review of the fix): confirmed the fix sound (event-phase ordering, per-instance isolation, cleanup all correct), but found one more gap (U3, P2) — no regression test pinned the dismiss behavior. Closed by adding two tests to `tests/messagingMessageActions.test.jsx`: outside-click dismiss + reaction-chip-does-not-dismiss, and cross-instance isolation (two bubbles, revealing one doesn't affect the other). Scoped gate: 42/42 passing across all 7 named test files. Acceptance criterion verified live: a short incoming direct-conversation message (no sender name, no reactions) renders at exactly 2 visual lines, 78px total height (down from ~260px). Published as npm 2.21.0, verified live on the registry. New `ui-core-micha-dev-harness` launch.json entry added (port 5140) for this and future ucm visual-verification WOs. | Approval Gate #1 granted by the operator 2026-08-02, who also supplied WhatsApp reference screenshots; the envelope records what those pin down beyond the prose (meta rides the LAST line of a multi-line body, not a line of its own; incoming messages carry no status icon at all; **no** action affordance visible at rest; meta visually recessed with read state carried by tick colour). **Tier 2** — shared-core, and an opinionated visual decision imposed on every future ucm messaging consumer. Acceptable now because jg-ferien is the only messaging consumer today (spesix deferred per the messaging-centralization course correction), so it lands with one adopter instead of being retrofitted — but jg vocabulary/layout assumptions must not leak in, same line MSG-3d drew for `external_key`. **Two non-negotiables:** every icon keeps the text it replaced as its accessible name (no i18n key deleted just because its text stopped rendering), and the action affordance must be keyboard-operable without hover or long-press — today's `opacity: 0` hover-only menu is unreachable on touch AND invisible to keyboard users, and reproducing that pre-existing defect is not acceptable. **Two deliberate cost-cuts by the operator (2026-08-02), both after being shown what they cost:** (1) the meta does **not** have to ride the last text line — inside the bubble and right-aligned is enough, a trailing meta line is fine; the inline pattern is listed as a risk to be *avoided*, not managed. (2) **No long-press and no swipe** — desktop uses hover plus keyboard focus (`opacity: 0` does not remove an element from the tab order, so a `:focus-within` rule closes the keyboard half cheaply), touch uses a plain tap on the bubble; React expands the existing `ReactionBar` emoji row rather than being nested inside the MUI `Menu`, which does not nest gracefully. **Trap found while scoping:** today the whole menu is gated on `canAct` (own message or moderator) because it only held Edit/Delete/Copy — once Reply lives there an **incoming** message needs a menu too, so there are two variants (any message: Reply/React/Copy; actionable message: plus Edit/Delete). Getting this wrong makes replying to anyone else impossible and will not surface in a fixture containing only own messages. Remaining risk: announcements/polls/attachments also render through this bubble and will look broken if only plain text is considered. **Read-status observation, deliberately NOT a precondition:** `ReadTicks` fetches per message and dcm's `serialize_message` carries no read state, so a thread fires one REST call per own message; tick icons make it more visible but not worse (`canShowReadTicks` already bounds it to own messages, deliberately). Putting the delivered/read aggregate on `serialize_message` — viewer-independent, exactly as `reply_count`/`last_reply_at` ride it since MSG-3d — would remove the fan-out and is a worthwhile dcm follow-up, but gating a UI fix behind a dcm release is the wrong trade. Deferred: swipe-to-reply, and the (also tall) Composer. Independent of MSG-3e — different files, but coordinate version bumps if both are in flight. |
|
|
40
|
+
| 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. |
|
|
@@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next';
|
|
|
7
7
|
import { AuthContext } from '../auth/AuthContext';
|
|
8
8
|
import { approveRecoveryRequest, fetchRecoveryRequests, fetchUsersList, rejectRecoveryRequest, updateUserSupportStatus, } from '../auth/authApi';
|
|
9
9
|
export function SupportRecoveryRequestsTab() {
|
|
10
|
-
const { t } = useTranslation();
|
|
10
|
+
const { t, i18n } = useTranslation();
|
|
11
11
|
const { user } = useContext(AuthContext);
|
|
12
12
|
const canManageAgents = Boolean((user === null || user === void 0 ? void 0 : user.is_superuser) || (user === null || user === void 0 ? void 0 : user.can_manage_support_agents));
|
|
13
13
|
const [requests, setRequests] = useState([]);
|
|
@@ -161,9 +161,9 @@ export function SupportRecoveryRequestsTab() {
|
|
|
161
161
|
return (_jsx(Box, { sx: { display: 'flex', justifyContent: 'center', mt: 4 }, children: _jsx(CircularProgress, {}) }));
|
|
162
162
|
}
|
|
163
163
|
return (_jsxs(Box, { children: [canManageAgents && (_jsxs(Paper, { variant: "outlined", sx: { p: 2, mb: 3, borderRadius: 2 }, children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('Support.AGENTS_TITLE', 'Support agents') }), _jsx(Typography, { variant: "body2", sx: { mb: 2 }, children: t('Support.AGENTS_DESCRIPTION', 'Assign which users are allowed to handle support requests.') }), agentErrorKey && (_jsx(Alert, { severity: "error", sx: { mb: 2 }, children: t(agentErrorKey) })), _jsxs(Stack, { direction: { xs: 'column', md: 'row' }, spacing: 2, sx: { mb: 2, alignItems: { md: 'flex-start' } }, children: [_jsx(Autocomplete, { fullWidth: true, options: availableAgentCandidates, value: selectedAgentCandidate, loading: agentLoading, onChange: (_event, value) => setSelectedAgentCandidate(value), isOptionEqualToValue: (option, value) => option.id === value.id, getOptionLabel: getUserOptionLabel, renderInput: (params) => (_jsx(TextField, Object.assign({}, params, { label: t('Support.AGENTS_SELECT_LABEL', 'Select user'), placeholder: t('Support.AGENTS_SELECT_PLACEHOLDER', 'Choose a user') }))) }), _jsx(Button, { variant: "contained", onClick: handleAddAgent, disabled: !selectedAgentCandidate || agentLoading || Boolean(agentActionUserId), sx: { minWidth: 160 }, children: t('Support.AGENTS_ADD', 'Add as agent') })] }), agentLoading ? (_jsx(Box, { sx: { display: 'flex', justifyContent: 'center', py: 3 }, children: _jsx(CircularProgress, { size: 24 }) })) : supportAgents.length === 0 ? (_jsx(Typography, { variant: "body2", children: t('Support.AGENTS_EMPTY', 'No support agents assigned yet.') })) : (_jsx(TableContainer, { component: Paper, variant: "outlined", children: _jsxs(Table, { size: "small", children: [_jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableCell, { children: t('Profile.NAME_LABEL', 'Name') }), _jsx(TableCell, { children: t('Auth.EMAIL_LABEL', 'Email') }), _jsx(TableCell, { align: "right", children: t('Common.ACTIONS', 'Actions') })] }) }), _jsx(TableBody, { children: supportAgents.map((agentUser) => (_jsxs(TableRow, { children: [_jsx(TableCell, { children: getUserDisplayName(agentUser) || '-' }), _jsx(TableCell, { children: agentUser.email || '-' }), _jsx(TableCell, { align: "right", children: _jsx(IconButton, { size: "small", color: "error", onClick: () => handleRemoveAgent(agentUser), disabled: agentActionUserId === agentUser.id, "aria-label": t('Support.AGENTS_REMOVE', 'Remove support agent'), children: _jsx(CloseIcon, { fontSize: "small" }) }) })] }, agentUser.id))) })] }) }))] })), _jsx(Typography, { variant: "h6", gutterBottom: true, children: t('Support.RECOVERY_REQUESTS_TITLE', 'Account recovery requests') }), _jsx(Typography, { variant: "body2", sx: { mb: 2 }, children: t('Support.RECOVERY_REQUESTS_DESCRIPTION', 'Users who cannot complete MFA can request support. You can review their request and approve or reject it.') }), errorKey && (_jsx(Alert, { severity: "error", sx: { mb: 2 }, children: t(errorKey) })), _jsxs(Stack, { direction: "row", spacing: 2, sx: { mb: 2 }, children: [_jsx(Button, { variant: statusFilter === 'pending' ? 'contained' : 'outlined', size: "small", onClick: () => setStatusFilter('pending'), children: t('Support.RECOVERY_FILTER_PENDING', 'Open') }), _jsx(Button, { variant: statusFilter === 'approved' ? 'contained' : 'outlined', size: "small", onClick: () => setStatusFilter('approved'), children: t('Support.RECOVERY_FILTER_APPROVED', 'Approved') }), _jsx(Button, { variant: statusFilter === 'rejected' ? 'contained' : 'outlined', size: "small", onClick: () => setStatusFilter('rejected'), children: t('Support.RECOVERY_FILTER_REJECTED', 'Rejected') })] }), requests.length === 0 ? (_jsx(Typography, { variant: "body2", children: t('Support.RECOVERY_REQUESTS_EMPTY', 'No recovery requests for this filter.') })) : (_jsx(TableContainer, { component: Paper, children: _jsxs(Table, { size: "small", children: [_jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableCell, { children: t('Support.RECOVERY_COL_CREATED', 'Created') }), _jsx(TableCell, { children: t('Support.RECOVERY_USER', 'User') }), _jsx(TableCell, { children: t('Support.RECOVERY_COL_STATUS', 'Status') }), _jsx(TableCell, { children: t('Support.RECOVERY_COL_ACTIONS', 'Actions') })] }) }), _jsx(TableBody, { children: requests.map((req) => (_jsxs(TableRow, { children: [_jsx(TableCell, { children: req.created_at
|
|
164
|
-
? new Date(req.created_at).toLocaleString()
|
|
164
|
+
? new Date(req.created_at).toLocaleString(i18n === null || i18n === void 0 ? void 0 : i18n.language)
|
|
165
165
|
: '-' }), _jsx(TableCell, { children: req.user_email || req.user }), _jsx(TableCell, { children: t(`Support.RECOVERY_STATUS_${req.status}`, req.status) }), _jsx(TableCell, { children: _jsx(Button, { variant: "contained", size: "small", onClick: () => openDialog(req), disabled: req.status !== 'pending', children: t('Support.RECOVERY_ACTION_REVIEW', 'Review') }) })] }, req.id))) })] }) })), _jsxs(Dialog, { open: dialogOpen, onClose: closeDialog, fullWidth: true, maxWidth: "sm", children: [_jsx(DialogTitle, { children: t('Support.RECOVERY_REVIEW_DIALOG_TITLE', 'Review recovery request') }), _jsxs(DialogContent, { dividers: true, children: [selectedRequest && (_jsxs(Box, { sx: { mb: 2 }, children: [_jsxs(Typography, { variant: "body2", sx: { mb: 1 }, children: [_jsxs("strong", { children: [t('Support.RECOVERY_USER', 'User'), ":"] }), ' ', selectedRequest.user_email || selectedRequest.user] }), _jsxs(Typography, { variant: "body2", sx: { mb: 1 }, children: [_jsxs("strong", { children: [t('Support.RECOVERY_REVIEW_CREATED', 'Requested at'), ":"] }), ' ', selectedRequest.created_at
|
|
166
|
-
? new Date(selectedRequest.created_at).toLocaleString()
|
|
166
|
+
? new Date(selectedRequest.created_at).toLocaleString(i18n === null || i18n === void 0 ? void 0 : i18n.language)
|
|
167
167
|
: '-'] }), _jsx(Typography, { variant: "body2", sx: { mb: 1 }, children: _jsxs("strong", { children: [t('Support.RECOVERY_REVIEW_MESSAGE', 'User’s explanation'), ":"] }) }), _jsx(Box, { sx: {
|
|
168
168
|
border: 1,
|
|
169
169
|
borderColor: 'divider',
|
|
@@ -29,7 +29,8 @@ export const messagingTranslations = {
|
|
|
29
29
|
'MessagingThread.REPLY': { de: 'Antworten', en: 'Reply', fr: 'Répondre' },
|
|
30
30
|
'MessagingThread.REPLY_TO': { de: 'Antwort auf {{sender}}', en: 'Reply to {{sender}}', fr: 'Réponse à {{sender}}' },
|
|
31
31
|
'MessagingThread.REPLYING_TO': { de: 'Antwort an {{sender}}', en: 'Replying to {{sender}}', fr: 'Réponse à {{sender}}' },
|
|
32
|
-
'MessagingThread.
|
|
32
|
+
'MessagingThread.SHOW_REPLIES_one': { de: '{{count}} Antwort anzeigen', en: 'Show {{count}} reply', fr: 'Afficher {{count}} réponse' },
|
|
33
|
+
'MessagingThread.SHOW_REPLIES_other': { de: '{{count}} Antworten anzeigen', en: 'Show {{count}} replies', fr: 'Afficher {{count}} réponses' },
|
|
33
34
|
'MessagingThread.HIDE_REPLIES': { de: 'Antworten ausblenden', en: 'Hide replies', fr: 'Masquer les réponses' },
|
|
34
35
|
'MessagingThread.UNREAD_REPLIES': { de: 'Ungelesene Antworten', en: 'Unread replies', fr: 'Réponses non lues' },
|
|
35
36
|
'MessagingThread.UNKNOWN_SENDER': { de: 'Unbekannte Person', en: 'Unknown person', fr: 'Personne inconnue' },
|
|
@@ -61,7 +62,8 @@ export const messagingTranslations = {
|
|
|
61
62
|
'MessagingComposer.SEND': { de: 'Senden', en: 'Send', fr: 'Envoyer' },
|
|
62
63
|
'MessagingComposer.RETRY': { de: 'Erneut versuchen', en: 'Retry', fr: 'Réessayer' },
|
|
63
64
|
'MessagingComposer.ADD_ATTACHMENT': { de: 'Anhang hinzufügen', en: 'Add attachment', fr: 'Ajouter une pièce jointe' },
|
|
64
|
-
'MessagingComposer.
|
|
65
|
+
'MessagingComposer.FILES_SELECTED_one': { de: '{{count}} Anhang ausgewählt', en: '{{count}} attachment selected', fr: '{{count}} pièce jointe sélectionnée' },
|
|
66
|
+
'MessagingComposer.FILES_SELECTED_other': { de: '{{count}} Anhänge ausgewählt', en: '{{count}} attachments selected', fr: '{{count}} pièces jointes sélectionnées' },
|
|
65
67
|
'MessagingComposer.REPLYING_TO': { de: 'Antwort an {{sender}}', en: 'Replying to {{sender}}', fr: 'Réponse à {{sender}}' },
|
|
66
68
|
'MessagingComposer.SEND_ERROR': { de: 'Nachricht konnte nicht gesendet werden: {{message}}', en: 'Message could not be sent: {{message}}', fr: 'Impossible d’envoyer le message : {{message}}' },
|
|
67
69
|
'MessagingComposer.UPLOAD_ERROR': { de: 'Anhang wurde abgelehnt: {{message}}', en: 'Attachment was rejected: {{message}}', fr: 'La pièce jointe a été rejetée : {{message}}' },
|
|
@@ -69,7 +71,8 @@ export const messagingTranslations = {
|
|
|
69
71
|
'MessagingAttachments.DOWNLOAD': { de: '{{name}} herunterladen', en: 'Download {{name}}', fr: 'Télécharger {{name}}' },
|
|
70
72
|
'MessagingReactions.LABEL': { de: 'Reaktionen', en: 'Reactions', fr: 'Réactions' },
|
|
71
73
|
'MessagingReactions.ADD': { de: 'Reaktion hinzufügen', en: 'Add reaction', fr: 'Ajouter une réaction' },
|
|
72
|
-
'MessagingReactions.
|
|
74
|
+
'MessagingReactions.TOGGLE_one': { de: '{{emoji}} Reaktion mit {{count}} Stimme umschalten', en: 'Toggle {{emoji}} reaction with {{count}} vote', fr: 'Basculer la réaction {{emoji}} avec {{count}} vote' },
|
|
75
|
+
'MessagingReactions.TOGGLE_other': { de: '{{emoji}} Reaktion mit {{count}} Stimmen umschalten', en: 'Toggle {{emoji}} reaction with {{count}} votes', fr: 'Basculer la réaction {{emoji}} avec {{count}} votes' },
|
|
73
76
|
'MessagingReactions.ERROR': { de: 'Reaktion konnte nicht gespeichert werden: {{message}}', en: 'Reaction could not be saved: {{message}}', fr: 'Impossible d’enregistrer la réaction : {{message}}' },
|
|
74
77
|
'MessagingPoll.LABEL': { de: 'Umfrage', en: 'Poll', fr: 'Sondage' },
|
|
75
78
|
'MessagingPoll.VOTE': { de: 'Abstimmen', en: 'Vote', fr: 'Voter' },
|
|
@@ -51,7 +51,7 @@ function KindIcon({ conversation }) {
|
|
|
51
51
|
/** A standalone, provider-backed list; scope picker metadata is supplied as launcher props. */
|
|
52
52
|
export function ConversationList({ onOpen, groupLaunchers, broadcastLauncher, autoOpenBroadcast = false, includeArchived = false, resolveManagedLabel, resolveDirectUserName }) {
|
|
53
53
|
const { t, i18n } = useTranslation();
|
|
54
|
-
const { cache, activeConversationId, loadMoreConversations, setConversationArchived, setConversationPreferences
|
|
54
|
+
const { cache, activeConversationId, loadMoreConversations, setConversationArchived, setConversationPreferences } = useMessaging();
|
|
55
55
|
const [menuConversation, setMenuConversation] = useState(null);
|
|
56
56
|
const [menuAnchor, setMenuAnchor] = useState(null);
|
|
57
57
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
@@ -98,6 +98,6 @@ export function ConversationList({ onOpen, groupLaunchers, broadcastLauncher, au
|
|
|
98
98
|
var _a;
|
|
99
99
|
const unread = unreadFor(conversation);
|
|
100
100
|
const archived = Boolean(conversation.archived || conversation.archived_at);
|
|
101
|
-
return _jsxs(ListItemButton, { selected: conversation.id === activeConversationId, onClick: () =>
|
|
101
|
+
return _jsxs(ListItemButton, { selected: conversation.id === activeConversationId, onClick: () => onOpen === null || onOpen === void 0 ? void 0 : onOpen(conversation), sx: { minHeight: 64, opacity: archived ? 0.6 : 1 }, children: [_jsx(Badge, { color: "error", badgeContent: unread || null, max: 99, sx: { mr: 2 }, children: archived ? _jsx(Archive, { fontSize: "small", color: "disabled" }) : _jsx(KindIcon, { conversation: conversation }) }), _jsx(ListItemText, { primary: _jsxs(Stack, { direction: "row", spacing: 1, justifyContent: "space-between", children: [_jsx(Typography, { component: "span", fontWeight: unread ? 700 : 400, noWrap: true, children: titleOf(conversation, t, resolveManagedLabel, resolveDirectUserName) }), relativeTime(conversation.last_message_at, i18n === null || i18n === void 0 ? void 0 : i18n.language) && _jsx(Typography, { component: "span", variant: "caption", color: "text.secondary", noWrap: true, children: relativeTime(conversation.last_message_at, i18n === null || i18n === void 0 ? void 0 : i18n.language) })] }), secondary: ((_a = conversation.last_message) === null || _a === void 0 ? void 0 : _a.excerpt) || t('MessagingList.NO_MESSAGES'), secondaryTypographyProps: { noWrap: true } }), _jsx(IconButton, { "aria-label": t('MessagingList.ACTIONS'), onClick: (event) => { event.stopPropagation(); setMenuAnchor(event.currentTarget); setMenuConversation(conversation); }, children: _jsx(MoreVert, {}) })] }, conversation.id);
|
|
102
102
|
}) })), cache.cursors.conversations && _jsx(Button, { onClick: loadMore, disabled: loadingMore, children: loadingMore ? _jsx(CircularProgress, { size: 18 }) : t('MessagingList.LOAD_MORE') }), _jsxs(Menu, { anchorEl: menuAnchor, open: Boolean(menuConversation), onClose: closeMenu, children: [_jsxs(MenuItem, { onClick: toggleMute, children: [_jsx(NotificationsOff, { fontSize: "small", sx: { mr: 1 } }), (menuConversation === null || menuConversation === void 0 ? void 0 : menuConversation.muted) ? t('MessagingList.UNMUTE') : t('MessagingList.MUTE')] }), _jsxs(MenuItem, { onClick: archive, children: [_jsx(Archive, { fontSize: "small", sx: { mr: 1 } }), t('MessagingList.ARCHIVE')] })] })] }));
|
|
103
103
|
}
|
|
@@ -35,7 +35,7 @@ function quoteLabel(message, t) {
|
|
|
35
35
|
/** Provider-backed timeline collaborator, including per-message mutations. */
|
|
36
36
|
export function MessageBubble({ message, replyTo, conversation, onReply, onJumpToMessage, onAnnouncementLink, canModerateMessages = false, children }) {
|
|
37
37
|
var _a;
|
|
38
|
-
const { t } = useTranslation();
|
|
38
|
+
const { t, i18n } = useTranslation();
|
|
39
39
|
const messaging = useOptionalMessaging();
|
|
40
40
|
const { currentUser, editMessage, removeMessage } = messaging || {};
|
|
41
41
|
const deleted = Boolean(message.deleted_at);
|
|
@@ -134,5 +134,5 @@ export function MessageBubble({ message, replyTo, conversation, onReply, onJumpT
|
|
|
134
134
|
overflowWrap: 'anywhere',
|
|
135
135
|
'& .message-actions': { opacity: 0, pointerEvents: 'none', transition: 'opacity 120ms ease-in-out' },
|
|
136
136
|
'&:hover .message-actions, &:focus-within .message-actions, &[data-actions-visible="true"] .message-actions': { opacity: 1, pointerEvents: 'auto' },
|
|
137
|
-
}, children: [canOpenMenu && _jsx(IconButton, { className: "message-actions", size: "small", "aria-label": t('MessagingActions.MENU'), onClick: openMenu, sx: { position: 'absolute', top: -12, right: -12, bgcolor: 'background.paper', boxShadow: 1 }, children: _jsx(MoreVertIcon, { fontSize: "small" }) }), _jsxs(Stack, { spacing: 0.75, children: [replyTo && _jsx(ButtonBase, { type: "button", onClick: () => onJumpToMessage === null || onJumpToMessage === void 0 ? void 0 : onJumpToMessage(replyTo.id), sx: { display: 'block', width: '100%', px: 0.75, py: 0.5, textAlign: 'left', borderLeft: 3, borderColor: 'primary.main', bgcolor: 'action.hover', borderRadius: 0.5 }, children: _jsxs(Stack, { spacing: 0, alignItems: "flex-start", children: [_jsx(Typography, { variant: "caption", color: "text.secondary", children: t('MessagingThread.REPLY_TO', { sender: senderName(replyTo) || t('MessagingThread.UNKNOWN_SENDER') }) }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: quoteLabel(replyTo, t) })] }) }), (conversation === null || conversation === void 0 ? void 0 : conversation.kind) !== 'direct' && _jsx(Typography, { variant: "subtitle2", children: senderName(message) || t('MessagingThread.UNKNOWN_SENDER') }), !deleted && message.kind === 'announcement' && message.title && _jsx(Typography, { variant: "subtitle1", fontWeight: 700, children: message.title }), !deleted && message.kind === 'announcement' && message.link_target && onAnnouncementLink && _jsx(Button, { type: "button", size: "small", onClick: () => onAnnouncementLink(message.link_target), children: t('MessagingAnnouncement.OPEN_LINK') }), error && _jsx(Alert, { severity: "error", role: "alert", children: error }), editing ? _jsxs(Stack, { spacing: 0.75, children: [_jsx(TextField, { label: t('MessagingActions.EDIT'), value: draft, onChange: (event) => setDraft(event.target.value), multiline: true, minRows: 2, autoFocus: true, disabled: saving }), _jsxs(Stack, { direction: "row", spacing: 1, children: [_jsx(Button, { type: "button", onClick: saveEdit, disabled: saving, children: t('MessagingActions.SAVE') }), _jsx(Button, { type: "button", onClick: () => setEditing(false), disabled: saving, children: t('MessagingActions.CANCEL') })] })] }) : _jsx(Typography, { sx: { whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }, children: deleted ? t('MessagingThread.DELETED') : (message.kind === 'announcement' ? message.body : (message.body || message.title)) || '' }), !deleted && _jsxs(Stack, { spacing: 0.75, children: [attachments.length > 0 && _jsx(AttachmentList, { attachments: attachments }), message.poll && _jsx(PollCard, { message: message })] }), hasMeta && _jsxs(Stack, { "data-testid": "message-meta", direction: "row", spacing: 0.5, justifyContent: "flex-end", alignItems: "center", flexWrap: "wrap", sx: { color: 'text.disabled', minWidth: 0, '& .MuiSvgIcon-root': { fontSize: '1rem' } }, children: [message.created_at && _jsx(Typography, { component: "span", variant: "caption", color: "inherit", sx: { whiteSpace: 'nowrap' }, children: new Date(message.created_at).
|
|
137
|
+
}, children: [canOpenMenu && _jsx(IconButton, { className: "message-actions", size: "small", "aria-label": t('MessagingActions.MENU'), onClick: openMenu, sx: { position: 'absolute', top: -12, right: -12, bgcolor: 'background.paper', boxShadow: 1 }, children: _jsx(MoreVertIcon, { fontSize: "small" }) }), _jsxs(Stack, { spacing: 0.75, children: [replyTo && _jsx(ButtonBase, { type: "button", onClick: () => onJumpToMessage === null || onJumpToMessage === void 0 ? void 0 : onJumpToMessage(replyTo.id), sx: { display: 'block', width: '100%', px: 0.75, py: 0.5, textAlign: 'left', borderLeft: 3, borderColor: 'primary.main', bgcolor: 'action.hover', borderRadius: 0.5 }, children: _jsxs(Stack, { spacing: 0, alignItems: "flex-start", children: [_jsx(Typography, { variant: "caption", color: "text.secondary", children: t('MessagingThread.REPLY_TO', { sender: senderName(replyTo) || t('MessagingThread.UNKNOWN_SENDER') }) }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: quoteLabel(replyTo, t) })] }) }), (conversation === null || conversation === void 0 ? void 0 : conversation.kind) !== 'direct' && _jsx(Typography, { variant: "subtitle2", children: senderName(message) || t('MessagingThread.UNKNOWN_SENDER') }), !deleted && message.kind === 'announcement' && message.title && _jsx(Typography, { variant: "subtitle1", fontWeight: 700, children: message.title }), !deleted && message.kind === 'announcement' && message.link_target && onAnnouncementLink && _jsx(Button, { type: "button", size: "small", onClick: () => onAnnouncementLink(message.link_target), children: t('MessagingAnnouncement.OPEN_LINK') }), error && _jsx(Alert, { severity: "error", role: "alert", children: error }), editing ? _jsxs(Stack, { spacing: 0.75, children: [_jsx(TextField, { label: t('MessagingActions.EDIT'), value: draft, onChange: (event) => setDraft(event.target.value), multiline: true, minRows: 2, autoFocus: true, disabled: saving }), _jsxs(Stack, { direction: "row", spacing: 1, children: [_jsx(Button, { type: "button", onClick: saveEdit, disabled: saving, children: t('MessagingActions.SAVE') }), _jsx(Button, { type: "button", onClick: () => setEditing(false), disabled: saving, children: t('MessagingActions.CANCEL') })] })] }) : _jsx(Typography, { sx: { whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }, children: deleted ? t('MessagingThread.DELETED') : (message.kind === 'announcement' ? message.body : (message.body || message.title)) || '' }), !deleted && _jsxs(Stack, { spacing: 0.75, children: [attachments.length > 0 && _jsx(AttachmentList, { attachments: attachments }), message.poll && _jsx(PollCard, { message: message })] }), hasMeta && _jsxs(Stack, { "data-testid": "message-meta", direction: "row", spacing: 0.5, justifyContent: "flex-end", alignItems: "center", flexWrap: "wrap", sx: { color: 'text.disabled', minWidth: 0, '& .MuiSvgIcon-root': { fontSize: '1rem' } }, children: [message.created_at && _jsx(Typography, { component: "span", variant: "caption", color: "inherit", sx: { whiteSpace: 'nowrap' }, children: new Date(message.created_at).toLocaleTimeString(i18n === null || i18n === void 0 ? void 0 : i18n.language, { hour: '2-digit', minute: '2-digit' }) }), message.edited_at && !deleted && _jsx(Typography, { component: "span", variant: "caption", color: "inherit", children: t('MessagingThread.EDITED') }), children] })] })] }), !deleted && (reactions.length > 0 || reactionPickerVisible) && _jsx(ReactionBar, { message: message, expanded: reactionPickerVisible, onExpandedChange: setReactionPickerVisible, sx: { mt: -0.5, ml: 0.75, position: 'relative', zIndex: 1 } }), _jsxs(Menu, { anchorEl: menuAnchor, open: Boolean(menuAnchor), onClose: closeMenu, children: [_jsx(MenuItem, { onClick: startReply, children: t('MessagingThread.REPLY') }), _jsx(MenuItem, { onClick: startReact, children: t('MessagingReactions.ADD') }), canAct && _jsx(MenuItem, { onClick: startEdit, children: t('MessagingActions.EDIT') }), canAct && _jsx(MenuItem, { onClick: () => { setConfirmingDelete(true); closeMenu(); }, children: t('MessagingActions.DELETE') }), _jsx(MenuItem, { onClick: copyMessage, children: t('MessagingActions.COPY') })] }), _jsxs(Dialog, { open: confirmingDelete, onClose: () => !saving && setConfirmingDelete(false), children: [_jsx(DialogTitle, { children: t('MessagingActions.DELETE_CONFIRM_TITLE') }), _jsx(DialogContent, { children: t('MessagingActions.DELETE_CONFIRM_BODY') }), _jsxs(DialogActions, { children: [_jsx(Button, { onClick: () => setConfirmingDelete(false), disabled: saving, children: t('MessagingActions.CANCEL') }), _jsx(Button, { onClick: confirmDelete, disabled: saving, color: "error", children: t('MessagingActions.DELETE') })] })] })] }));
|
|
138
138
|
}
|
|
@@ -92,7 +92,7 @@ export function messagingReducer(state, action) {
|
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
export function applyFrame(state, frame, activeConversationId = null) {
|
|
95
|
-
var _a, _b, _c, _d, _e, _f;
|
|
95
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
|
|
96
96
|
const payload = frame.message || frame.conversation || frame.participant || frame;
|
|
97
97
|
const conversationId = (_a = frame.conversation_id) !== null && _a !== void 0 ? _a : payload.conversation_id;
|
|
98
98
|
if (frame.type === 'message') {
|
|
@@ -172,6 +172,23 @@ export function applyFrame(state, frame, activeConversationId = null) {
|
|
|
172
172
|
? Object.assign(Object.assign({}, incomingPoll), { voted_option_ids: previousVotedOptionIds }) : incomingPoll;
|
|
173
173
|
return Object.assign(Object.assign({}, state), { messages: mergeById(state.messages, Object.assign(Object.assign({}, previous), { poll })), polls: mergeById(state.polls, poll) });
|
|
174
174
|
}
|
|
175
|
+
if (frame.type === 'read_state') {
|
|
176
|
+
const userId = (_g = frame.user_id) !== null && _g !== void 0 ? _g : payload.user_id;
|
|
177
|
+
const lastReadAt = (_h = frame.last_read_at) !== null && _h !== void 0 ? _h : payload.last_read_at;
|
|
178
|
+
if (conversationId == null || userId == null || lastReadAt == null)
|
|
179
|
+
return state;
|
|
180
|
+
const key = `${conversationId}:${userId}`;
|
|
181
|
+
return Object.assign(Object.assign({}, state), { receipts: Object.assign(Object.assign({}, state.receipts), { [key]: Object.assign(Object.assign({}, state.receipts[key]), { conversation_id: conversationId, user_id: userId, last_read_at: lastReadAt }) }) });
|
|
182
|
+
}
|
|
183
|
+
if (frame.type === 'thread_read_state') {
|
|
184
|
+
const rootId = (_j = frame.root_id) !== null && _j !== void 0 ? _j : payload.root_id;
|
|
185
|
+
const userId = (_k = frame.user_id) !== null && _k !== void 0 ? _k : payload.user_id;
|
|
186
|
+
const lastReadAt = (_l = frame.last_read_at) !== null && _l !== void 0 ? _l : payload.last_read_at;
|
|
187
|
+
if (rootId == null || userId == null || lastReadAt == null)
|
|
188
|
+
return state;
|
|
189
|
+
const key = `thread:${rootId}:${userId}`;
|
|
190
|
+
return Object.assign(Object.assign({}, state), { receipts: Object.assign(Object.assign({}, state.receipts), { [key]: Object.assign(Object.assign({}, state.receipts[key]), { root_id: rootId, user_id: userId, last_read_at: lastReadAt }) }) });
|
|
191
|
+
}
|
|
175
192
|
if (frame.type === 'message_deleted') {
|
|
176
193
|
const previous = state.messages[frame.message_id] || {};
|
|
177
194
|
return Object.assign(Object.assign({}, state), { messages: Object.assign(Object.assign({}, state.messages), { [frame.message_id]: Object.assign(Object.assign({}, previous), { id: frame.message_id, deleted_at: frame.deleted_at, deleted_by: frame.deleted_by, body: null, title: null, link_target: null }) }) });
|
|
@@ -365,9 +382,10 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
|
|
|
365
382
|
const createConversationPoll = useCallback(async (conversationId, payload, { clientRequestId } = {}) => {
|
|
366
383
|
var _a;
|
|
367
384
|
const poll = await api.createPoll(conversationId, Object.assign(Object.assign({}, payload), { client_request_id: clientRequestId }), { idempotencyKey: clientRequestId });
|
|
368
|
-
const
|
|
369
|
-
if (
|
|
370
|
-
|
|
385
|
+
const messageId = poll === null || poll === void 0 ? void 0 : poll.message_id;
|
|
386
|
+
if (messageId == null)
|
|
387
|
+
throw new Error('Poll response is missing message_id');
|
|
388
|
+
patchMessage({ id: messageId, conversation_id: (_a = poll.conversation_id) !== null && _a !== void 0 ? _a : conversationId, created_at: new Date().toISOString(), kind: 'poll', poll });
|
|
371
389
|
return poll;
|
|
372
390
|
}, [api, patchMessage]);
|
|
373
391
|
const castPollVote = useCallback(async (messageId, poll, optionIds) => {
|
|
@@ -7,8 +7,9 @@ import { useTranslation } from 'react-i18next';
|
|
|
7
7
|
import { useMessaging } from './MessagingProvider';
|
|
8
8
|
/** Renders only the aggregate receipt unless the server explicitly supplies permitted detail. */
|
|
9
9
|
export function ReadTicks({ messageId, conversation }) {
|
|
10
|
+
var _a;
|
|
10
11
|
const { t } = useTranslation();
|
|
11
|
-
const { getMessageReadStatus } = useMessaging();
|
|
12
|
+
const { cache, getMessageReadStatus } = useMessaging();
|
|
12
13
|
const [status, setStatus] = useState(null);
|
|
13
14
|
const [anchorEl, setAnchorEl] = useState(null);
|
|
14
15
|
useEffect(() => {
|
|
@@ -20,12 +21,18 @@ export function ReadTicks({ messageId, conversation }) {
|
|
|
20
21
|
}, [getMessageReadStatus, messageId]);
|
|
21
22
|
if (!status)
|
|
22
23
|
return null;
|
|
23
|
-
const
|
|
24
|
-
const
|
|
24
|
+
const recipientDetail = ((_a = status.recipient_detail) === null || _a === void 0 ? void 0 : _a.map((recipient) => { var _a, _b; return (Object.assign(Object.assign({}, recipient), { last_read_at: (_b = (_a = cache.receipts[`${conversation === null || conversation === void 0 ? void 0 : conversation.id}:${recipient.user_id}`]) === null || _a === void 0 ? void 0 : _a.last_read_at) !== null && _b !== void 0 ? _b : recipient.last_read_at })); })) || status.recipient_detail;
|
|
25
|
+
const directReceipt = (conversation === null || conversation === void 0 ? void 0 : conversation.kind) === 'direct' && (conversation === null || conversation === void 0 ? void 0 : conversation.other_user_id) != null
|
|
26
|
+
? cache.receipts[`${conversation.id}:${conversation.other_user_id}`]
|
|
27
|
+
: null;
|
|
28
|
+
const allRead = status.all_read || Boolean(directReceipt === null || directReceipt === void 0 ? void 0 : directReceipt.last_read_at) || Boolean((recipientDetail === null || recipientDetail === void 0 ? void 0 : recipientDetail.length) && recipientDetail.every((recipient) => recipient.last_read_at));
|
|
29
|
+
const renderedStatus = Object.assign(Object.assign({}, status), { recipient_detail: recipientDetail, all_read: allRead });
|
|
30
|
+
const label = renderedStatus.all_read ? t('MessagingReadTicks.ALL_READ') : t('MessagingReadTicks.DELIVERED', { count: renderedStatus.delivered_count || 0 });
|
|
31
|
+
const StatusIcon = renderedStatus.all_read ? DoneAllOutlinedIcon : DoneOutlinedIcon;
|
|
25
32
|
// The server never returns DM detail; enforce the same carve-out defensively.
|
|
26
|
-
const detail = (conversation === null || conversation === void 0 ? void 0 : conversation.kind) === 'direct' ? null :
|
|
33
|
+
const detail = (conversation === null || conversation === void 0 ? void 0 : conversation.kind) === 'direct' ? null : renderedStatus.recipient_detail;
|
|
27
34
|
const recipients = (detail === null || detail === void 0 ? void 0 : detail.map((recipient) => recipient.display_name || recipient.username || recipient.user_id).filter(Boolean)) || [];
|
|
28
35
|
if (!recipients.length)
|
|
29
|
-
return _jsx(StatusIcon, { role: "img", "aria-label": label, color:
|
|
30
|
-
return _jsxs(_Fragment, { children: [_jsx(ButtonBase, { "aria-label": label, "aria-haspopup": "dialog", "aria-expanded": Boolean(anchorEl), onClick: (event) => setAnchorEl(event.currentTarget), sx: { display: 'inline-flex', color:
|
|
36
|
+
return _jsx(StatusIcon, { role: "img", "aria-label": label, color: renderedStatus.all_read ? 'primary' : 'inherit' });
|
|
37
|
+
return _jsxs(_Fragment, { children: [_jsx(ButtonBase, { "aria-label": label, "aria-haspopup": "dialog", "aria-expanded": Boolean(anchorEl), onClick: (event) => setAnchorEl(event.currentTarget), sx: { display: 'inline-flex', color: renderedStatus.all_read ? 'primary.main' : 'inherit' }, children: _jsx(StatusIcon, { "aria-hidden": "true" }) }), _jsx(Popover, { open: Boolean(anchorEl), anchorEl: anchorEl, onClose: () => setAnchorEl(null), children: _jsx(List, { "aria-label": t('MessagingReadTicks.RECIPIENTS'), children: recipients.map((recipient) => _jsx(ListItem, { children: recipient }, recipient)) }) })] });
|
|
31
38
|
}
|
package/dist/messaging/Thread.js
CHANGED
|
@@ -12,12 +12,14 @@ function replyToId(message) { var _a; return (_a = message.reply_to_id) !== null
|
|
|
12
12
|
// thread_last_read_at is viewer-specific and REST-only (never on a frame — see
|
|
13
13
|
// MessagingProvider.jsx's applyFrame merge-trap handling). A null receipt with
|
|
14
14
|
// at least one reply counts as unread; no replies means no marker at all.
|
|
15
|
-
function hasUnreadReplies(message) {
|
|
15
|
+
function hasUnreadReplies(message, receipts, userId) {
|
|
16
|
+
var _a, _b;
|
|
16
17
|
if (!message.reply_count)
|
|
17
18
|
return false;
|
|
18
|
-
|
|
19
|
+
const lastReadAt = (_b = (_a = receipts[`thread:${message.id}:${userId}`]) === null || _a === void 0 ? void 0 : _a.last_read_at) !== null && _b !== void 0 ? _b : message.thread_last_read_at;
|
|
20
|
+
if (lastReadAt == null)
|
|
19
21
|
return true;
|
|
20
|
-
return Boolean(message.last_reply_at) && new Date(message.last_reply_at) > new Date(
|
|
22
|
+
return Boolean(message.last_reply_at) && new Date(message.last_reply_at) > new Date(lastReadAt);
|
|
21
23
|
}
|
|
22
24
|
// A still-pending optimistic row (chunk 4) has a fake `local-<requestId>` id
|
|
23
25
|
// and no durable server row yet — mounting ReadTicks against it would fire a
|
|
@@ -40,7 +42,7 @@ function canShowReadTicks(message, user) {
|
|
|
40
42
|
*/
|
|
41
43
|
export function Thread({ conversationId, onReplyTargetChange, canModerateMessages = false, onAnnouncementLink }) {
|
|
42
44
|
var _a;
|
|
43
|
-
const { t } = useTranslation();
|
|
45
|
+
const { t, i18n } = useTranslation();
|
|
44
46
|
const { user } = useContext(AuthContext) || {};
|
|
45
47
|
const { cache, loadMoreMessages, loadThreadReplies, markConversationRead, markThreadRead, refreshThread } = useMessaging();
|
|
46
48
|
const scrollRef = useRef(null);
|
|
@@ -54,6 +56,7 @@ export function Thread({ conversationId, onReplyTargetChange, canModerateMessage
|
|
|
54
56
|
const conversation = cache.conversations[conversationId];
|
|
55
57
|
const conversationMessages = useMemo(() => chronological(Object.values(cache.messages).filter((message) => message.conversation_id === conversationId)), [cache.messages, conversationId]);
|
|
56
58
|
const roots = useMemo(() => chronological(Object.values(cache.messages).filter((message) => message.conversation_id === conversationId && !replyToId(message))), [cache.messages, conversationId]);
|
|
59
|
+
const datedRoots = useMemo(() => roots.map((message, index) => ({ message, showDateSeparator: index > 0 && new Date(roots[index - 1].created_at).toDateString() !== new Date(message.created_at).toDateString() })), [roots]);
|
|
57
60
|
useEffect(() => { if (conversationId != null)
|
|
58
61
|
markConversationRead(conversationId).catch(() => { }); }, [conversationId, markConversationRead]);
|
|
59
62
|
useEffect(() => { wasNearBottomRef.current = true; messageCountRef.current = 0; }, [conversationId]);
|
|
@@ -139,10 +142,10 @@ export function Thread({ conversationId, onReplyTargetChange, canModerateMessage
|
|
|
139
142
|
}
|
|
140
143
|
setOpenThreads((current) => (Object.assign(Object.assign({}, current), { [root.id]: !current[root.id] })));
|
|
141
144
|
};
|
|
142
|
-
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:
|
|
145
|
+
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 }) => {
|
|
143
146
|
const replies = chronological(Object.values(cache.messages).filter((item) => String(replyToId(item)) === String(message.id)));
|
|
144
|
-
return _jsxs(Stack, { spacing: 0.75, children: [_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 && (() => {
|
|
145
|
-
const unread = hasUnreadReplies(message);
|
|
147
|
+
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 && (() => {
|
|
148
|
+
const unread = hasUnreadReplies(message, cache.receipts, user === null || user === void 0 ? void 0 : user.id);
|
|
146
149
|
const label = openThreads[message.id] ? t('MessagingThread.HIDE_REPLIES') : t('MessagingThread.SHOW_REPLIES', { count: message.reply_count || replies.length });
|
|
147
150
|
// MUI's Badge spreads unrecognized props (incl. aria-label) onto
|
|
148
151
|
// its own non-interactive wrapping span, not the inner control —
|
package/package.json
CHANGED
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
} from '../auth/authApi';
|
|
35
35
|
|
|
36
36
|
export function SupportRecoveryRequestsTab() {
|
|
37
|
-
const { t } = useTranslation();
|
|
37
|
+
const { t, i18n } = useTranslation();
|
|
38
38
|
const { user } = useContext(AuthContext);
|
|
39
39
|
const canManageAgents = Boolean(user?.is_superuser || user?.can_manage_support_agents);
|
|
40
40
|
|
|
@@ -365,7 +365,7 @@ export function SupportRecoveryRequestsTab() {
|
|
|
365
365
|
<TableRow key={req.id}>
|
|
366
366
|
<TableCell>
|
|
367
367
|
{req.created_at
|
|
368
|
-
? new Date(req.created_at).toLocaleString()
|
|
368
|
+
? new Date(req.created_at).toLocaleString(i18n?.language)
|
|
369
369
|
: '-'}
|
|
370
370
|
</TableCell>
|
|
371
371
|
<TableCell>{req.user_email || req.user}</TableCell>
|
|
@@ -407,7 +407,7 @@ export function SupportRecoveryRequestsTab() {
|
|
|
407
407
|
{t('Support.RECOVERY_REVIEW_CREATED', 'Requested at')}:
|
|
408
408
|
</strong>{' '}
|
|
409
409
|
{selectedRequest.created_at
|
|
410
|
-
? new Date(selectedRequest.created_at).toLocaleString()
|
|
410
|
+
? new Date(selectedRequest.created_at).toLocaleString(i18n?.language)
|
|
411
411
|
: '-'}
|
|
412
412
|
</Typography>
|
|
413
413
|
<Typography variant="body2" sx={{ mb: 1 }}>
|
|
@@ -29,7 +29,8 @@ export const messagingTranslations = {
|
|
|
29
29
|
'MessagingThread.REPLY': { de: 'Antworten', en: 'Reply', fr: 'Répondre' },
|
|
30
30
|
'MessagingThread.REPLY_TO': { de: 'Antwort auf {{sender}}', en: 'Reply to {{sender}}', fr: 'Réponse à {{sender}}' },
|
|
31
31
|
'MessagingThread.REPLYING_TO': { de: 'Antwort an {{sender}}', en: 'Replying to {{sender}}', fr: 'Réponse à {{sender}}' },
|
|
32
|
-
'MessagingThread.
|
|
32
|
+
'MessagingThread.SHOW_REPLIES_one': { de: '{{count}} Antwort anzeigen', en: 'Show {{count}} reply', fr: 'Afficher {{count}} réponse' },
|
|
33
|
+
'MessagingThread.SHOW_REPLIES_other': { de: '{{count}} Antworten anzeigen', en: 'Show {{count}} replies', fr: 'Afficher {{count}} réponses' },
|
|
33
34
|
'MessagingThread.HIDE_REPLIES': { de: 'Antworten ausblenden', en: 'Hide replies', fr: 'Masquer les réponses' },
|
|
34
35
|
'MessagingThread.UNREAD_REPLIES': { de: 'Ungelesene Antworten', en: 'Unread replies', fr: 'Réponses non lues' },
|
|
35
36
|
'MessagingThread.UNKNOWN_SENDER': { de: 'Unbekannte Person', en: 'Unknown person', fr: 'Personne inconnue' },
|
|
@@ -61,7 +62,8 @@ export const messagingTranslations = {
|
|
|
61
62
|
'MessagingComposer.SEND': { de: 'Senden', en: 'Send', fr: 'Envoyer' },
|
|
62
63
|
'MessagingComposer.RETRY': { de: 'Erneut versuchen', en: 'Retry', fr: 'Réessayer' },
|
|
63
64
|
'MessagingComposer.ADD_ATTACHMENT': { de: 'Anhang hinzufügen', en: 'Add attachment', fr: 'Ajouter une pièce jointe' },
|
|
64
|
-
'MessagingComposer.
|
|
65
|
+
'MessagingComposer.FILES_SELECTED_one': { de: '{{count}} Anhang ausgewählt', en: '{{count}} attachment selected', fr: '{{count}} pièce jointe sélectionnée' },
|
|
66
|
+
'MessagingComposer.FILES_SELECTED_other': { de: '{{count}} Anhänge ausgewählt', en: '{{count}} attachments selected', fr: '{{count}} pièces jointes sélectionnées' },
|
|
65
67
|
'MessagingComposer.REPLYING_TO': { de: 'Antwort an {{sender}}', en: 'Replying to {{sender}}', fr: 'Réponse à {{sender}}' },
|
|
66
68
|
'MessagingComposer.SEND_ERROR': { de: 'Nachricht konnte nicht gesendet werden: {{message}}', en: 'Message could not be sent: {{message}}', fr: 'Impossible d’envoyer le message : {{message}}' },
|
|
67
69
|
'MessagingComposer.UPLOAD_ERROR': { de: 'Anhang wurde abgelehnt: {{message}}', en: 'Attachment was rejected: {{message}}', fr: 'La pièce jointe a été rejetée : {{message}}' },
|
|
@@ -69,7 +71,8 @@ export const messagingTranslations = {
|
|
|
69
71
|
'MessagingAttachments.DOWNLOAD': { de: '{{name}} herunterladen', en: 'Download {{name}}', fr: 'Télécharger {{name}}' },
|
|
70
72
|
'MessagingReactions.LABEL': { de: 'Reaktionen', en: 'Reactions', fr: 'Réactions' },
|
|
71
73
|
'MessagingReactions.ADD': { de: 'Reaktion hinzufügen', en: 'Add reaction', fr: 'Ajouter une réaction' },
|
|
72
|
-
'MessagingReactions.
|
|
74
|
+
'MessagingReactions.TOGGLE_one': { de: '{{emoji}} Reaktion mit {{count}} Stimme umschalten', en: 'Toggle {{emoji}} reaction with {{count}} vote', fr: 'Basculer la réaction {{emoji}} avec {{count}} vote' },
|
|
75
|
+
'MessagingReactions.TOGGLE_other': { de: '{{emoji}} Reaktion mit {{count}} Stimmen umschalten', en: 'Toggle {{emoji}} reaction with {{count}} votes', fr: 'Basculer la réaction {{emoji}} avec {{count}} votes' },
|
|
73
76
|
'MessagingReactions.ERROR': { de: 'Reaktion konnte nicht gespeichert werden: {{message}}', en: 'Reaction could not be saved: {{message}}', fr: 'Impossible d’enregistrer la réaction : {{message}}' },
|
|
74
77
|
'MessagingPoll.LABEL': { de: 'Umfrage', en: 'Poll', fr: 'Sondage' },
|
|
75
78
|
'MessagingPoll.VOTE': { de: 'Abstimmen', en: 'Vote', fr: 'Voter' },
|
|
@@ -47,7 +47,7 @@ function KindIcon({ conversation }) {
|
|
|
47
47
|
/** A standalone, provider-backed list; scope picker metadata is supplied as launcher props. */
|
|
48
48
|
export function ConversationList({ onOpen, groupLaunchers, broadcastLauncher, autoOpenBroadcast = false, includeArchived = false, resolveManagedLabel, resolveDirectUserName }) {
|
|
49
49
|
const { t, i18n } = useTranslation();
|
|
50
|
-
const { cache, activeConversationId, loadMoreConversations, setConversationArchived, setConversationPreferences
|
|
50
|
+
const { cache, activeConversationId, loadMoreConversations, setConversationArchived, setConversationPreferences } = useMessaging();
|
|
51
51
|
const [menuConversation, setMenuConversation] = useState(null);
|
|
52
52
|
const [menuAnchor, setMenuAnchor] = useState(null);
|
|
53
53
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
@@ -82,7 +82,7 @@ export function ConversationList({ onOpen, groupLaunchers, broadcastLauncher, au
|
|
|
82
82
|
{conversations.map((conversation) => {
|
|
83
83
|
const unread = unreadFor(conversation);
|
|
84
84
|
const archived = Boolean(conversation.archived || conversation.archived_at);
|
|
85
|
-
return <ListItemButton key={conversation.id} selected={conversation.id === activeConversationId} onClick={() =>
|
|
85
|
+
return <ListItemButton key={conversation.id} selected={conversation.id === activeConversationId} onClick={() => onOpen?.(conversation)} sx={{ minHeight: 64, opacity: archived ? 0.6 : 1 }}>
|
|
86
86
|
<Badge color="error" badgeContent={unread || null} max={99} sx={{ mr: 2 }}>
|
|
87
87
|
{archived ? <Archive fontSize="small" color="disabled" /> : <KindIcon conversation={conversation} />}
|
|
88
88
|
</Badge>
|
|
@@ -30,7 +30,7 @@ function quoteLabel(message, t) {
|
|
|
30
30
|
|
|
31
31
|
/** Provider-backed timeline collaborator, including per-message mutations. */
|
|
32
32
|
export function MessageBubble({ message, replyTo, conversation, onReply, onJumpToMessage, onAnnouncementLink, canModerateMessages = false, children }) {
|
|
33
|
-
const { t } = useTranslation();
|
|
33
|
+
const { t, i18n } = useTranslation();
|
|
34
34
|
const messaging = useOptionalMessaging();
|
|
35
35
|
const { currentUser, editMessage, removeMessage } = messaging || {};
|
|
36
36
|
const deleted = Boolean(message.deleted_at);
|
|
@@ -130,7 +130,7 @@ export function MessageBubble({ message, replyTo, conversation, onReply, onJumpT
|
|
|
130
130
|
{message.poll && <PollCard message={message} />}
|
|
131
131
|
</Stack>}
|
|
132
132
|
{hasMeta && <Stack data-testid="message-meta" direction="row" spacing={0.5} justifyContent="flex-end" alignItems="center" flexWrap="wrap" sx={{ color: 'text.disabled', minWidth: 0, '& .MuiSvgIcon-root': { fontSize: '1rem' } }}>
|
|
133
|
-
{message.created_at && <Typography component="span" variant="caption" color="inherit" sx={{ whiteSpace: 'nowrap' }}>{new Date(message.created_at).
|
|
133
|
+
{message.created_at && <Typography component="span" variant="caption" color="inherit" sx={{ whiteSpace: 'nowrap' }}>{new Date(message.created_at).toLocaleTimeString(i18n?.language, { hour: '2-digit', minute: '2-digit' })}</Typography>}
|
|
134
134
|
{message.edited_at && !deleted && <Typography component="span" variant="caption" color="inherit">{t('MessagingThread.EDITED')}</Typography>}
|
|
135
135
|
{children}
|
|
136
136
|
</Stack>}
|
|
@@ -207,6 +207,21 @@ export function applyFrame(state, frame, activeConversationId = null) {
|
|
|
207
207
|
: incomingPoll;
|
|
208
208
|
return { ...state, messages: mergeById(state.messages, { ...previous, poll }), polls: mergeById(state.polls, poll) };
|
|
209
209
|
}
|
|
210
|
+
if (frame.type === 'read_state') {
|
|
211
|
+
const userId = frame.user_id ?? payload.user_id;
|
|
212
|
+
const lastReadAt = frame.last_read_at ?? payload.last_read_at;
|
|
213
|
+
if (conversationId == null || userId == null || lastReadAt == null) return state;
|
|
214
|
+
const key = `${conversationId}:${userId}`;
|
|
215
|
+
return { ...state, receipts: { ...state.receipts, [key]: { ...state.receipts[key], conversation_id: conversationId, user_id: userId, last_read_at: lastReadAt } } };
|
|
216
|
+
}
|
|
217
|
+
if (frame.type === 'thread_read_state') {
|
|
218
|
+
const rootId = frame.root_id ?? payload.root_id;
|
|
219
|
+
const userId = frame.user_id ?? payload.user_id;
|
|
220
|
+
const lastReadAt = frame.last_read_at ?? payload.last_read_at;
|
|
221
|
+
if (rootId == null || userId == null || lastReadAt == null) return state;
|
|
222
|
+
const key = `thread:${rootId}:${userId}`;
|
|
223
|
+
return { ...state, receipts: { ...state.receipts, [key]: { ...state.receipts[key], root_id: rootId, user_id: userId, last_read_at: lastReadAt } } };
|
|
224
|
+
}
|
|
210
225
|
if (frame.type === 'message_deleted') {
|
|
211
226
|
const previous = state.messages[frame.message_id] || {};
|
|
212
227
|
return { ...state, messages: { ...state.messages, [frame.message_id]: { ...previous, id: frame.message_id, deleted_at: frame.deleted_at, deleted_by: frame.deleted_by, body: null, title: null, link_target: null } } };
|
|
@@ -400,8 +415,9 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
|
|
|
400
415
|
}, [api, cache.messages, patchMessage]);
|
|
401
416
|
const createConversationPoll = useCallback(async (conversationId, payload, { clientRequestId } = {}) => {
|
|
402
417
|
const poll = await api.createPoll(conversationId, { ...payload, client_request_id: clientRequestId }, { idempotencyKey: clientRequestId });
|
|
403
|
-
const
|
|
404
|
-
if (
|
|
418
|
+
const messageId = poll?.message_id;
|
|
419
|
+
if (messageId == null) throw new Error('Poll response is missing message_id');
|
|
420
|
+
patchMessage({ id: messageId, conversation_id: poll.conversation_id ?? conversationId, created_at: new Date().toISOString(), kind: 'poll', poll });
|
|
405
421
|
return poll;
|
|
406
422
|
}, [api, patchMessage]);
|
|
407
423
|
const castPollVote = useCallback(async (messageId, poll, optionIds) => {
|
|
@@ -9,7 +9,7 @@ import { useMessaging } from './MessagingProvider';
|
|
|
9
9
|
/** Renders only the aggregate receipt unless the server explicitly supplies permitted detail. */
|
|
10
10
|
export function ReadTicks({ messageId, conversation }) {
|
|
11
11
|
const { t } = useTranslation();
|
|
12
|
-
const { getMessageReadStatus } = useMessaging();
|
|
12
|
+
const { cache, getMessageReadStatus } = useMessaging();
|
|
13
13
|
const [status, setStatus] = useState(null);
|
|
14
14
|
const [anchorEl, setAnchorEl] = useState(null);
|
|
15
15
|
useEffect(() => {
|
|
@@ -18,11 +18,17 @@ export function ReadTicks({ messageId, conversation }) {
|
|
|
18
18
|
return () => { mounted = false; };
|
|
19
19
|
}, [getMessageReadStatus, messageId]);
|
|
20
20
|
if (!status) return null;
|
|
21
|
-
const
|
|
22
|
-
const
|
|
21
|
+
const recipientDetail = status.recipient_detail?.map((recipient) => ({ ...recipient, last_read_at: cache.receipts[`${conversation?.id}:${recipient.user_id}`]?.last_read_at ?? recipient.last_read_at })) || status.recipient_detail;
|
|
22
|
+
const directReceipt = conversation?.kind === 'direct' && conversation?.other_user_id != null
|
|
23
|
+
? cache.receipts[`${conversation.id}:${conversation.other_user_id}`]
|
|
24
|
+
: null;
|
|
25
|
+
const allRead = status.all_read || Boolean(directReceipt?.last_read_at) || Boolean(recipientDetail?.length && recipientDetail.every((recipient) => recipient.last_read_at));
|
|
26
|
+
const renderedStatus = { ...status, recipient_detail: recipientDetail, all_read: allRead };
|
|
27
|
+
const label = renderedStatus.all_read ? t('MessagingReadTicks.ALL_READ') : t('MessagingReadTicks.DELIVERED', { count: renderedStatus.delivered_count || 0 });
|
|
28
|
+
const StatusIcon = renderedStatus.all_read ? DoneAllOutlinedIcon : DoneOutlinedIcon;
|
|
23
29
|
// The server never returns DM detail; enforce the same carve-out defensively.
|
|
24
|
-
const detail = conversation?.kind === 'direct' ? null :
|
|
30
|
+
const detail = conversation?.kind === 'direct' ? null : renderedStatus.recipient_detail;
|
|
25
31
|
const recipients = detail?.map((recipient) => recipient.display_name || recipient.username || recipient.user_id).filter(Boolean) || [];
|
|
26
|
-
if (!recipients.length) return <StatusIcon role="img" aria-label={label} color={
|
|
27
|
-
return <><ButtonBase aria-label={label} aria-haspopup="dialog" aria-expanded={Boolean(anchorEl)} onClick={(event) => setAnchorEl(event.currentTarget)} sx={{ display: 'inline-flex', color:
|
|
32
|
+
if (!recipients.length) return <StatusIcon role="img" aria-label={label} color={renderedStatus.all_read ? 'primary' : 'inherit'} />;
|
|
33
|
+
return <><ButtonBase aria-label={label} aria-haspopup="dialog" aria-expanded={Boolean(anchorEl)} onClick={(event) => setAnchorEl(event.currentTarget)} sx={{ display: 'inline-flex', color: renderedStatus.all_read ? 'primary.main' : 'inherit' }}><StatusIcon aria-hidden="true" /></ButtonBase><Popover open={Boolean(anchorEl)} anchorEl={anchorEl} onClose={() => setAnchorEl(null)}><List aria-label={t('MessagingReadTicks.RECIPIENTS')}>{recipients.map((recipient) => <ListItem key={recipient}>{recipient}</ListItem>)}</List></Popover></>;
|
|
28
34
|
}
|
package/src/messaging/Thread.jsx
CHANGED
|
@@ -13,10 +13,11 @@ function replyToId(message) { return message.reply_to_id ?? message.reply_to; }
|
|
|
13
13
|
// thread_last_read_at is viewer-specific and REST-only (never on a frame — see
|
|
14
14
|
// MessagingProvider.jsx's applyFrame merge-trap handling). A null receipt with
|
|
15
15
|
// at least one reply counts as unread; no replies means no marker at all.
|
|
16
|
-
function hasUnreadReplies(message) {
|
|
16
|
+
function hasUnreadReplies(message, receipts, userId) {
|
|
17
17
|
if (!message.reply_count) return false;
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
const lastReadAt = receipts[`thread:${message.id}:${userId}`]?.last_read_at ?? message.thread_last_read_at;
|
|
19
|
+
if (lastReadAt == null) return true;
|
|
20
|
+
return Boolean(message.last_reply_at) && new Date(message.last_reply_at) > new Date(lastReadAt);
|
|
20
21
|
}
|
|
21
22
|
// A still-pending optimistic row (chunk 4) has a fake `local-<requestId>` id
|
|
22
23
|
// and no durable server row yet — mounting ReadTicks against it would fire a
|
|
@@ -38,7 +39,7 @@ function canShowReadTicks(message, user) {
|
|
|
38
39
|
* message shown, not one per message in the conversation.
|
|
39
40
|
*/
|
|
40
41
|
export function Thread({ conversationId, onReplyTargetChange, canModerateMessages = false, onAnnouncementLink }) {
|
|
41
|
-
const { t } = useTranslation();
|
|
42
|
+
const { t, i18n } = useTranslation();
|
|
42
43
|
const { user } = useContext(AuthContext) || {};
|
|
43
44
|
const { cache, loadMoreMessages, loadThreadReplies, markConversationRead, markThreadRead, refreshThread } = useMessaging();
|
|
44
45
|
const scrollRef = useRef(null);
|
|
@@ -52,6 +53,7 @@ export function Thread({ conversationId, onReplyTargetChange, canModerateMessage
|
|
|
52
53
|
const conversation = cache.conversations[conversationId];
|
|
53
54
|
const conversationMessages = useMemo(() => chronological(Object.values(cache.messages).filter((message) => message.conversation_id === conversationId)), [cache.messages, conversationId]);
|
|
54
55
|
const roots = useMemo(() => chronological(Object.values(cache.messages).filter((message) => message.conversation_id === conversationId && !replyToId(message))), [cache.messages, conversationId]);
|
|
56
|
+
const datedRoots = useMemo(() => roots.map((message, index) => ({ message, showDateSeparator: index > 0 && new Date(roots[index - 1].created_at).toDateString() !== new Date(message.created_at).toDateString() })), [roots]);
|
|
55
57
|
|
|
56
58
|
useEffect(() => { if (conversationId != null) markConversationRead(conversationId).catch(() => {}); }, [conversationId, markConversationRead]);
|
|
57
59
|
useEffect(() => { wasNearBottomRef.current = true; messageCountRef.current = 0; }, [conversationId]);
|
|
@@ -136,11 +138,11 @@ export function Thread({ conversationId, onReplyTargetChange, canModerateMessage
|
|
|
136
138
|
(the scrollbar fix above) would clip the icon whenever there's no
|
|
137
139
|
vertical scrollbar reserving extra width (a short conversation,
|
|
138
140
|
no vertical overflow). */}
|
|
139
|
-
<Stack spacing={1} sx={{ pr: 2 }}>{
|
|
141
|
+
<Stack spacing={1} sx={{ pr: 2 }}>{datedRoots.map(({ message, showDateSeparator }) => {
|
|
140
142
|
const replies = chronological(Object.values(cache.messages).filter((item) => String(replyToId(item)) === String(message.id)));
|
|
141
|
-
return <Stack key={message.id} spacing={0.75}><MessageBubble message={message} conversation={conversation} onReply={reply} onJumpToMessage={jumpToMessage} onAnnouncementLink={onAnnouncementLink} canModerateMessages={canModerateMessages}>{canShowReadTicks(message, user) && <ReadTicks messageId={message.id} conversation={conversation} />}</MessageBubble>
|
|
143
|
+
return <Stack key={message.id} spacing={0.75}>{showDateSeparator && <Stack data-testid="day-separator" direction="row" alignItems="center" spacing={1}><Divider sx={{ flex: 1 }} /><Typography variant="caption" color="text.secondary">{new Date(message.created_at).toLocaleDateString(i18n?.language)}</Typography><Divider sx={{ flex: 1 }} /></Stack>}<MessageBubble message={message} conversation={conversation} onReply={reply} onJumpToMessage={jumpToMessage} onAnnouncementLink={onAnnouncementLink} canModerateMessages={canModerateMessages}>{canShowReadTicks(message, user) && <ReadTicks messageId={message.id} conversation={conversation} />}</MessageBubble>
|
|
142
144
|
{(message.reply_count || replies.length) > 0 && (() => {
|
|
143
|
-
const unread = hasUnreadReplies(message);
|
|
145
|
+
const unread = hasUnreadReplies(message, cache.receipts, user?.id);
|
|
144
146
|
const label = openThreads[message.id] ? t('MessagingThread.HIDE_REPLIES') : t('MessagingThread.SHOW_REPLIES', { count: message.reply_count || replies.length });
|
|
145
147
|
// MUI's Badge spreads unrecognized props (incl. aria-label) onto
|
|
146
148
|
// its own non-interactive wrapping span, not the inner control —
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
let frameHandler;
|
|
7
|
+
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options?.count != null ? `${key}:${options.count}` : key, i18n: { language: 'de-DE' } }) }));
|
|
8
|
+
vi.mock('../src/notifications/realtime', () => ({ useRealtime: () => ({ subscribe: (_envelope, handler) => { frameHandler = handler; return () => {}; }, onReconnect: () => () => {} }) }));
|
|
9
|
+
|
|
10
|
+
import { MessageBubble } from '../src/messaging/MessageBubble';
|
|
11
|
+
import { ConversationList } from '../src/messaging/ConversationList';
|
|
12
|
+
import { applyFrame, MessagingProvider, useMessaging } from '../src/messaging/MessagingProvider';
|
|
13
|
+
import { ReadTicks } from '../src/messaging/ReadTicks';
|
|
14
|
+
import { Thread } from '../src/messaging/Thread';
|
|
15
|
+
import { messagingTranslations } from '../src/i18n/messagingTranslations';
|
|
16
|
+
import { AuthContext } from '../src/auth/AuthContext';
|
|
17
|
+
|
|
18
|
+
const api = (overrides = {}) => ({ listConversations: vi.fn().mockResolvedValue({ results: [{ id: 'c1', kind: 'group' }] }), listMessages: vi.fn().mockResolvedValue({ results: [] }), listThread: vi.fn().mockResolvedValue({ results: [] }), getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }), getReadStatus: vi.fn().mockResolvedValue({ all_read: false, delivered_count: 1, recipient_detail: [{ user_id: 'u2', display_name: 'Ava' }] }), markConversationRead: vi.fn().mockResolvedValue({}), ...overrides });
|
|
19
|
+
const state = () => ({ conversations: {}, messages: {}, threads: {}, polls: {}, reactions: {}, receipts: {}, unread: { unread_count: 0, by_conversation: {} }, cursors: { conversations: null, messages: {}, threads: {} } });
|
|
20
|
+
afterEach(cleanup);
|
|
21
|
+
|
|
22
|
+
function PollHarness() { const { createConversationPoll, cache } = useMessaging(); return <><button onClick={() => createConversationPoll('c1', { question: 'Q' })}>create</button><output>{JSON.stringify(cache.messages)}</output></>; }
|
|
23
|
+
|
|
24
|
+
describe('MSG-6 messaging regressions', () => {
|
|
25
|
+
it('uses the server message_id, never the poll id, for a newly-created poll cache row', async () => {
|
|
26
|
+
const client = api({ createPoll: vi.fn().mockResolvedValue({ id: 'poll-9', message_id: 'message-9', question: 'Q', options: [] }) });
|
|
27
|
+
render(<MessagingProvider api={client} active={false}><PollHarness /></MessagingProvider>);
|
|
28
|
+
await act(async () => screen.getByText('create').click());
|
|
29
|
+
const messages = JSON.parse(screen.getByText((_, node) => node.tagName === 'OUTPUT').textContent);
|
|
30
|
+
expect(Object.keys(messages)).toEqual(['message-9']);
|
|
31
|
+
expect(messages['message-9'].created_at).toBeTruthy();
|
|
32
|
+
expect(messages['poll-9']).toBeUndefined();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('renders time-only metadata in the active locale and one separator per changed root day', async () => {
|
|
36
|
+
const time = vi.spyOn(Date.prototype, 'toLocaleTimeString'); time.mockReturnValue('10:41');
|
|
37
|
+
render(<MessageBubble message={{ id: 1, body: 'Hi', created_at: '2026-08-03T10:41:00Z' }} />);
|
|
38
|
+
expect(await screen.findByText('10:41')).toBeTruthy();
|
|
39
|
+
expect(time).toHaveBeenCalledWith('de-DE', { hour: '2-digit', minute: '2-digit' });
|
|
40
|
+
time.mockRestore();
|
|
41
|
+
const client = api({ listMessages: vi.fn().mockResolvedValue({ results: [{ id: 1, conversation_id: 'c1', body: 'one', created_at: '2026-08-01T10:00:00Z' }, { id: 2, conversation_id: 'c1', body: 'two', created_at: '2026-08-01T11:00:00Z' }, { id: 3, conversation_id: 'c1', body: 'three', created_at: '2026-08-02T10:00:00Z' }] }) });
|
|
42
|
+
render(<MessagingProvider api={client}><Thread conversationId="c1" /></MessagingProvider>);
|
|
43
|
+
await screen.findByText('three');
|
|
44
|
+
expect(screen.getAllByTestId('day-separator')).toHaveLength(1);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('applies read and thread-read frames to cache and lets ReadTicks change without another REST call', async () => {
|
|
48
|
+
const client = api();
|
|
49
|
+
render(<MessagingProvider api={client} active={false}><ReadTicks messageId="m1" conversation={{ id: 'c1', kind: 'group' }} /></MessagingProvider>);
|
|
50
|
+
await screen.findByLabelText('MessagingReadTicks.DELIVERED:1');
|
|
51
|
+
act(() => frameHandler({ type: 'read_state', conversation_id: 'c1', user_id: 'u2', last_read_at: '2026-08-03T10:00:00Z' }));
|
|
52
|
+
await screen.findByLabelText('MessagingReadTicks.ALL_READ');
|
|
53
|
+
expect(client.getReadStatus).toHaveBeenCalledTimes(1);
|
|
54
|
+
const next = applyFrame(state(), { type: 'thread_read_state', root_id: 'root-1', user_id: 'u2', last_read_at: '2026-08-03T10:00:00Z' });
|
|
55
|
+
expect(next.receipts['thread:root-1:u2'].last_read_at).toBe('2026-08-03T10:00:00Z');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('updates direct-conversation ReadTicks from a read frame without another REST call', async () => {
|
|
59
|
+
const client = api();
|
|
60
|
+
render(<MessagingProvider api={client} active={false}><ReadTicks messageId="m1" conversation={{ id: 'c1', kind: 'direct', other_user_id: 'u2' }} /></MessagingProvider>);
|
|
61
|
+
await screen.findByLabelText('MessagingReadTicks.DELIVERED:1');
|
|
62
|
+
act(() => frameHandler({ type: 'read_state', conversation_id: 'c1', user_id: 'u2', last_read_at: '2026-08-03T10:00:00Z' }));
|
|
63
|
+
await screen.findByLabelText('MessagingReadTicks.ALL_READ');
|
|
64
|
+
expect(client.getReadStatus).toHaveBeenCalledTimes(1);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('keeps Thread as the sole read-mark owner for direct mounts and list opens', async () => {
|
|
68
|
+
const client = api({ listMessages: vi.fn().mockResolvedValue({ results: [] }) });
|
|
69
|
+
function Surface() { const [conversationId, setConversationId] = React.useState(null); return <MessagingProvider api={client}><ConversationList onOpen={(conversation) => setConversationId(conversation.id)} />{conversationId && <Thread conversationId={conversationId} />}</MessagingProvider>; }
|
|
70
|
+
render(<Surface />);
|
|
71
|
+
await screen.findByText('MessagingList.UNTITLED');
|
|
72
|
+
fireEvent.click(screen.getByText('MessagingList.UNTITLED'));
|
|
73
|
+
await waitFor(() => expect(client.markConversationRead).toHaveBeenCalledTimes(1));
|
|
74
|
+
client.markConversationRead.mockClear();
|
|
75
|
+
render(<MessagingProvider api={client} active={false}><Thread conversationId="c1" /></MessagingProvider>);
|
|
76
|
+
await waitFor(() => expect(client.markConversationRead).toHaveBeenCalledTimes(1));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('updates the rendered thread unread state from a thread_read_state frame', async () => {
|
|
80
|
+
const client = api({ listMessages: vi.fn().mockResolvedValue({ results: [{ id: 'root-1', conversation_id: 'c1', body: 'Root', sender: { id: 'u1' }, reply_count: 1, last_reply_at: '2026-08-03T10:00:00Z', thread_last_read_at: null }] }) });
|
|
81
|
+
render(<AuthContext.Provider value={{ user: { id: 'u2' } }}><MessagingProvider api={client}><Thread conversationId="c1" /></MessagingProvider></AuthContext.Provider>);
|
|
82
|
+
await screen.findByLabelText(/MessagingThread.UNREAD_REPLIES/);
|
|
83
|
+
act(() => frameHandler({ type: 'thread_read_state', root_id: 'root-1', user_id: 'u2', last_read_at: '2026-08-03T11:00:00Z' }));
|
|
84
|
+
await waitFor(() => expect(screen.queryByLabelText(/MessagingThread.UNREAD_REPLIES/)).toBeNull());
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('defines singular reply labels for de, en, and fr', () => {
|
|
88
|
+
expect(messagingTranslations['MessagingThread.SHOW_REPLIES_one']).toEqual({ de: '{{count}} Antwort anzeigen', en: 'Show {{count}} reply', fr: 'Afficher {{count}} réponse' });
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -23,9 +23,21 @@ function makeApi(overrides = {}) {
|
|
|
23
23
|
afterEach(cleanup);
|
|
24
24
|
|
|
25
25
|
describe('messaging unread lifecycle', () => {
|
|
26
|
-
it('marks the selected conversation read', async () => {
|
|
26
|
+
it('marks the selected conversation read once its thread mounts', async () => {
|
|
27
|
+
// MSG-6e made `Thread`'s mount effect the sole owner of marking a
|
|
28
|
+
// conversation read (`ConversationList`'s own onClick no longer does it,
|
|
29
|
+
// to stop the double `POST .../read/` this test used to mask) — so the
|
|
30
|
+
// realistic host wiring (onOpen mounts Thread) is what's under test here,
|
|
31
|
+
// not a click on ConversationList in isolation.
|
|
27
32
|
const api = makeApi({ listConversations: vi.fn().mockResolvedValue({ results: [{ id: 12, title: 'Support' }] }) });
|
|
28
|
-
|
|
33
|
+
function Surface() {
|
|
34
|
+
const [conversationId, setConversationId] = React.useState(null);
|
|
35
|
+
return <>
|
|
36
|
+
<ConversationList onOpen={(conversation) => setConversationId(conversation.id)} />
|
|
37
|
+
{conversationId != null && <Thread conversationId={conversationId} />}
|
|
38
|
+
</>;
|
|
39
|
+
}
|
|
40
|
+
render(<MessagingProvider api={api}><Surface /></MessagingProvider>);
|
|
29
41
|
fireEvent.click(await screen.findByRole('button', { name: /Support/ }));
|
|
30
42
|
await waitFor(() => expect(api.markConversationRead).toHaveBeenCalledWith(12, undefined));
|
|
31
43
|
});
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
# WORK ORDER MSG-6e (ui-core-micha) — messaging surface defects found by click-through
|
|
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). Origin: jg-ferien `work-orders/MSG-9.md` findings 3, 7, 9, 10,
|
|
11
|
+
13b and 14 — all reproduced in a running app on 2026-08-03. Read that file's "Live verification"
|
|
12
|
+
section; do not re-derive the evidence.
|
|
13
|
+
|
|
14
|
+
## TIER
|
|
15
|
+
Tier 2 — shared-core, consumed by every app. Independent `reviewer` mandatory. `ui_reviewer` requested
|
|
16
|
+
for scope A (visible layout change).
|
|
17
|
+
|
|
18
|
+
## SCOPE — six independent defects, one release
|
|
19
|
+
|
|
20
|
+
**A. Timestamps: `HH:MM` plus a day separator, and pass the active locale (findings 3 + 9).**
|
|
21
|
+
`MessageBubble.jsx:133` calls `new Date(message.created_at).toLocaleString()` **with no locale
|
|
22
|
+
argument**, so it renders in the runtime's default locale — observed as `8/3/2026, 10:41:29 AM` in a
|
|
23
|
+
German UI. Two changes, and they must land together:
|
|
24
|
+
|
|
25
|
+
1. Show `HH:MM` on the bubble. The **date** appears once as a separator row when the day changes,
|
|
26
|
+
inserted by `Thread` (it needs the neighbouring message to compare against), **not** by
|
|
27
|
+
`MessageBubble`.
|
|
28
|
+
2. Pass i18next's active language to every date formatter. `ConversationList` already threads
|
|
29
|
+
`i18n?.language` into `relativeTime` — follow that existing pattern rather than inventing another.
|
|
30
|
+
|
|
31
|
+
Not a one-off: `SupportRecoveryRequestsTab.jsx:368` and `:410` have the same bare `toLocaleString()`.
|
|
32
|
+
Fix them in the same pass; leaving them is how this recurs.
|
|
33
|
+
|
|
34
|
+
**B. A freshly created poll is cached under the wrong id (finding 10).**
|
|
35
|
+
`createConversationPoll` (`MessagingProvider.jsx:401-406`):
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
const message = poll?.message || poll; // <- dcm never sends `message`
|
|
39
|
+
if (message?.id != null) patchMessage({ ...message, ..., poll: ... });
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
dcm's `_poll_response` (`messaging/views.py:32-36`) returns `serialize_poll(poll)` + `voted_option_ids`.
|
|
43
|
+
Keys: `id`, `question`, `allow_multiple`, `closed_at`, `created_by_id`, `options`, `voted_option_ids`.
|
|
44
|
+
**No `message`, no `message_id`.** So `message.id` is the **poll's** id and `patchMessage` writes a
|
|
45
|
+
phantom timeline entry keyed by it, with no `created_at`, no `sender_id`, no `kind`.
|
|
46
|
+
|
|
47
|
+
Observed: a newly created poll renders with **no timestamp**, sender "Unbekannte Person", and sorts to
|
|
48
|
+
the **top** of the timeline (`chronological()` on `new Date(undefined)` → `NaN`), above messages from
|
|
49
|
+
two days earlier. Correct after reload.
|
|
50
|
+
|
|
51
|
+
**The `|| poll` fallback is the actual defect** — it converts a wrong assumption into silent data
|
|
52
|
+
corruption instead of an error. Remove the guess: resolve the message id explicitly from a field dcm
|
|
53
|
+
really sends, or re-fetch the message. **If dcm's response genuinely carries no way to identify the
|
|
54
|
+
message, do not invent one — stop and report**; that would be a dcm contract gap needing its own WO.
|
|
55
|
+
Apply the same scrutiny to `castPollVote`/`closePoll` (`:407+`), which use the same response shape.
|
|
56
|
+
|
|
57
|
+
**C. Handle the read-state frames dcm already sends (finding 7).**
|
|
58
|
+
`applyFrame` (`MessagingProvider.jsx:123ff`) handles only `message`, `message_edited`, `reaction` and
|
|
59
|
+
`poll_updated`. dcm publishes `read_state` (`services.py:293`), `delivered` (`:302`) and
|
|
60
|
+
`thread_read_state` (`:312`) — all silently discarded. Combined with `ReadTicks` fetching its status
|
|
61
|
+
once on mount (`ReadTicks.jsx:15-19`) and never re-fetching, a receipt can **never** update within a
|
|
62
|
+
session.
|
|
63
|
+
|
|
64
|
+
**Ordering, updated 2026-08-03 — the earlier caveat here is resolved.** jg MSG-9 finding 1 is now
|
|
65
|
+
diagnosed as `django-core-micha` MSG-8: `push_to_users` feeds raw `datetime` objects into a msgpack
|
|
66
|
+
channel layer, `group_send` raises, the exception is swallowed as a warning, and the frame is dropped
|
|
67
|
+
(measured 16/16 failures). **C is therefore worth doing and no longer speculative.**
|
|
68
|
+
|
|
69
|
+
Two concrete implications:
|
|
70
|
+
|
|
71
|
+
- `read_state`, `delivered` and `thread_read_state` build their payloads with an explicit
|
|
72
|
+
`.isoformat()`, so **those three frames arrive today** — C is verifiable right now, before MSG-8
|
|
73
|
+
lands.
|
|
74
|
+
- `message` / `message_edited` / `conversation_upsert` frames start arriving only once MSG-8 ships. Do
|
|
75
|
+
not conclude from "no message frame appears" that C is broken; check MSG-8's status first.
|
|
76
|
+
|
|
77
|
+
**D. Stop double-firing `markConversationRead` (finding 13, ucm half).**
|
|
78
|
+
Both `Thread.jsx:55` (a `useEffect` on `conversationId`) and `ConversationList.jsx:77` (the row's
|
|
79
|
+
`onClick`) call it for the same conversation. Measured: `POST /conversations/<id>/read/` fires **twice**
|
|
80
|
+
on every open. Keep exactly one owner. `Thread` is the better one — it fires on every way a
|
|
81
|
+
conversation becomes active, including deep links and programmatic switches, whereas the list click
|
|
82
|
+
covers only one path. Removing the `Thread` effect instead would silently break those paths.
|
|
83
|
+
|
|
84
|
+
**E. Plural forms for the reply counter (finding 14).**
|
|
85
|
+
`MessagingThread.SHOW_REPLIES` is a single string `'{{count}} Antworten anzeigen'`
|
|
86
|
+
(`i18n/messagingTranslations.ts:32`). All three languages are wrong at count 1: "1 Antworten anzeigen",
|
|
87
|
+
"Show 1 replies", "1 réponses". Use i18next plural keys (`_one`/`_other`). **Audit the rest of
|
|
88
|
+
`messagingTranslations.ts` for other `{{count}}` strings without plural forms** and fix them in the same
|
|
89
|
+
pass — including `MessagingReadTicks.DELIVERED` if it survives dcm MSG-7 scope C.
|
|
90
|
+
|
|
91
|
+
## DEPENDENCY — do not fix findings 2, 5 and 11 here
|
|
92
|
+
Own/incoming alignment, missing read ticks and "Unbekannte Person" all trace to dcm never serializing a
|
|
93
|
+
`sender` object. **ucm's code is already correct for all three** — `MessageBubble.jsx:100/108/109`
|
|
94
|
+
implements the alignment in full, it simply never sees `isOwn === true`. That fix is
|
|
95
|
+
`django-core-micha` MSG-7. **Changing `MessageBubble`'s ownership logic here would be wrong and would
|
|
96
|
+
mask the real defect.** If a change in this WO appears to need it, stop and report.
|
|
97
|
+
|
|
98
|
+
## NON-GOALS / DO NOT TOUCH
|
|
99
|
+
- No change to `isOwn`, `canShowReadTicks`, `senderName`, or any ownership derivation (see above).
|
|
100
|
+
- No change to `reconcileMessage`'s merge semantics — the optimistic-`sender` survival it produces is
|
|
101
|
+
currently the only thing making own messages render at all; changing it before MSG-7 lands would make
|
|
102
|
+
the surface strictly worse.
|
|
103
|
+
- No change to `useRealtimeCore`. The missing heartbeat is real (jg MSG-9 finding 1) but is its own
|
|
104
|
+
Tier 0 diagnosis with cross-app scope.
|
|
105
|
+
- No new dependencies.
|
|
106
|
+
|
|
107
|
+
## RISKS
|
|
108
|
+
- **B is the one that can corrupt state.** A wrong id key writes a phantom row into every consumer's
|
|
109
|
+
timeline. Get its test right before the fix.
|
|
110
|
+
- A changes visible layout in every host app — hence `ui_reviewer`.
|
|
111
|
+
- D: removing the wrong caller silently breaks read-marking on deep links. State which one you kept and
|
|
112
|
+
why.
|
|
113
|
+
- E: i18next plural resolution depends on the configured language; a key that works in `de` can still be
|
|
114
|
+
wrong in `fr`. Assert all three.
|
|
115
|
+
|
|
116
|
+
## REQUIRED TESTS TO WRITE
|
|
117
|
+
Narrow and behavioural. Do NOT run the full suite.
|
|
118
|
+
|
|
119
|
+
1. **B, the important one:** creating a poll puts exactly **one** new entry in the message cache, keyed
|
|
120
|
+
by the **message** id, carrying `created_at`, and it sorts **last** in a timeline whose other
|
|
121
|
+
messages are older. Assert the poll id is **not** used as a message key.
|
|
122
|
+
2. A bubble renders `HH:MM` only; a day separator appears exactly once between two messages on different
|
|
123
|
+
days and **not** between two on the same day.
|
|
124
|
+
3. Date formatting uses the active i18next language — assert a `de` render differs from an `en` render
|
|
125
|
+
for the same timestamp (this fails today, where both produce the runtime default).
|
|
126
|
+
4. D: opening a conversation issues exactly **one** `markConversationRead` call, via both entry paths
|
|
127
|
+
(list click and direct mount).
|
|
128
|
+
5. E: `SHOW_REPLIES` at `count: 1` renders the singular in **de, en and fr**.
|
|
129
|
+
6. C: a `read_state` frame and a `thread_read_state` frame each update the cached receipt `ReadTicks`
|
|
130
|
+
actually renders, without a REST round-trip. No test asserts a `delivered` frame does anything — it
|
|
131
|
+
should not (dcm MSG-7 retires that frame's only publisher in this same run).
|
|
132
|
+
|
|
133
|
+
**Non-vacuity:** each test must fail with its fix reverted. Test 1 in particular must fail against
|
|
134
|
+
today's `|| poll` fallback — if it passes on the unmodified code, it is not testing the defect.
|
|
135
|
+
|
|
136
|
+
## TEST SCOPE FOR THE GATE (orchestrator)
|
|
137
|
+
The messaging test files plus whatever covers `SupportRecoveryRequestsTab`. Not the full suite.
|
|
138
|
+
|
|
139
|
+
## OPERATOR DECISIONS (this run)
|
|
140
|
+
- **Scope C — superseded below.** The envelope above was updated after the original defer decision was
|
|
141
|
+
made (finding 1 is now diagnosed as `django-core-micha` MSG-8, not still-open); the instruction that
|
|
142
|
+
follows replaces the earlier "defer all of C" call.
|
|
143
|
+
- **Implement C narrowly: `read_state` and `thread_read_state` only. Do NOT add a `delivered` frame
|
|
144
|
+
handler.** The envelope's update confirms these three frames already carry an explicit `.isoformat()`
|
|
145
|
+
and arrive today, independent of MSG-8 (which only gates `message`/`message_edited`/`conversation_upsert`).
|
|
146
|
+
But `delivered` is the one exception: dcm MSG-7 (this same run, scope C there) **deletes `mark_delivered`**
|
|
147
|
+
— the only production code that ever published a `delivered` frame — as part of retiring delivery
|
|
148
|
+
tracking down to an honest two-state receipt (`all_read` only). Adding a `delivered` handler in
|
|
149
|
+
`applyFrame` here would be live-testable today (against the pre-MSG-7 backend) but dead on arrival the
|
|
150
|
+
moment MSG-7 ships, and contradicts the product decision behind it. In `applyFrame`
|
|
151
|
+
(`MessagingProvider.jsx:123ff`), add `read_state`/`thread_read_state` branches that merge the received
|
|
152
|
+
`last_read_at` (and for `thread_read_state`, the `root_id`) into the relevant cached receipt/message so
|
|
153
|
+
`ReadTicks`/thread-unread state updates without a REST round-trip — do not add a third branch for
|
|
154
|
+
`delivered`. Required test 6 covers `read_state`; add the equivalent for `thread_read_state`, do not add
|
|
155
|
+
one for `delivered`.
|
|
156
|
+
- **Scope B's root cause is now confirmed and fixed at the source, in parallel, by `django-core-micha`
|
|
157
|
+
MSG-7 scope F (operator-approved extension, running concurrently in this session):** dcm's poll REST
|
|
158
|
+
response (`_poll_response` / `serialize_poll`) previously had no `message_id` at all — verified by
|
|
159
|
+
reading dcm's `views.py`/`serializers.py`/`models.py` directly, not assumed. It now returns
|
|
160
|
+
`message_id` (a string, dcm's UUID-as-string convention) on create/vote/close. **Do not re-derive or
|
|
161
|
+
re-verify dcm's fix — build against the assumption that `poll.message_id` is present** on the response
|
|
162
|
+
object `createConversationPoll`/`castPollVote`/`closeConversationPoll` receive. If your local dcm
|
|
163
|
+
checkout does not yet have `message_id` on that response when you test, that is a sequencing problem to
|
|
164
|
+
report, not a signal to invent a fallback in ucm.
|
|
165
|
+
|
|
166
|
+
## IMPLEMENTATION MAP (Orchestrator)
|
|
167
|
+
|
|
168
|
+
### Context package
|
|
169
|
+
|
|
170
|
+
**A — timestamps (`src/messaging/MessageBubble.jsx`, `src/components/SupportRecoveryRequestsTab.jsx`)**
|
|
171
|
+
- `MessageBubble.jsx:133` — `{new Date(message.created_at).toLocaleString()}` inside the `hasMeta` block
|
|
172
|
+
(`:132-136`). Change to render `HH:MM` only, with the active i18next locale, e.g.
|
|
173
|
+
`new Date(message.created_at).toLocaleTimeString(i18n.language, { hour: '2-digit', minute: '2-digit' })`
|
|
174
|
+
— `useTranslation()` is already destructured as `{ t }` at `:33`; add `i18n` to that destructure.
|
|
175
|
+
- The day-separator is `Thread`'s job (it has the neighbouring message), not `MessageBubble`'s. In
|
|
176
|
+
`Thread.jsx`, the render loop over `roots` (`:139-153`, `roots.map((message) => { ... })`) and the
|
|
177
|
+
nested `replies.map` (`:151`) only ever see one message at a time inside the callback — you need the
|
|
178
|
+
previous rendered root's date to compare against. Track it across iterations (e.g. reduce over `roots`
|
|
179
|
+
building an array of `{ message, showDateSeparator }` before rendering, rather than mutating a ref
|
|
180
|
+
inside `.map`, which fires render-order-dependent and is fragile under React StrictMode's double-invoke).
|
|
181
|
+
Insert a separator row (a `Divider`-adjacent `Typography` with the formatted date, locale-aware, e.g. via
|
|
182
|
+
`toLocaleDateString(i18n.language)`) when the day changes. `chronological()` (`Thread.jsx:10`) already
|
|
183
|
+
guarantees ascending order, so a simple previous-vs-current day comparison suffices — no need to
|
|
184
|
+
re-sort.
|
|
185
|
+
- `ConversationList.jsx`'s `relativeTime(value, locale)` (`:31-36`) is the existing pattern for threading
|
|
186
|
+
`i18n?.language` through a date formatter (called at `:89` with `i18n?.language`) — follow that shape,
|
|
187
|
+
don't invent a second convention.
|
|
188
|
+
- `SupportRecoveryRequestsTab.jsx:368` and `:410` (both bare `new Date(...).toLocaleString()`) — same fix,
|
|
189
|
+
same locale-threading. Check whether that component already has `i18n` from `useTranslation()`; if not,
|
|
190
|
+
destructure it there too.
|
|
191
|
+
|
|
192
|
+
**B — poll cache key (`src/messaging/MessagingProvider.jsx`)**
|
|
193
|
+
- `createConversationPoll` (`:401-406`): replace `const message = poll?.message || poll;` — dcm never
|
|
194
|
+
sends a `message` object, only the flat poll projection now carrying `message_id` (see Operator
|
|
195
|
+
Decisions above). Resolve the message id from `poll.message_id` explicitly; if absent, treat it as a
|
|
196
|
+
real error (don't silently fall back to `poll.id`). Something like:
|
|
197
|
+
```js
|
|
198
|
+
const messageId = poll?.message_id;
|
|
199
|
+
if (messageId != null) patchMessage({ id: messageId, conversation_id: poll.conversation_id ?? conversationId, created_at: poll.created_at ?? new Date().toISOString(), poll });
|
|
200
|
+
```
|
|
201
|
+
— note dcm's poll object itself carries no `created_at` either (check `serialize_poll`); you likely need
|
|
202
|
+
to accept that a freshly created poll message has no server `created_at` until the next fetch/frame, OR
|
|
203
|
+
confirm whether the realtime `poll_updated` frame (which does carry a full conversation/message context
|
|
204
|
+
via `_poll_updated_payload`) arrives fast enough in practice to backfill it. Investigate `serialize_poll`'s
|
|
205
|
+
actual returned keys yourself (do not assume) and choose the least-guessy option; state your reasoning.
|
|
206
|
+
- `castPollVote` (`:407-412`) / `closeConversationPoll` (`:413-418`) use `cache.messages[messageId]` where `messageId` is already
|
|
207
|
+
passed in by the caller (not derived from the poll response) — these two are likely already correct
|
|
208
|
+
today (the bug is specific to *creation*, where the id doesn't exist in cache yet). Verify, don't assume;
|
|
209
|
+
the WO's instruction to apply "the same scrutiny" to these means check-first, not change-by-default.
|
|
210
|
+
|
|
211
|
+
**D — double `markConversationRead` (`src/messaging/Thread.jsx`, `src/messaging/ConversationList.jsx`)**
|
|
212
|
+
- `Thread.jsx:56` — `useEffect(() => { if (conversationId != null) markConversationRead(conversationId)...`
|
|
213
|
+
- `ConversationList.jsx:85` — the `ListItemButton`'s `onClick` also calls `markConversationRead(...)`.
|
|
214
|
+
- Keep `Thread.jsx:56`; remove the call from `ConversationList.jsx:85`'s `onClick` (keep `onOpen?.(conversation)`
|
|
215
|
+
itself — only drop the `markConversationRead` call and its `.catch`).
|
|
216
|
+
|
|
217
|
+
**C — wiring gap you must close, not just `applyFrame` (`src/messaging/MessagingProvider.jsx`, `src/messaging/ReadTicks.jsx`)**
|
|
218
|
+
- `EMPTY_CACHE` (`:35`) already declares a `receipts: {}` slot — unused today, no reducer case writes to
|
|
219
|
+
it and nothing reads from it. This is almost certainly the intended home for what `read_state`/
|
|
220
|
+
`thread_read_state` frames should update. Add reducer cases in `applyFrame` (`:123ff`, alongside the
|
|
221
|
+
existing `message`/`reaction`/`poll_updated`/etc. branches) for `frame.type === 'read_state'` and
|
|
222
|
+
`'thread_read_state'` that merge into `state.receipts`, keyed so `ReadTicks` can look itself up (e.g. by
|
|
223
|
+
`conversation_id` + `user_id` for `read_state`, by `root_id` + `user_id` for `thread_read_state` — the
|
|
224
|
+
payload shapes are `{"user_id", "last_read_at"}` and `{"user_id", "root_id", "last_read_at"}`
|
|
225
|
+
respectively, per dcm `services.py:293`/`:312`).
|
|
226
|
+
- **Writing to `cache.receipts` alone does not satisfy required test 6** ("updates the cached receipt
|
|
227
|
+
without a REST round-trip") unless something actually reads it. `ReadTicks.jsx` (`:10-19`) today only
|
|
228
|
+
ever calls `getMessageReadStatus(messageId)` once on mount via `useEffect`, into its own local `status`
|
|
229
|
+
state — it has no path back to `cache` at all. You need `ReadTicks` to react to the relevant slice of
|
|
230
|
+
`cache.receipts` (via `useMessaging()`) so a frame arriving after the initial REST fetch actually updates
|
|
231
|
+
what's rendered — e.g. merge a fresher `cache.receipts` entry over the REST-derived `status` state, or
|
|
232
|
+
derive `all_read` from the participant set + `cache.receipts` entries you have instead of holding a
|
|
233
|
+
second independent read of the same fact. Use your judgment on the exact merge (a full redesign of
|
|
234
|
+
`ReadTicks`' data source is not required — the REST fetch stays as the initial/fallback source of truth,
|
|
235
|
+
e.g. for `recipient_detail`, which no frame carries), but the frame update must be observable in what
|
|
236
|
+
`ReadTicks` renders, not just in an otherwise-unread part of `cache`.
|
|
237
|
+
|
|
238
|
+
**E — plural forms (`src/i18n/messagingTranslations.ts`)**
|
|
239
|
+
- `MessagingThread.SHOW_REPLIES` (`:32`): convert to i18next `_one`/`_other` keys (de/en/fr), all three —
|
|
240
|
+
`Thread.jsx:144` already calls `t('MessagingThread.SHOW_REPLIES', { count: ... })`, i18next resolves the
|
|
241
|
+
plural key automatically once `_one`/`_other` exist; no call-site change needed.
|
|
242
|
+
- Audit found two more `{{count}}` strings with no plural variants: `MessagingComposer.FILES_SELECTED`
|
|
243
|
+
(`:64`) and `MessagingReactions.TOGGLE` (`:72`) — fix both the same way. `MessagingPoll.OPTION` (`:85`,
|
|
244
|
+
`"Option {{count}}"`) is an ordinal label, not a plural noun phrase — it reads correctly at every count
|
|
245
|
+
in all three languages as-is; leave it alone.
|
|
246
|
+
- `MessagingReadTicks.DELIVERED` (`:57`) — **do not add plural forms for it.** Per the WO's own conditional
|
|
247
|
+
("if it survives dcm MSG-7 scope C"): it does not survive — dcm MSG-7 removes `delivered_count` from
|
|
248
|
+
`read_status` this same run (operator-approved), so `ReadTicks.jsx:21`'s
|
|
249
|
+
`status.delivered_count || 0` will render `0` from now on for a different reason (`undefined` instead of
|
|
250
|
+
an always-NULL field) but with **no change in rendered output** — it already always showed "0" before
|
|
251
|
+
this run. This is a pre-existing, unrelated-to-this-WO UX rough edge (a two-state receipt showing a
|
|
252
|
+
meaningless count); flag it as a follow-up in your final report, do not fix `ReadTicks.jsx` here — that
|
|
253
|
+
is out of this WO's scope A-E.
|
|
254
|
+
|
|
255
|
+
### Do-not-touch reminders (from the envelope, restated)
|
|
256
|
+
No change to `isOwn`/`canShowReadTicks`/`senderName`/ownership derivation (that's dcm MSG-7's job) or to
|
|
257
|
+
`reconcileMessage`'s merge semantics or `useRealtimeCore`.
|
|
258
|
+
|
|
259
|
+
### Target repo working directory
|
|
260
|
+
`C:\Users\biglmi\Documents\webapps\ui-core-micha`
|
|
261
|
+
|
|
262
|
+
## TARGET REPO
|
|
263
|
+
`C:\Users\biglmi\Documents\webapps\ui-core-micha`. Branch `main` (or `develop` if present). Publish per
|
|
264
|
+
the repo's release flow; **jg's pin bump is a separate step and NOT part of this WO** — jg also needs
|
|
265
|
+
django-core-micha MSG-7, and bumping one without the other leaves the surface half-fixed.
|
|
266
|
+
|
|
267
|
+
## PROGRESS CONTRACT
|
|
268
|
+
Emit `PLAN: <steps>` up front, then a single-line `PROGRESS: [<n>/<total>] <action>` before every
|
|
269
|
+
relevant action and `PROGRESS: [<n>/<total>] done` on completion, spaced so no gap exceeds ~2 min,
|
|
270
|
+
stdout unbuffered, and exactly one final `RESULT: DONE|BLOCKED <reason>`.
|
|
271
|
+
|
|
272
|
+
## FIX ROUND 1 (Orchestrator, post-review — read this if you are resuming this WO)
|
|
273
|
+
|
|
274
|
+
Your prior chunk (`RESULT: DONE`) is already in the working tree, uncommitted: the orchestrator
|
|
275
|
+
independently confirmed 221/221 tests pass (41 files, up from a 215/40 baseline) and `tsc` is clean. Do
|
|
276
|
+
NOT redo or re-verify what already works. The orchestrator also fixed one pre-existing test itself
|
|
277
|
+
(`tests/messagingUnreadLifecycle.test.jsx`'s "marks the selected conversation read" — it rendered
|
|
278
|
+
`ConversationList` alone with no `Thread`, which your scope D change legitimately broke since `Thread`'s
|
|
279
|
+
mount effect is now sole owner; already corrected and re-verified green, no action needed from you on
|
|
280
|
+
that file). Fix only the following, then re-run your test scope and report `RESULT: DONE` again.
|
|
281
|
+
|
|
282
|
+
**[P1, blocking — confirmed by the independent `reviewer`] Scope C's fix does not actually work for
|
|
283
|
+
direct (1:1) conversations — the single most common case.** `ReadTicks.jsx`'s merge of `cache.receipts`
|
|
284
|
+
over the REST-fetched `status` only takes effect through `recipientDetail` (derived from
|
|
285
|
+
`status.recipient_detail`). But per this repo's own established server contract
|
|
286
|
+
(`work-orders/MSG-3f.md:267`: `recipient_detail` is only present for non-direct conversations — dcm's
|
|
287
|
+
`read_status` never returns it for a `kind: 'direct'` conversation, by design, per the DM-privacy
|
|
288
|
+
carve-out). So for a DM: `applyFrame` correctly writes the incoming `read_state` frame into
|
|
289
|
+
`cache.receipts`, but `ReadTicks` never looks at `cache.receipts` for that case — `recipientDetail` stays
|
|
290
|
+
`undefined`, and `allRead` falls back to the stale `status.all_read` from the one-time REST fetch. The
|
|
291
|
+
defect this scope exists to fix ("a receipt can never update within a session", WO finding 7) is therefore
|
|
292
|
+
still present for every 1:1 conversation. Your own new test (`tests/messagingMsg6.test.jsx`, the
|
|
293
|
+
`read_state` test) only exercises `conversation: { kind: 'group' }` — write a DM-scoped equivalent that
|
|
294
|
+
would have caught this.
|
|
295
|
+
|
|
296
|
+
Fix: for a direct conversation, derive the receipt from `cache.receipts` keyed by the conversation's
|
|
297
|
+
counterpart, not from `recipient_detail` (which will never be populated for DMs — do not wait for it).
|
|
298
|
+
`conversation.other_user_id` (dcm's bare-id field for a direct conversation's counterpart, added in a
|
|
299
|
+
prior WO) is what you need: look up `cache.receipts[\`${conversation.id}:${conversation.other_user_id}\`]`
|
|
300
|
+
directly when `conversation?.kind === 'direct'`, and treat its `last_read_at` presence as `all_read` for
|
|
301
|
+
that case (a DM has exactly one other participant, so "all read" and "counterpart has read" are the same
|
|
302
|
+
fact — no `.every()` needed, unlike the group/managed case). Keep the existing `recipient_detail`-based
|
|
303
|
+
path for non-direct conversations unchanged. Add a test mirroring the existing group-conversation
|
|
304
|
+
`read_state` test but with `conversation: { id: 'c1', kind: 'direct', other_user_id: 'u2' }`, asserting the
|
|
305
|
+
label flips from `DELIVERED` to `ALL_READ` after the frame arrives, with no additional REST call.
|
|
306
|
+
|
|
307
|
+
Re-run your test scope (the messaging test files plus whatever covers `SupportRecoveryRequestsTab`) after
|
|
308
|
+
this change. Do not touch anything outside `ReadTicks.jsx` and the one new/extended test.
|