@micha.bigler/ui-core-micha 2.21.2 → 2.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/WORK_ORDERS.md +3 -0
- package/dist/components/SupportRecoveryRequestsTab.js +3 -3
- package/dist/i18n/messagingTranslations.js +8 -4
- package/dist/messaging/ConversationList.js +10 -3
- package/dist/messaging/MessageBubble.js +2 -2
- package/dist/messaging/MessagingProvider.js +22 -4
- package/dist/messaging/ReadTicks.js +59 -10
- package/dist/messaging/Thread.js +39 -8
- package/package.json +1 -1
- package/src/components/SupportRecoveryRequestsTab.jsx +3 -3
- package/src/i18n/messagingTranslations.ts +8 -4
- package/src/messaging/ConversationList.jsx +10 -3
- package/src/messaging/MessageBubble.jsx +2 -2
- package/src/messaging/MessagingProvider.jsx +18 -2
- package/src/messaging/ReadTicks.jsx +61 -8
- package/src/messaging/Thread.jsx +33 -8
- package/tests/ConversationList.test.jsx +52 -0
- package/tests/Thread.test.jsx +9 -8
- package/tests/messagingChunk6.test.jsx +16 -9
- package/tests/messagingCompactBubble.test.jsx +3 -3
- package/tests/messagingMsg6.test.jsx +105 -0
- package/tests/messagingMsg6f.test.jsx +155 -0
- package/tests/messagingThreadReceipts.test.jsx +10 -2
- package/tests/messagingThreadingQuoting.test.jsx +1 -1
- package/tests/messagingUnreadLifecycle.test.jsx +14 -2
- package/work-orders/MSG-6e.md +308 -0
- package/work-orders/MSG-6f.md +214 -0
package/WORK_ORDERS.md
CHANGED
|
@@ -34,5 +34,8 @@ 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. |
|
|
41
|
+
| MSG-6f | `titleOf` never calls the host resolver for a broadcast; the read ticks claim a delivery that does not exist | (A) `ConversationList.jsx` `titleOf()` gates the host callback on `conversation.kind === 'managed'`, so jg-ferien MSG-10's correct broadcast branch in `resolveManagedConversationLabel` is **unreachable from the list** — the row still falls through to `MessagingList.UNTITLED`. (B) dcm MSG-7 removed `delivered_count` from `read-status`, but `ReadTicks.jsx` still renders `t('MessagingReadTicks.DELIVERED', { count: renderedStatus.delivered_count \|\| 0 })`, so `undefined \|\| 0` produces the identical "Zugestellt an 0" label as before — a claimed delivery with no data source behind it. (C) a viewer already sitting in an open thread when a new message lands is recorded as "not read" until they navigate away and back — the mount-effect only re-marks once per open. | 2026-08-03 | done | (pending push) | Envelope: [work-orders/MSG-6f.md](work-orders/MSG-6f.md); all three scopes released together per operator's critical-path directive (one release instead of two, since A and C never depended on dcm) — this also stands as the operator confirmation the envelope asked for before implementing B's vocabulary change. **A:** kind gate removed entirely from `titleOf()`; a host resolver returning `null` already means "no label", so gating duplicated a decision that belongs to the host. **B:** `DELIVERED` retired; dcm MSG-9 (published 2.40.0) added `read_count`/`recipient_count` to `read-status`, gated the same way as `recipient_detail` (non-direct, `read_receipt_detail`-eligible). `ReadTicks.jsx` now renders: counts present → a read ratio (`READ_RATIO`, e.g. "18/40"), no counts + `direct` → two-state tick (`SENT`/`ALL_READ`), no counts + non-direct → nothing (was a permanently-grey "always delivered" tick before). **C:** `Thread.jsx` now also subscribes to `MESSAGING_ENVELOPE` directly (independent of `MessagingProvider`'s own subscription — the registry supports multiple handlers per envelope) and re-marks read on a live `message` frame for the open conversation, skipped when the tab is hidden. **Reviewed independently: `reviewer` + `ui_reviewer`, both clean after one fix round.** Fix round 1 closed a **P1** (`reviewer`, self-caught during the fix, not shipped): the ratio was briefly recomputed client-side from `recipientDetail.filter(r => r.last_read_at).length` to make it "live-update" from `read_state` frames — this silently contradicts the WO's explicit instruction and is wrong, because `recipientDetail`'s `last_read_at` is never thresholded against the specific message's `created_at` the way dcm's own aggregate is (`last_read_at__gte=message.created_at`, dcm `services.py`); in a real group with history it would read as close to N/N regardless of who actually saw *this* message. Reverted to trusting dcm's `read_count`/`recipient_count` verbatim, with the trade-off documented in `ReadTicks.jsx`: the ratio no longer live-updates from a `read_state` frame, only on the next read-status fetch (unlike the DM tick and the per-person popover, both viewer/timestamp-relative facts that safely reuse `cache.receipts`). Also fixed a P3 (`ui_reviewer`): the ratio's `color="text.secondary"` diverged from the sibling meta row's `inherit` convention, changed to `inherit`. **Known, disclosed, non-blocking follow-up (`ui_reviewer` P2, not fixed here):** the DM tick's fixed `grey.500`/`primary.main` colour pair is not fully dark-mode contrast-neutral — verified by hand (WCAG-style luminance/contrast math against both themes' actual palette values, not just accepting the reviewer's claim): light-mode state separation ≈1.71:1, dark-mode ≈1.53:1 (worse, since `primary.main` lightens substantially in dark mode while `grey.500` is a fixed, non-mode-aware value). No single grey threads both themes well — the two themes need opposite-direction adjustments relative to their own inverted primary-blue lightness — and the icon shape (`Done` vs `DoneAll`) remains the primary, non-colour signal per the component's own design principle, so this is tracked rather than guessed at further without live visual verification. Test-infrastructure fix alongside C, not a behavioural change: `Thread.jsx`'s new `subscribe()` call broke 3 existing test files' single-captured-handler mock pattern (`frameHandler = handler`, overwritten per call) — converted to Set/Map-based multi-handler dispatch in `messagingChunk6.test.jsx`, `messagingMsg6.test.jsx`, `messagingThreadReceipts.test.jsx`. Full suite 234/234 (was 222), `tsc` clean. Published as npm 2.22.0 (pending). jg-ferien pin bump to `dcm 2.40.0` / `ucm 2.22.0` is a separate, subsequent step. |
|
|
@@ -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' },
|
|
@@ -54,14 +55,16 @@ export const messagingTranslations = {
|
|
|
54
55
|
'MessagingDirect.CANCEL': { de: 'Abbrechen', en: 'Cancel', fr: 'Annuler' },
|
|
55
56
|
'MessagingDirect.START_ERROR': { de: 'Direktnachricht konnte nicht gestartet werden.', en: 'Direct message could not be started.', fr: 'Impossible de démarrer le message direct.' },
|
|
56
57
|
'MessagingReadTicks.ALL_READ': { de: 'Von allen gelesen', en: 'Read by everyone', fr: 'Lu par tout le monde' },
|
|
57
|
-
'MessagingReadTicks.
|
|
58
|
+
'MessagingReadTicks.SENT': { de: 'Gesendet', en: 'Sent', fr: 'Envoyé' },
|
|
59
|
+
'MessagingReadTicks.READ_RATIO': { de: '{{read}} von {{total}} gelesen', en: '{{read}} of {{total}} read', fr: '{{read}} sur {{total}} lu(s)' },
|
|
58
60
|
'MessagingReadTicks.RECIPIENTS': { de: 'Lesebestätigungen', en: 'Read recipients', fr: 'Destinataires lus' },
|
|
59
61
|
'MessagingComposer.LABEL': { de: 'Nachricht verfassen', en: 'Compose message', fr: 'Rédiger un message' },
|
|
60
62
|
'MessagingComposer.MESSAGE': { de: 'Nachricht', en: 'Message', fr: 'Message' },
|
|
61
63
|
'MessagingComposer.SEND': { de: 'Senden', en: 'Send', fr: 'Envoyer' },
|
|
62
64
|
'MessagingComposer.RETRY': { de: 'Erneut versuchen', en: 'Retry', fr: 'Réessayer' },
|
|
63
65
|
'MessagingComposer.ADD_ATTACHMENT': { de: 'Anhang hinzufügen', en: 'Add attachment', fr: 'Ajouter une pièce jointe' },
|
|
64
|
-
'MessagingComposer.
|
|
66
|
+
'MessagingComposer.FILES_SELECTED_one': { de: '{{count}} Anhang ausgewählt', en: '{{count}} attachment selected', fr: '{{count}} pièce jointe sélectionnée' },
|
|
67
|
+
'MessagingComposer.FILES_SELECTED_other': { de: '{{count}} Anhänge ausgewählt', en: '{{count}} attachments selected', fr: '{{count}} pièces jointes sélectionnées' },
|
|
65
68
|
'MessagingComposer.REPLYING_TO': { de: 'Antwort an {{sender}}', en: 'Replying to {{sender}}', fr: 'Réponse à {{sender}}' },
|
|
66
69
|
'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
70
|
'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 +72,8 @@ export const messagingTranslations = {
|
|
|
69
72
|
'MessagingAttachments.DOWNLOAD': { de: '{{name}} herunterladen', en: 'Download {{name}}', fr: 'Télécharger {{name}}' },
|
|
70
73
|
'MessagingReactions.LABEL': { de: 'Reaktionen', en: 'Reactions', fr: 'Réactions' },
|
|
71
74
|
'MessagingReactions.ADD': { de: 'Reaktion hinzufügen', en: 'Add reaction', fr: 'Ajouter une réaction' },
|
|
72
|
-
'MessagingReactions.
|
|
75
|
+
'MessagingReactions.TOGGLE_one': { de: '{{emoji}} Reaktion mit {{count}} Stimme umschalten', en: 'Toggle {{emoji}} reaction with {{count}} vote', fr: 'Basculer la réaction {{emoji}} avec {{count}} vote' },
|
|
76
|
+
'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
77
|
'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
78
|
'MessagingPoll.LABEL': { de: 'Umfrage', en: 'Poll', fr: 'Sondage' },
|
|
75
79
|
'MessagingPoll.VOTE': { de: 'Abstimmen', en: 'Vote', fr: 'Voter' },
|
|
@@ -15,7 +15,14 @@ import { ConversationLaunchers } from './ConversationLaunchers';
|
|
|
15
15
|
// like a managed conversation's label above.
|
|
16
16
|
function titleOf(conversation, t, resolveManagedLabel, resolveDirectUserName) {
|
|
17
17
|
var _a, _b;
|
|
18
|
-
|
|
18
|
+
// Not gated on kind === 'managed': a host resolver already returns null to
|
|
19
|
+
// mean "I have no label for this conversation" (the delegation contract
|
|
20
|
+
// this comment block describes above), so gating here duplicated a
|
|
21
|
+
// decision that belongs to the host and silently made the resolver
|
|
22
|
+
// unreachable for any kind the host later decided to label (found live:
|
|
23
|
+
// jg-ferien's resolver was taught to handle 'broadcast' too, but never got
|
|
24
|
+
// a chance to run because this gate excluded it).
|
|
25
|
+
if (resolveManagedLabel) {
|
|
19
26
|
const label = resolveManagedLabel(conversation);
|
|
20
27
|
if (label)
|
|
21
28
|
return label;
|
|
@@ -51,7 +58,7 @@ function KindIcon({ conversation }) {
|
|
|
51
58
|
/** A standalone, provider-backed list; scope picker metadata is supplied as launcher props. */
|
|
52
59
|
export function ConversationList({ onOpen, groupLaunchers, broadcastLauncher, autoOpenBroadcast = false, includeArchived = false, resolveManagedLabel, resolveDirectUserName }) {
|
|
53
60
|
const { t, i18n } = useTranslation();
|
|
54
|
-
const { cache, activeConversationId, loadMoreConversations, setConversationArchived, setConversationPreferences
|
|
61
|
+
const { cache, activeConversationId, loadMoreConversations, setConversationArchived, setConversationPreferences } = useMessaging();
|
|
55
62
|
const [menuConversation, setMenuConversation] = useState(null);
|
|
56
63
|
const [menuAnchor, setMenuAnchor] = useState(null);
|
|
57
64
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
@@ -98,6 +105,6 @@ export function ConversationList({ onOpen, groupLaunchers, broadcastLauncher, au
|
|
|
98
105
|
var _a;
|
|
99
106
|
const unread = unreadFor(conversation);
|
|
100
107
|
const archived = Boolean(conversation.archived || conversation.archived_at);
|
|
101
|
-
return _jsxs(ListItemButton, { selected: conversation.id === activeConversationId, onClick: () =>
|
|
108
|
+
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
109
|
}) })), 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
110
|
}
|
|
@@ -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) => {
|
|
@@ -1,14 +1,35 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import DoneAllOutlinedIcon from '@mui/icons-material/DoneAllOutlined';
|
|
3
3
|
import DoneOutlinedIcon from '@mui/icons-material/DoneOutlined';
|
|
4
|
-
import { ButtonBase, List, ListItem, Popover } from '@mui/material';
|
|
4
|
+
import { ButtonBase, List, ListItem, Popover, Typography } from '@mui/material';
|
|
5
5
|
import { useEffect, useState } from 'react';
|
|
6
6
|
import { useTranslation } from 'react-i18next';
|
|
7
7
|
import { useMessaging } from './MessagingProvider';
|
|
8
|
-
|
|
8
|
+
// Explicit tokens, not `inherit`/`primary` -- `inherit` inside the message
|
|
9
|
+
// meta Stack resolved to `text.disabled`, which read too close to the "read"
|
|
10
|
+
// state at the rendered 1rem icon size. The icon shape (Done vs DoneAll)
|
|
11
|
+
// already differs, so the colour distinction is a second, independent signal
|
|
12
|
+
// rather than the only one.
|
|
13
|
+
const DM_SENT_COLOR = 'grey.500';
|
|
14
|
+
const DM_READ_COLOR = 'primary.main';
|
|
15
|
+
/**
|
|
16
|
+
* Shape-driven, no client-side role/permission branching: dcm only ever
|
|
17
|
+
* includes `read_count`/`recipient_count` inside the same `read_receipt_detail`-
|
|
18
|
+
* gated block as `recipient_detail`, and never for a `direct` conversation
|
|
19
|
+
* (the DM privacy carve-out). So "counts present" already means "team member,
|
|
20
|
+
* non-direct" -- checking anything else here would re-implement a decision
|
|
21
|
+
* dcm's response shape already makes.
|
|
22
|
+
*
|
|
23
|
+
* - counts present -> a read ratio ("18/40"), numeric, no icon ladder.
|
|
24
|
+
* - no counts, `direct` -> a two-state tick (sent / read).
|
|
25
|
+
* - no counts, non-direct -> no indicator at all (an ordinary participant in
|
|
26
|
+
* a group; a permanently-grey tick here would be the "looks always
|
|
27
|
+
* delivered" defect this component exists to remove).
|
|
28
|
+
*/
|
|
9
29
|
export function ReadTicks({ messageId, conversation }) {
|
|
30
|
+
var _a;
|
|
10
31
|
const { t } = useTranslation();
|
|
11
|
-
const { getMessageReadStatus } = useMessaging();
|
|
32
|
+
const { cache, getMessageReadStatus } = useMessaging();
|
|
12
33
|
const [status, setStatus] = useState(null);
|
|
13
34
|
const [anchorEl, setAnchorEl] = useState(null);
|
|
14
35
|
useEffect(() => {
|
|
@@ -20,12 +41,40 @@ export function ReadTicks({ messageId, conversation }) {
|
|
|
20
41
|
}, [getMessageReadStatus, messageId]);
|
|
21
42
|
if (!status)
|
|
22
43
|
return null;
|
|
23
|
-
const
|
|
24
|
-
const
|
|
44
|
+
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;
|
|
45
|
+
const directReceipt = (conversation === null || conversation === void 0 ? void 0 : conversation.kind) === 'direct' && (conversation === null || conversation === void 0 ? void 0 : conversation.other_user_id) != null
|
|
46
|
+
? cache.receipts[`${conversation.id}:${conversation.other_user_id}`]
|
|
47
|
+
: null;
|
|
48
|
+
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));
|
|
49
|
+
const hasCounts = typeof status.read_count === 'number' && typeof status.recipient_count === 'number';
|
|
50
|
+
if (!hasCounts && (conversation === null || conversation === void 0 ? void 0 : conversation.kind) !== 'direct')
|
|
51
|
+
return null;
|
|
25
52
|
// 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 :
|
|
53
|
+
const detail = (conversation === null || conversation === void 0 ? void 0 : conversation.kind) === 'direct' ? null : recipientDetail;
|
|
27
54
|
const recipients = (detail === null || detail === void 0 ? void 0 : detail.map((recipient) => recipient.display_name || recipient.username || recipient.user_id).filter(Boolean)) || [];
|
|
28
|
-
if (
|
|
29
|
-
|
|
30
|
-
|
|
55
|
+
if (hasCounts) {
|
|
56
|
+
// Read count/recipient_count exactly as dcm computed them -- do NOT
|
|
57
|
+
// recompute from recipientDetail here. dcm thresholds each participant's
|
|
58
|
+
// last_read_at against THIS message's created_at
|
|
59
|
+
// (`last_read_at__gte=message.created_at`, services.py); recipientDetail
|
|
60
|
+
// only carries each participant's raw, unthresholded last_read_at, and
|
|
61
|
+
// ReadTicks has no access to the message's own created_at to reproduce
|
|
62
|
+
// that comparison. Recomputing from recipientDetail (tried during
|
|
63
|
+
// implementation, caught in review) silently counted anyone who had ever
|
|
64
|
+
// read the conversation at any point as "read this message" -- in a real
|
|
65
|
+
// group with history, that reads as close to N/N regardless of who
|
|
66
|
+
// actually saw this specific message, the opposite of the WO's intent.
|
|
67
|
+
// The trade-off: unlike the DM tick and the per-person popover below
|
|
68
|
+
// (both viewer/timestamp-relative facts, not message-relative, so they
|
|
69
|
+
// safely reuse cache.receipts), this numeric ratio only updates on the
|
|
70
|
+
// next read-status fetch, not live from a read_state frame.
|
|
71
|
+
const label = t('MessagingReadTicks.READ_RATIO', { read: status.read_count, total: status.recipient_count });
|
|
72
|
+
const ratio = _jsxs(Typography, { component: "span", variant: "caption", color: "inherit", children: [status.read_count, "/", status.recipient_count] });
|
|
73
|
+
if (!recipients.length)
|
|
74
|
+
return _jsx("span", { role: "img", "aria-label": label, children: ratio });
|
|
75
|
+
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' }, children: ratio }), _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)) }) })] });
|
|
76
|
+
}
|
|
77
|
+
const label = allRead ? t('MessagingReadTicks.ALL_READ') : t('MessagingReadTicks.SENT');
|
|
78
|
+
const StatusIcon = allRead ? DoneAllOutlinedIcon : DoneOutlinedIcon;
|
|
79
|
+
return _jsx(StatusIcon, { role: "img", "aria-label": label, sx: { color: allRead ? DM_READ_COLOR : DM_SENT_COLOR } });
|
|
31
80
|
}
|
package/dist/messaging/Thread.js
CHANGED
|
@@ -4,20 +4,23 @@ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'r
|
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
5
5
|
import { AuthContext } from '../auth/AuthContext';
|
|
6
6
|
import { MessageBubble } from './MessageBubble';
|
|
7
|
-
import { useMessaging } from './MessagingProvider';
|
|
7
|
+
import { MESSAGING_ENVELOPE, useMessaging } from './MessagingProvider';
|
|
8
8
|
import { ReadTicks } from './ReadTicks';
|
|
9
|
+
import { useRealtime } from '../notifications/realtime';
|
|
9
10
|
function chronological(messages) { return [...messages].sort((left, right) => new Date(left.created_at || 0) - new Date(right.created_at || 0)); }
|
|
10
11
|
function replyToId(message) { var _a; return (_a = message.reply_to_id) !== null && _a !== void 0 ? _a : message.reply_to; }
|
|
11
12
|
// last_reply_at/reply_count are viewer-independent (always on serialize_message);
|
|
12
13
|
// thread_last_read_at is viewer-specific and REST-only (never on a frame — see
|
|
13
14
|
// MessagingProvider.jsx's applyFrame merge-trap handling). A null receipt with
|
|
14
15
|
// at least one reply counts as unread; no replies means no marker at all.
|
|
15
|
-
function hasUnreadReplies(message) {
|
|
16
|
+
function hasUnreadReplies(message, receipts, userId) {
|
|
17
|
+
var _a, _b;
|
|
16
18
|
if (!message.reply_count)
|
|
17
19
|
return false;
|
|
18
|
-
|
|
20
|
+
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;
|
|
21
|
+
if (lastReadAt == null)
|
|
19
22
|
return true;
|
|
20
|
-
return Boolean(message.last_reply_at) && new Date(message.last_reply_at) > new Date(
|
|
23
|
+
return Boolean(message.last_reply_at) && new Date(message.last_reply_at) > new Date(lastReadAt);
|
|
21
24
|
}
|
|
22
25
|
// A still-pending optimistic row (chunk 4) has a fake `local-<requestId>` id
|
|
23
26
|
// and no durable server row yet — mounting ReadTicks against it would fire a
|
|
@@ -40,9 +43,10 @@ function canShowReadTicks(message, user) {
|
|
|
40
43
|
*/
|
|
41
44
|
export function Thread({ conversationId, onReplyTargetChange, canModerateMessages = false, onAnnouncementLink }) {
|
|
42
45
|
var _a;
|
|
43
|
-
const { t } = useTranslation();
|
|
46
|
+
const { t, i18n } = useTranslation();
|
|
44
47
|
const { user } = useContext(AuthContext) || {};
|
|
45
48
|
const { cache, loadMoreMessages, loadThreadReplies, markConversationRead, markThreadRead, refreshThread } = useMessaging();
|
|
49
|
+
const { subscribe } = useRealtime();
|
|
46
50
|
const scrollRef = useRef(null);
|
|
47
51
|
const wasNearBottomRef = useRef(true);
|
|
48
52
|
const messageCountRef = useRef(0);
|
|
@@ -54,8 +58,35 @@ export function Thread({ conversationId, onReplyTargetChange, canModerateMessage
|
|
|
54
58
|
const conversation = cache.conversations[conversationId];
|
|
55
59
|
const conversationMessages = useMemo(() => chronological(Object.values(cache.messages).filter((message) => message.conversation_id === conversationId)), [cache.messages, conversationId]);
|
|
56
60
|
const roots = useMemo(() => chronological(Object.values(cache.messages).filter((message) => message.conversation_id === conversationId && !replyToId(message))), [cache.messages, conversationId]);
|
|
61
|
+
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
62
|
useEffect(() => { if (conversationId != null)
|
|
58
63
|
markConversationRead(conversationId).catch(() => { }); }, [conversationId, markConversationRead]);
|
|
64
|
+
// Re-mark read when a live message frame arrives for the conversation
|
|
65
|
+
// currently open -- without this, `read_count` biases downward for the
|
|
66
|
+
// person paying the most attention: the mount-effect above only fires once
|
|
67
|
+
// per open, so a viewer already sitting in the thread when a new message
|
|
68
|
+
// lands is recorded as "not read" until they navigate away and back.
|
|
69
|
+
// Subscribes independently of MessagingProvider's own subscription to the
|
|
70
|
+
// same envelope (the registry supports multiple handlers per envelope) so
|
|
71
|
+
// this stays a Thread-local concern, not a MessagingProvider reducer change.
|
|
72
|
+
// No sender check needed: dcm's `resolve_live_recipients(sender=actor)`
|
|
73
|
+
// already excludes the sender from the live fan-out, so a `message` frame
|
|
74
|
+
// for this conversation structurally cannot be the viewer's own send.
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (conversationId == null)
|
|
77
|
+
return undefined;
|
|
78
|
+
return subscribe(MESSAGING_ENVELOPE, (frame) => {
|
|
79
|
+
var _a, _b;
|
|
80
|
+
if (frame.type !== 'message')
|
|
81
|
+
return;
|
|
82
|
+
const frameConversationId = (_a = frame.conversation_id) !== null && _a !== void 0 ? _a : (_b = frame.message) === null || _b === void 0 ? void 0 : _b.conversation_id;
|
|
83
|
+
if (String(frameConversationId) !== String(conversationId))
|
|
84
|
+
return;
|
|
85
|
+
if (document.visibilityState === 'hidden')
|
|
86
|
+
return;
|
|
87
|
+
markConversationRead(conversationId).catch(() => { });
|
|
88
|
+
});
|
|
89
|
+
}, [conversationId, markConversationRead, subscribe]);
|
|
59
90
|
useEffect(() => { wasNearBottomRef.current = true; messageCountRef.current = 0; }, [conversationId]);
|
|
60
91
|
// Nothing else in this component ever fetches messages for the conversation
|
|
61
92
|
// actually being opened — `cache.messages` is otherwise only populated by a
|
|
@@ -139,10 +170,10 @@ export function Thread({ conversationId, onReplyTargetChange, canModerateMessage
|
|
|
139
170
|
}
|
|
140
171
|
setOpenThreads((current) => (Object.assign(Object.assign({}, current), { [root.id]: !current[root.id] })));
|
|
141
172
|
};
|
|
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:
|
|
173
|
+
return (_jsxs(Stack, { spacing: 1, sx: { minWidth: 0 }, children: [error && _jsx(Alert, { severity: "error", role: "alert", children: error }), replyingTo && _jsx(Alert, { severity: "info", onClose: () => { setReplyingTo(null); onReplyTargetChange === null || onReplyTargetChange === void 0 ? void 0 : onReplyTargetChange(null); }, children: t('MessagingThread.REPLYING_TO', { sender: ((_a = replyingTo.sender) === null || _a === void 0 ? void 0 : _a.display_name) || t('MessagingThread.UNKNOWN_SENDER') }) }), _jsxs(Box, { ref: scrollRef, onScroll: onScroll, sx: { maxHeight: 560, overflowY: 'auto', overflowX: 'hidden', px: 0.5 }, "aria-label": t('MessagingThread.TIMELINE'), children: [cache.cursors.messages[conversationId] && _jsx(Box, { textAlign: "center", py: 1, children: _jsx(Button, { onClick: loadOlder, disabled: loadingOlder, children: loadingOlder ? _jsx(CircularProgress, { size: 18 }) : t('MessagingThread.LOAD_OLDER') }) }), loadingInitial && !roots.length ? _jsx(Box, { textAlign: "center", py: 4, children: _jsx(CircularProgress, { size: 20 }) }) : (!roots.length && _jsx(Typography, { color: "text.secondary", textAlign: "center", py: 4, children: t('MessagingThread.EMPTY') })), _jsx(Stack, { spacing: 1, sx: { pr: 2 }, children: datedRoots.map(({ message, showDateSeparator }) => {
|
|
143
174
|
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);
|
|
175
|
+
return _jsxs(Stack, { spacing: 0.75, children: [showDateSeparator && _jsxs(Stack, { "data-testid": "day-separator", direction: "row", alignItems: "center", spacing: 1, children: [_jsx(Divider, { sx: { flex: 1 } }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: new Date(message.created_at).toLocaleDateString(i18n === null || i18n === void 0 ? void 0 : i18n.language) }), _jsx(Divider, { sx: { flex: 1 } })] }), _jsx(MessageBubble, { message: message, conversation: conversation, onReply: reply, onJumpToMessage: jumpToMessage, onAnnouncementLink: onAnnouncementLink, canModerateMessages: canModerateMessages, children: canShowReadTicks(message, user) && _jsx(ReadTicks, { messageId: message.id, conversation: conversation }) }), (message.reply_count || replies.length) > 0 && (() => {
|
|
176
|
+
const unread = hasUnreadReplies(message, cache.receipts, user === null || user === void 0 ? void 0 : user.id);
|
|
146
177
|
const label = openThreads[message.id] ? t('MessagingThread.HIDE_REPLIES') : t('MessagingThread.SHOW_REPLIES', { count: message.reply_count || replies.length });
|
|
147
178
|
// MUI's Badge spreads unrecognized props (incl. aria-label) onto
|
|
148
179
|
// 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' },
|
|
@@ -54,14 +55,16 @@ export const messagingTranslations = {
|
|
|
54
55
|
'MessagingDirect.CANCEL': { de: 'Abbrechen', en: 'Cancel', fr: 'Annuler' },
|
|
55
56
|
'MessagingDirect.START_ERROR': { de: 'Direktnachricht konnte nicht gestartet werden.', en: 'Direct message could not be started.', fr: 'Impossible de démarrer le message direct.' },
|
|
56
57
|
'MessagingReadTicks.ALL_READ': { de: 'Von allen gelesen', en: 'Read by everyone', fr: 'Lu par tout le monde' },
|
|
57
|
-
'MessagingReadTicks.
|
|
58
|
+
'MessagingReadTicks.SENT': { de: 'Gesendet', en: 'Sent', fr: 'Envoyé' },
|
|
59
|
+
'MessagingReadTicks.READ_RATIO': { de: '{{read}} von {{total}} gelesen', en: '{{read}} of {{total}} read', fr: '{{read}} sur {{total}} lu(s)' },
|
|
58
60
|
'MessagingReadTicks.RECIPIENTS': { de: 'Lesebestätigungen', en: 'Read recipients', fr: 'Destinataires lus' },
|
|
59
61
|
'MessagingComposer.LABEL': { de: 'Nachricht verfassen', en: 'Compose message', fr: 'Rédiger un message' },
|
|
60
62
|
'MessagingComposer.MESSAGE': { de: 'Nachricht', en: 'Message', fr: 'Message' },
|
|
61
63
|
'MessagingComposer.SEND': { de: 'Senden', en: 'Send', fr: 'Envoyer' },
|
|
62
64
|
'MessagingComposer.RETRY': { de: 'Erneut versuchen', en: 'Retry', fr: 'Réessayer' },
|
|
63
65
|
'MessagingComposer.ADD_ATTACHMENT': { de: 'Anhang hinzufügen', en: 'Add attachment', fr: 'Ajouter une pièce jointe' },
|
|
64
|
-
'MessagingComposer.
|
|
66
|
+
'MessagingComposer.FILES_SELECTED_one': { de: '{{count}} Anhang ausgewählt', en: '{{count}} attachment selected', fr: '{{count}} pièce jointe sélectionnée' },
|
|
67
|
+
'MessagingComposer.FILES_SELECTED_other': { de: '{{count}} Anhänge ausgewählt', en: '{{count}} attachments selected', fr: '{{count}} pièces jointes sélectionnées' },
|
|
65
68
|
'MessagingComposer.REPLYING_TO': { de: 'Antwort an {{sender}}', en: 'Replying to {{sender}}', fr: 'Réponse à {{sender}}' },
|
|
66
69
|
'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
70
|
'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 +72,8 @@ export const messagingTranslations = {
|
|
|
69
72
|
'MessagingAttachments.DOWNLOAD': { de: '{{name}} herunterladen', en: 'Download {{name}}', fr: 'Télécharger {{name}}' },
|
|
70
73
|
'MessagingReactions.LABEL': { de: 'Reaktionen', en: 'Reactions', fr: 'Réactions' },
|
|
71
74
|
'MessagingReactions.ADD': { de: 'Reaktion hinzufügen', en: 'Add reaction', fr: 'Ajouter une réaction' },
|
|
72
|
-
'MessagingReactions.
|
|
75
|
+
'MessagingReactions.TOGGLE_one': { de: '{{emoji}} Reaktion mit {{count}} Stimme umschalten', en: 'Toggle {{emoji}} reaction with {{count}} vote', fr: 'Basculer la réaction {{emoji}} avec {{count}} vote' },
|
|
76
|
+
'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
77
|
'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
78
|
'MessagingPoll.LABEL': { de: 'Umfrage', en: 'Poll', fr: 'Sondage' },
|
|
75
79
|
'MessagingPoll.VOTE': { de: 'Abstimmen', en: 'Vote', fr: 'Voter' },
|
|
@@ -15,7 +15,14 @@ import { ConversationLaunchers } from './ConversationLaunchers';
|
|
|
15
15
|
// name — so a direct conversation's counterpart name is host-supplied, exactly
|
|
16
16
|
// like a managed conversation's label above.
|
|
17
17
|
function titleOf(conversation, t, resolveManagedLabel, resolveDirectUserName) {
|
|
18
|
-
|
|
18
|
+
// Not gated on kind === 'managed': a host resolver already returns null to
|
|
19
|
+
// mean "I have no label for this conversation" (the delegation contract
|
|
20
|
+
// this comment block describes above), so gating here duplicated a
|
|
21
|
+
// decision that belongs to the host and silently made the resolver
|
|
22
|
+
// unreachable for any kind the host later decided to label (found live:
|
|
23
|
+
// jg-ferien's resolver was taught to handle 'broadcast' too, but never got
|
|
24
|
+
// a chance to run because this gate excluded it).
|
|
25
|
+
if (resolveManagedLabel) {
|
|
19
26
|
const label = resolveManagedLabel(conversation);
|
|
20
27
|
if (label) return label;
|
|
21
28
|
}
|
|
@@ -47,7 +54,7 @@ function KindIcon({ conversation }) {
|
|
|
47
54
|
/** A standalone, provider-backed list; scope picker metadata is supplied as launcher props. */
|
|
48
55
|
export function ConversationList({ onOpen, groupLaunchers, broadcastLauncher, autoOpenBroadcast = false, includeArchived = false, resolveManagedLabel, resolveDirectUserName }) {
|
|
49
56
|
const { t, i18n } = useTranslation();
|
|
50
|
-
const { cache, activeConversationId, loadMoreConversations, setConversationArchived, setConversationPreferences
|
|
57
|
+
const { cache, activeConversationId, loadMoreConversations, setConversationArchived, setConversationPreferences } = useMessaging();
|
|
51
58
|
const [menuConversation, setMenuConversation] = useState(null);
|
|
52
59
|
const [menuAnchor, setMenuAnchor] = useState(null);
|
|
53
60
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
@@ -82,7 +89,7 @@ export function ConversationList({ onOpen, groupLaunchers, broadcastLauncher, au
|
|
|
82
89
|
{conversations.map((conversation) => {
|
|
83
90
|
const unread = unreadFor(conversation);
|
|
84
91
|
const archived = Boolean(conversation.archived || conversation.archived_at);
|
|
85
|
-
return <ListItemButton key={conversation.id} selected={conversation.id === activeConversationId} onClick={() =>
|
|
92
|
+
return <ListItemButton key={conversation.id} selected={conversation.id === activeConversationId} onClick={() => onOpen?.(conversation)} sx={{ minHeight: 64, opacity: archived ? 0.6 : 1 }}>
|
|
86
93
|
<Badge color="error" badgeContent={unread || null} max={99} sx={{ mr: 2 }}>
|
|
87
94
|
{archived ? <Archive fontSize="small" color="disabled" /> : <KindIcon conversation={conversation} />}
|
|
88
95
|
</Badge>
|