@micha.bigler/ui-core-micha 2.21.0 → 2.21.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/WORK_ORDERS.md +2 -1
- package/dist/messaging/Composer.js +8 -3
- package/dist/messaging/ConversationList.js +12 -3
- package/dist/messaging/MessageBubble.js +1 -1
- package/dist/messaging/Thread.js +27 -2
- package/package.json +1 -1
- package/src/messaging/Composer.jsx +8 -3
- package/src/messaging/ConversationList.jsx +11 -3
- package/src/messaging/MessageBubble.jsx +2 -2
- package/src/messaging/Thread.jsx +42 -4
- package/tests/ConversationList.test.jsx +26 -0
- package/tests/Thread.test.jsx +51 -1
- package/tests/messagingInteractions.test.jsx +15 -0
package/WORK_ORDERS.md
CHANGED
|
@@ -34,4 +34,5 @@ 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-
|
|
37
|
+
| 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
|
+
| 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. |
|
|
@@ -21,9 +21,14 @@ function isImage(file) { var _a; return (_a = file.type) === null || _a === void
|
|
|
21
21
|
* client only offers the affordance). `linkTarget`'s VALUE is inherently
|
|
22
22
|
* app-specific navigation context (jg pre-fills it from the currently open
|
|
23
23
|
* event-info section) that ucm cannot compute itself, so the host supplies it.
|
|
24
|
+
* Broadcasting to a 1:1 direct conversation is never meaningful regardless of
|
|
25
|
+
* what the host passes for `allowAnnouncement` (typically an event-manager
|
|
26
|
+
* permission check with no notion of the currently open conversation's kind),
|
|
27
|
+
* so this is excluded here rather than relying on every host to remember it.
|
|
24
28
|
*/
|
|
25
|
-
export function Composer({ conversationId, replyTarget = null, onReplyTargetChange, allowAnnouncement = false, linkTarget }) {
|
|
29
|
+
export function Composer({ conversationId, conversation, replyTarget = null, onReplyTargetChange, allowAnnouncement = false, linkTarget }) {
|
|
26
30
|
var _a;
|
|
31
|
+
const canAnnounce = allowAnnouncement && (conversation === null || conversation === void 0 ? void 0 : conversation.kind) !== 'direct';
|
|
27
32
|
const { t } = useTranslation();
|
|
28
33
|
const { sendMessage, sendAttachments, createConversationPoll } = useMessaging();
|
|
29
34
|
const [body, setBody] = useState('');
|
|
@@ -149,8 +154,8 @@ export function Composer({ conversationId, replyTarget = null, onReplyTargetChan
|
|
|
149
154
|
setSending(false);
|
|
150
155
|
}
|
|
151
156
|
};
|
|
152
|
-
return _jsxs(Stack, { component: "form", spacing: 1, onSubmit: (event) => { event.preventDefault(); submit(); }, "aria-label": t('MessagingComposer.LABEL'), children: [replyTarget && _jsx(Alert, { severity: "info", onClose: () => onReplyTargetChange === null || onReplyTargetChange === void 0 ? void 0 : onReplyTargetChange(null), children: t('MessagingComposer.REPLYING_TO', { sender: ((_a = replyTarget.sender) === null || _a === void 0 ? void 0 : _a.display_name) || t('MessagingThread.UNKNOWN_SENDER') }) }), error && _jsx(Alert, { severity: "error", role: "alert", children: error }), files.length > 0 && _jsx(Stack, { direction: "row", spacing: 0.5, flexWrap: "wrap", "aria-label": t('MessagingComposer.STAGED_FILES'), children: files.map((file, index) => _jsxs(Stack, { direction: "row", alignItems: "center", spacing: 0.25, sx: { border: 1, borderColor: 'divider', borderRadius: 1, p: 0.25 }, children: [previews[index] && _jsx(Box, { component: "img", src: previews[index], alt: file.name, width: 36, height: 36, sx: { objectFit: 'cover' } }), _jsx(Typography, { variant: "caption", children: file.name }), _jsx(IconButton, { type: "button", size: "small", "aria-label": t('MessagingComposer.REMOVE_ATTACHMENT', { name: file.name }), onClick: () => removeFile(index), children: _jsx(CloseIcon, { fontSize: "inherit" }) })] }, `${file.name}-${index}`)) }), files.length > 0 && _jsx(Typography, { variant: "caption", children: t('MessagingComposer.FILES_SELECTED', { count: files.length }) }), _jsxs(Stack, { direction: "row", spacing: 1, alignItems: "flex-end", children: [_jsx("input", { ref: inputRef, hidden: true, type: "file", multiple: true, onChange: (event) => setFiles(Array.from(event.target.files || [])) }), _jsx(IconButton, { type: "button", "aria-label": t('MessagingComposer.ADD_ATTACHMENT'), onClick: () => { var _a; return (_a = inputRef.current) === null || _a === void 0 ? void 0 : _a.click(); }, children: _jsx(AttachFileIcon, {}) }), _jsx(IconButton, { type: "button", "aria-label": t('MessagingComposer.ADD_EMOJI'), onClick: (event) => setEmojiAnchor(event.currentTarget), children: _jsx(EmojiEmotionsOutlinedIcon, {}) }), _jsx(IconButton, { type: "button", "aria-label": t('MessagingPoll.CREATE'), disabled: pollOpen || files.some(isImage), onClick: () => setPollOpen(true), children: _jsx(PollOutlinedIcon, {}) }),
|
|
157
|
+
return _jsxs(Stack, { component: "form", spacing: 1, onSubmit: (event) => { event.preventDefault(); submit(); }, "aria-label": t('MessagingComposer.LABEL'), children: [replyTarget && _jsx(Alert, { severity: "info", onClose: () => onReplyTargetChange === null || onReplyTargetChange === void 0 ? void 0 : onReplyTargetChange(null), children: t('MessagingComposer.REPLYING_TO', { sender: ((_a = replyTarget.sender) === null || _a === void 0 ? void 0 : _a.display_name) || t('MessagingThread.UNKNOWN_SENDER') }) }), error && _jsx(Alert, { severity: "error", role: "alert", children: error }), files.length > 0 && _jsx(Stack, { direction: "row", spacing: 0.5, flexWrap: "wrap", "aria-label": t('MessagingComposer.STAGED_FILES'), children: files.map((file, index) => _jsxs(Stack, { direction: "row", alignItems: "center", spacing: 0.25, sx: { border: 1, borderColor: 'divider', borderRadius: 1, p: 0.25 }, children: [previews[index] && _jsx(Box, { component: "img", src: previews[index], alt: file.name, width: 36, height: 36, sx: { objectFit: 'cover' } }), _jsx(Typography, { variant: "caption", children: file.name }), _jsx(IconButton, { type: "button", size: "small", "aria-label": t('MessagingComposer.REMOVE_ATTACHMENT', { name: file.name }), onClick: () => removeFile(index), children: _jsx(CloseIcon, { fontSize: "inherit" }) })] }, `${file.name}-${index}`)) }), files.length > 0 && _jsx(Typography, { variant: "caption", children: t('MessagingComposer.FILES_SELECTED', { count: files.length }) }), _jsxs(Stack, { direction: "row", spacing: 1, alignItems: "flex-end", children: [_jsx("input", { ref: inputRef, hidden: true, type: "file", multiple: true, onChange: (event) => setFiles(Array.from(event.target.files || [])) }), _jsx(IconButton, { type: "button", "aria-label": t('MessagingComposer.ADD_ATTACHMENT'), onClick: () => { var _a; return (_a = inputRef.current) === null || _a === void 0 ? void 0 : _a.click(); }, children: _jsx(AttachFileIcon, {}) }), _jsx(IconButton, { type: "button", "aria-label": t('MessagingComposer.ADD_EMOJI'), onClick: (event) => setEmojiAnchor(event.currentTarget), children: _jsx(EmojiEmotionsOutlinedIcon, {}) }), _jsx(IconButton, { type: "button", "aria-label": t('MessagingPoll.CREATE'), disabled: pollOpen || files.some(isImage), onClick: () => setPollOpen(true), children: _jsx(PollOutlinedIcon, {}) }), canAnnounce && _jsx(IconButton, { type: "button", "aria-label": t('MessagingAnnouncement.CREATE'), onClick: () => setAnnouncementOpen(true), children: _jsx(CampaignOutlinedIcon, {}) }), _jsx(TextField, { inputRef: messageInputRef, fullWidth: true, multiline: true, minRows: 2, value: body, onChange: (event) => setBody(event.target.value), onKeyDown: (event) => { if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
|
|
153
158
|
event.preventDefault();
|
|
154
159
|
submit();
|
|
155
|
-
} }, label: t('MessagingComposer.MESSAGE'), disabled: sending }), _jsx(Button, { type: "submit", variant: "contained", disabled: sending || (!body.trim() && !files.length), children: t('MessagingComposer.SEND') })] }), sending && files.length > 0 && _jsxs(Stack, { spacing: 0.5, role: "status", children: [_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [uploadProgress == null ? _jsx(CircularProgress, { size: 16 }) : _jsx(CircularProgress, { size: 16, variant: "determinate", value: uploadProgress }), _jsx(Typography, { variant: "caption", children: t('MessagingComposer.UPLOADING') })] }), uploadProgress != null && _jsx(LinearProgress, { variant: "determinate", value: uploadProgress })] }), failed && _jsx(Button, { type: "button", onClick: () => submit(failed.requestId, true), disabled: sending, children: t('MessagingComposer.RETRY') }), _jsx(Menu, { anchorEl: emojiAnchor, open: Boolean(emojiAnchor), onClose: () => setEmojiAnchor(null), children: QUICK_EMOJIS.map((emoji) => _jsx(MenuItem, { onClick: () => insertEmoji(emoji), children: emoji }, emoji)) }), _jsxs(Dialog, { open: pollOpen, onClose: () => !sending && setPollOpen(false), fullWidth: true, maxWidth: "sm", children: [_jsx(DialogTitle, { children: t('MessagingPoll.CREATE') }), _jsx(DialogContent, { children: _jsxs(Stack, { spacing: 1, sx: { pt: 1 }, children: [_jsx(TextField, { label: t('MessagingPoll.QUESTION'), value: pollQuestion, onChange: (event) => setPollQuestion(event.target.value), autoFocus: true }), pollOptions.map((option, index) => _jsx(TextField, { label: t('MessagingPoll.OPTION', { count: index + 1 }), value: option, onChange: (event) => setPollOptions((current) => current.map((value, optionIndex) => optionIndex === index ? event.target.value : value)) }, index)), _jsx(Button, { type: "button", disabled: pollOptions.length >= 10, onClick: () => setPollOptions((current) => [...current, '']), children: t('MessagingPoll.ADD_OPTION') }), _jsx(FormControlLabel, { control: _jsx(Checkbox, { checked: allowMultiple, onChange: (event) => setAllowMultiple(event.target.checked) }), label: t('MessagingPoll.ALLOW_MULTIPLE') })] }) }), _jsxs(DialogActions, { children: [_jsx(Button, { onClick: () => setPollOpen(false), children: t('MessagingPoll.CANCEL') }), _jsx(Button, { variant: "contained", onClick: submitPoll, disabled: sending, children: t('MessagingPoll.CREATE') })] })] }),
|
|
160
|
+
} }, label: t('MessagingComposer.MESSAGE'), disabled: sending }), _jsx(Button, { type: "submit", variant: "contained", disabled: sending || (!body.trim() && !files.length), children: t('MessagingComposer.SEND') })] }), sending && files.length > 0 && _jsxs(Stack, { spacing: 0.5, role: "status", children: [_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [uploadProgress == null ? _jsx(CircularProgress, { size: 16 }) : _jsx(CircularProgress, { size: 16, variant: "determinate", value: uploadProgress }), _jsx(Typography, { variant: "caption", children: t('MessagingComposer.UPLOADING') })] }), uploadProgress != null && _jsx(LinearProgress, { variant: "determinate", value: uploadProgress })] }), failed && _jsx(Button, { type: "button", onClick: () => submit(failed.requestId, true), disabled: sending, children: t('MessagingComposer.RETRY') }), _jsx(Menu, { anchorEl: emojiAnchor, open: Boolean(emojiAnchor), onClose: () => setEmojiAnchor(null), children: QUICK_EMOJIS.map((emoji) => _jsx(MenuItem, { onClick: () => insertEmoji(emoji), children: emoji }, emoji)) }), _jsxs(Dialog, { open: pollOpen, onClose: () => !sending && setPollOpen(false), fullWidth: true, maxWidth: "sm", children: [_jsx(DialogTitle, { children: t('MessagingPoll.CREATE') }), _jsx(DialogContent, { children: _jsxs(Stack, { spacing: 1, sx: { pt: 1 }, children: [_jsx(TextField, { label: t('MessagingPoll.QUESTION'), value: pollQuestion, onChange: (event) => setPollQuestion(event.target.value), autoFocus: true }), pollOptions.map((option, index) => _jsx(TextField, { label: t('MessagingPoll.OPTION', { count: index + 1 }), value: option, onChange: (event) => setPollOptions((current) => current.map((value, optionIndex) => optionIndex === index ? event.target.value : value)) }, index)), _jsx(Button, { type: "button", disabled: pollOptions.length >= 10, onClick: () => setPollOptions((current) => [...current, '']), children: t('MessagingPoll.ADD_OPTION') }), _jsx(FormControlLabel, { control: _jsx(Checkbox, { checked: allowMultiple, onChange: (event) => setAllowMultiple(event.target.checked) }), label: t('MessagingPoll.ALLOW_MULTIPLE') })] }) }), _jsxs(DialogActions, { children: [_jsx(Button, { onClick: () => setPollOpen(false), children: t('MessagingPoll.CANCEL') }), _jsx(Button, { variant: "contained", onClick: submitPoll, disabled: sending, children: t('MessagingPoll.CREATE') })] })] }), canAnnounce && _jsxs(Dialog, { open: announcementOpen, onClose: () => !sending && setAnnouncementOpen(false), fullWidth: true, maxWidth: "sm", children: [_jsx(DialogTitle, { children: t('MessagingAnnouncement.CREATE') }), _jsx(DialogContent, { children: _jsxs(Stack, { spacing: 1, sx: { pt: 1 }, children: [_jsx(TextField, { label: t('MessagingAnnouncement.TITLE'), value: announcementTitle, onChange: (event) => setAnnouncementTitle(event.target.value), autoFocus: true }), _jsx(TextField, { label: t('MessagingAnnouncement.BODY'), value: announcementBody, onChange: (event) => setAnnouncementBody(event.target.value), multiline: true, minRows: 2 })] }) }), _jsxs(DialogActions, { children: [_jsx(Button, { onClick: () => setAnnouncementOpen(false), children: t('MessagingAnnouncement.CANCEL') }), _jsx(Button, { variant: "contained", onClick: submitAnnouncement, disabled: sending, children: t('MessagingAnnouncement.SEND') })] })] })] });
|
|
156
161
|
}
|
|
@@ -9,13 +9,22 @@ import { ConversationLaunchers } from './ConversationLaunchers';
|
|
|
9
9
|
// managed conversation — ucm must not know what any particular app's values
|
|
10
10
|
// mean, so a label for it is host-supplied only; resolveManagedLabel receives
|
|
11
11
|
// the raw conversation (including external_key) and owns all interpretation.
|
|
12
|
-
|
|
12
|
+
// dcm's `other_user_id` (added alongside this) is a bare id, deliberately never
|
|
13
|
+
// a resolved name — the same reason a message's `sender_id` carries no display
|
|
14
|
+
// name — so a direct conversation's counterpart name is host-supplied, exactly
|
|
15
|
+
// like a managed conversation's label above.
|
|
16
|
+
function titleOf(conversation, t, resolveManagedLabel, resolveDirectUserName) {
|
|
13
17
|
var _a, _b;
|
|
14
18
|
if (conversation.kind === 'managed' && resolveManagedLabel) {
|
|
15
19
|
const label = resolveManagedLabel(conversation);
|
|
16
20
|
if (label)
|
|
17
21
|
return label;
|
|
18
22
|
}
|
|
23
|
+
if (conversation.kind === 'direct' && conversation.other_user_id != null && resolveDirectUserName) {
|
|
24
|
+
const name = resolveDirectUserName(conversation.other_user_id);
|
|
25
|
+
if (name)
|
|
26
|
+
return name;
|
|
27
|
+
}
|
|
19
28
|
return conversation.title || ((_a = conversation.other_user) === null || _a === void 0 ? void 0 : _a.display_name) || ((_b = conversation.other_user) === null || _b === void 0 ? void 0 : _b.username) || t('MessagingList.UNTITLED');
|
|
20
29
|
}
|
|
21
30
|
function ordered(conversations) {
|
|
@@ -40,7 +49,7 @@ function KindIcon({ conversation }) {
|
|
|
40
49
|
return _jsx(Person, { fontSize: "small" });
|
|
41
50
|
}
|
|
42
51
|
/** A standalone, provider-backed list; scope picker metadata is supplied as launcher props. */
|
|
43
|
-
export function ConversationList({ onOpen, groupLaunchers, broadcastLauncher, autoOpenBroadcast = false, includeArchived = false, resolveManagedLabel }) {
|
|
52
|
+
export function ConversationList({ onOpen, groupLaunchers, broadcastLauncher, autoOpenBroadcast = false, includeArchived = false, resolveManagedLabel, resolveDirectUserName }) {
|
|
44
53
|
const { t, i18n } = useTranslation();
|
|
45
54
|
const { cache, activeConversationId, loadMoreConversations, setConversationArchived, setConversationPreferences, markConversationRead } = useMessaging();
|
|
46
55
|
const [menuConversation, setMenuConversation] = useState(null);
|
|
@@ -89,6 +98,6 @@ export function ConversationList({ onOpen, groupLaunchers, broadcastLauncher, au
|
|
|
89
98
|
var _a;
|
|
90
99
|
const unread = unreadFor(conversation);
|
|
91
100
|
const archived = Boolean(conversation.archived || conversation.archived_at);
|
|
92
|
-
return _jsxs(ListItemButton, { selected: conversation.id === activeConversationId, onClick: () => { markConversationRead(conversation.id).catch(() => { }); 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) }), 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);
|
|
101
|
+
return _jsxs(ListItemButton, { selected: conversation.id === activeConversationId, onClick: () => { markConversationRead(conversation.id).catch(() => { }); 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);
|
|
93
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')] })] })] }));
|
|
94
103
|
}
|
|
@@ -134,5 +134,5 @@ export function MessageBubble({ message, replyTo, conversation, onReply, onJumpT
|
|
|
134
134
|
overflowWrap: 'anywhere',
|
|
135
135
|
'& .message-actions': { opacity: 0, pointerEvents: 'none', transition: 'opacity 120ms ease-in-out' },
|
|
136
136
|
'&:hover .message-actions, &:focus-within .message-actions, &[data-actions-visible="true"] .message-actions': { opacity: 1, pointerEvents: 'auto' },
|
|
137
|
-
}, children: [canOpenMenu && _jsx(IconButton, { className: "message-actions", size: "small", "aria-label": t('MessagingActions.MENU'), onClick: openMenu, sx: { position: 'absolute', top: -12, right: -12, bgcolor: 'background.paper', boxShadow: 1 }, children: _jsx(MoreVertIcon, { fontSize: "small" }) }), _jsxs(Stack, { spacing: 0.75, children: [replyTo && _jsx(ButtonBase, { type: "button", onClick: () => onJumpToMessage === null || onJumpToMessage === void 0 ? void 0 : onJumpToMessage(replyTo.id), sx: { display: 'block', width: '100%', px: 0.75, py: 0.5, textAlign: 'left', borderLeft: 3, borderColor: 'primary.main', bgcolor: 'action.hover', borderRadius: 0.5 }, children: _jsxs(Stack, { spacing: 0, alignItems: "flex-start", children: [_jsx(Typography, { variant: "caption", color: "text.secondary", children: t('MessagingThread.REPLY_TO', { sender: senderName(replyTo) || t('MessagingThread.UNKNOWN_SENDER') }) }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: quoteLabel(replyTo, t) })] }) }), (conversation === null || conversation === void 0 ? void 0 : conversation.kind) !== 'direct' && _jsx(Typography, { variant: "subtitle2", children: senderName(message) || t('MessagingThread.UNKNOWN_SENDER') }), !deleted && message.kind === 'announcement' && message.title && _jsx(Typography, { variant: "subtitle1", fontWeight: 700, children: message.title }), !deleted && message.kind === 'announcement' && message.link_target && onAnnouncementLink && _jsx(Button, { type: "button", size: "small", onClick: () => onAnnouncementLink(message.link_target), children: t('MessagingAnnouncement.OPEN_LINK') }), error && _jsx(Alert, { severity: "error", role: "alert", children: error }), editing ? _jsxs(Stack, { spacing: 0.75, children: [_jsx(TextField, { label: t('MessagingActions.EDIT'), value: draft, onChange: (event) => setDraft(event.target.value), multiline: true, minRows: 2, autoFocus: true, disabled: saving }), _jsxs(Stack, { direction: "row", spacing: 1, children: [_jsx(Button, { type: "button", onClick: saveEdit, disabled: saving, children: t('MessagingActions.SAVE') }), _jsx(Button, { type: "button", onClick: () => setEditing(false), disabled: saving, children: t('MessagingActions.CANCEL') })] })] }) : _jsx(Typography, { sx: { whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }, children: deleted ? t('MessagingThread.DELETED') : (message.kind === 'announcement' ? message.body : (message.body || message.title)) || '' }), !deleted && _jsxs(Stack, { spacing: 0.75, children: [attachments.length > 0 && _jsx(AttachmentList, { attachments: attachments }), message.poll && _jsx(PollCard, { message: message })] }), hasMeta && _jsxs(Stack, { "data-testid": "message-meta", direction: "row", spacing: 0.5, justifyContent: "flex-end", alignItems: "center", sx: { color: 'text.disabled', '& .MuiSvgIcon-root': { fontSize: '1rem' } }, children: [message.created_at && _jsx(Typography, { component: "span", variant: "caption", color: "inherit", children: new Date(message.created_at).toLocaleString() }), 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') })] })] })] }));
|
|
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).toLocaleString() }), 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
|
}
|
package/dist/messaging/Thread.js
CHANGED
|
@@ -42,11 +42,12 @@ export function Thread({ conversationId, onReplyTargetChange, canModerateMessage
|
|
|
42
42
|
var _a;
|
|
43
43
|
const { t } = useTranslation();
|
|
44
44
|
const { user } = useContext(AuthContext) || {};
|
|
45
|
-
const { cache, loadMoreMessages, loadThreadReplies, markConversationRead, markThreadRead } = useMessaging();
|
|
45
|
+
const { cache, loadMoreMessages, loadThreadReplies, markConversationRead, markThreadRead, refreshThread } = useMessaging();
|
|
46
46
|
const scrollRef = useRef(null);
|
|
47
47
|
const wasNearBottomRef = useRef(true);
|
|
48
48
|
const messageCountRef = useRef(0);
|
|
49
49
|
const [loadingOlder, setLoadingOlder] = useState(false);
|
|
50
|
+
const [loadingInitial, setLoadingInitial] = useState(false);
|
|
50
51
|
const [error, setError] = useState(null);
|
|
51
52
|
const [replyingTo, setReplyingTo] = useState(null);
|
|
52
53
|
const [openThreads, setOpenThreads] = useState({});
|
|
@@ -56,6 +57,30 @@ export function Thread({ conversationId, onReplyTargetChange, canModerateMessage
|
|
|
56
57
|
useEffect(() => { if (conversationId != null)
|
|
57
58
|
markConversationRead(conversationId).catch(() => { }); }, [conversationId, markConversationRead]);
|
|
58
59
|
useEffect(() => { wasNearBottomRef.current = true; messageCountRef.current = 0; }, [conversationId]);
|
|
60
|
+
// Nothing else in this component ever fetches messages for the conversation
|
|
61
|
+
// actually being opened — `cache.messages` is otherwise only populated by a
|
|
62
|
+
// live realtime frame arriving while mounted, or this browser's own
|
|
63
|
+
// optimistic send. A conversation whose history predates this session (the
|
|
64
|
+
// common case — every DM someone didn't have open when a message arrived)
|
|
65
|
+
// rendered as permanently empty despite the conversation list correctly
|
|
66
|
+
// showing a last-message preview, which comes from a completely different
|
|
67
|
+
// request. Fetch on open, every time — a stale first page is worse than a
|
|
68
|
+
// redundant one.
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (conversationId == null)
|
|
71
|
+
return undefined;
|
|
72
|
+
let cancelled = false;
|
|
73
|
+
setLoadingInitial(true);
|
|
74
|
+
setError(null);
|
|
75
|
+
refreshThread(conversationId)
|
|
76
|
+
.catch(() => { if (!cancelled)
|
|
77
|
+
setError(t('MessagingThread.LOAD_ERROR')); })
|
|
78
|
+
.finally(() => { if (!cancelled)
|
|
79
|
+
setLoadingInitial(false); });
|
|
80
|
+
return () => { cancelled = true; };
|
|
81
|
+
// `t` deliberately not in deps: a language switch mid-flight must not re-trigger the fetch.
|
|
82
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
83
|
+
}, [conversationId, refreshThread]);
|
|
59
84
|
useEffect(() => {
|
|
60
85
|
var _a;
|
|
61
86
|
if (replyingTo && ((_a = cache.messages[replyingTo.id]) === null || _a === void 0 ? void 0 : _a.deleted_at)) {
|
|
@@ -114,7 +139,7 @@ export function Thread({ conversationId, onReplyTargetChange, canModerateMessage
|
|
|
114
139
|
}
|
|
115
140
|
setOpenThreads((current) => (Object.assign(Object.assign({}, current), { [root.id]: !current[root.id] })));
|
|
116
141
|
};
|
|
117
|
-
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', 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') }) }), !roots.length && _jsx(Typography, { color: "text.secondary", textAlign: "center", py: 4, children: t('MessagingThread.EMPTY') }), _jsx(Stack, { spacing: 1, children: roots.map((message) => {
|
|
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: roots.map((message) => {
|
|
118
143
|
const replies = chronological(Object.values(cache.messages).filter((item) => String(replyToId(item)) === String(message.id)));
|
|
119
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 && (() => {
|
|
120
145
|
const unread = hasUnreadReplies(message);
|
package/package.json
CHANGED
|
@@ -23,8 +23,13 @@ function isImage(file) { return file.type?.startsWith('image/'); }
|
|
|
23
23
|
* client only offers the affordance). `linkTarget`'s VALUE is inherently
|
|
24
24
|
* app-specific navigation context (jg pre-fills it from the currently open
|
|
25
25
|
* event-info section) that ucm cannot compute itself, so the host supplies it.
|
|
26
|
+
* Broadcasting to a 1:1 direct conversation is never meaningful regardless of
|
|
27
|
+
* what the host passes for `allowAnnouncement` (typically an event-manager
|
|
28
|
+
* permission check with no notion of the currently open conversation's kind),
|
|
29
|
+
* so this is excluded here rather than relying on every host to remember it.
|
|
26
30
|
*/
|
|
27
|
-
export function Composer({ conversationId, replyTarget = null, onReplyTargetChange, allowAnnouncement = false, linkTarget }) {
|
|
31
|
+
export function Composer({ conversationId, conversation, replyTarget = null, onReplyTargetChange, allowAnnouncement = false, linkTarget }) {
|
|
32
|
+
const canAnnounce = allowAnnouncement && conversation?.kind !== 'direct';
|
|
28
33
|
const { t } = useTranslation();
|
|
29
34
|
const { sendMessage, sendAttachments, createConversationPoll } = useMessaging();
|
|
30
35
|
const [body, setBody] = useState('');
|
|
@@ -123,7 +128,7 @@ export function Composer({ conversationId, replyTarget = null, onReplyTargetChan
|
|
|
123
128
|
<IconButton type="button" aria-label={t('MessagingComposer.ADD_ATTACHMENT')} onClick={() => inputRef.current?.click()}><AttachFileIcon /></IconButton>
|
|
124
129
|
<IconButton type="button" aria-label={t('MessagingComposer.ADD_EMOJI')} onClick={(event) => setEmojiAnchor(event.currentTarget)}><EmojiEmotionsOutlinedIcon /></IconButton>
|
|
125
130
|
<IconButton type="button" aria-label={t('MessagingPoll.CREATE')} disabled={pollOpen || files.some(isImage)} onClick={() => setPollOpen(true)}><PollOutlinedIcon /></IconButton>
|
|
126
|
-
{
|
|
131
|
+
{canAnnounce && <IconButton type="button" aria-label={t('MessagingAnnouncement.CREATE')} onClick={() => setAnnouncementOpen(true)}><CampaignOutlinedIcon /></IconButton>}
|
|
127
132
|
<TextField inputRef={messageInputRef} fullWidth multiline minRows={2} value={body} onChange={(event) => setBody(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); submit(); } }} label={t('MessagingComposer.MESSAGE')} disabled={sending} />
|
|
128
133
|
<Button type="submit" variant="contained" disabled={sending || (!body.trim() && !files.length)}>{t('MessagingComposer.SEND')}</Button>
|
|
129
134
|
</Stack>
|
|
@@ -146,7 +151,7 @@ export function Composer({ conversationId, replyTarget = null, onReplyTargetChan
|
|
|
146
151
|
<FormControlLabel control={<Checkbox checked={allowMultiple} onChange={(event) => setAllowMultiple(event.target.checked)} />} label={t('MessagingPoll.ALLOW_MULTIPLE')} />
|
|
147
152
|
</Stack></DialogContent><DialogActions><Button onClick={() => setPollOpen(false)}>{t('MessagingPoll.CANCEL')}</Button><Button variant="contained" onClick={submitPoll} disabled={sending}>{t('MessagingPoll.CREATE')}</Button></DialogActions>
|
|
148
153
|
</Dialog>
|
|
149
|
-
{
|
|
154
|
+
{canAnnounce && <Dialog open={announcementOpen} onClose={() => !sending && setAnnouncementOpen(false)} fullWidth maxWidth="sm">
|
|
150
155
|
<DialogTitle>{t('MessagingAnnouncement.CREATE')}</DialogTitle><DialogContent><Stack spacing={1} sx={{ pt: 1 }}>
|
|
151
156
|
<TextField label={t('MessagingAnnouncement.TITLE')} value={announcementTitle} onChange={(event) => setAnnouncementTitle(event.target.value)} autoFocus />
|
|
152
157
|
<TextField label={t('MessagingAnnouncement.BODY')} value={announcementBody} onChange={(event) => setAnnouncementBody(event.target.value)} multiline minRows={2} />
|
|
@@ -10,11 +10,19 @@ import { ConversationLaunchers } from './ConversationLaunchers';
|
|
|
10
10
|
// managed conversation — ucm must not know what any particular app's values
|
|
11
11
|
// mean, so a label for it is host-supplied only; resolveManagedLabel receives
|
|
12
12
|
// the raw conversation (including external_key) and owns all interpretation.
|
|
13
|
-
|
|
13
|
+
// dcm's `other_user_id` (added alongside this) is a bare id, deliberately never
|
|
14
|
+
// a resolved name — the same reason a message's `sender_id` carries no display
|
|
15
|
+
// name — so a direct conversation's counterpart name is host-supplied, exactly
|
|
16
|
+
// like a managed conversation's label above.
|
|
17
|
+
function titleOf(conversation, t, resolveManagedLabel, resolveDirectUserName) {
|
|
14
18
|
if (conversation.kind === 'managed' && resolveManagedLabel) {
|
|
15
19
|
const label = resolveManagedLabel(conversation);
|
|
16
20
|
if (label) return label;
|
|
17
21
|
}
|
|
22
|
+
if (conversation.kind === 'direct' && conversation.other_user_id != null && resolveDirectUserName) {
|
|
23
|
+
const name = resolveDirectUserName(conversation.other_user_id);
|
|
24
|
+
if (name) return name;
|
|
25
|
+
}
|
|
18
26
|
return conversation.title || conversation.other_user?.display_name || conversation.other_user?.username || t('MessagingList.UNTITLED');
|
|
19
27
|
}
|
|
20
28
|
function ordered(conversations) {
|
|
@@ -37,7 +45,7 @@ function KindIcon({ conversation }) {
|
|
|
37
45
|
}
|
|
38
46
|
|
|
39
47
|
/** A standalone, provider-backed list; scope picker metadata is supplied as launcher props. */
|
|
40
|
-
export function ConversationList({ onOpen, groupLaunchers, broadcastLauncher, autoOpenBroadcast = false, includeArchived = false, resolveManagedLabel }) {
|
|
48
|
+
export function ConversationList({ onOpen, groupLaunchers, broadcastLauncher, autoOpenBroadcast = false, includeArchived = false, resolveManagedLabel, resolveDirectUserName }) {
|
|
41
49
|
const { t, i18n } = useTranslation();
|
|
42
50
|
const { cache, activeConversationId, loadMoreConversations, setConversationArchived, setConversationPreferences, markConversationRead } = useMessaging();
|
|
43
51
|
const [menuConversation, setMenuConversation] = useState(null);
|
|
@@ -78,7 +86,7 @@ export function ConversationList({ onOpen, groupLaunchers, broadcastLauncher, au
|
|
|
78
86
|
<Badge color="error" badgeContent={unread || null} max={99} sx={{ mr: 2 }}>
|
|
79
87
|
{archived ? <Archive fontSize="small" color="disabled" /> : <KindIcon conversation={conversation} />}
|
|
80
88
|
</Badge>
|
|
81
|
-
<ListItemText primary={<Stack direction="row" spacing={1} justifyContent="space-between"><Typography component="span" fontWeight={unread ? 700 : 400} noWrap>{titleOf(conversation, t, resolveManagedLabel)}</Typography>{relativeTime(conversation.last_message_at, i18n?.language) && <Typography component="span" variant="caption" color="text.secondary" noWrap>{relativeTime(conversation.last_message_at, i18n?.language)}</Typography>}</Stack>} secondary={conversation.last_message?.excerpt || t('MessagingList.NO_MESSAGES')} secondaryTypographyProps={{ noWrap: true }} />
|
|
89
|
+
<ListItemText primary={<Stack direction="row" spacing={1} justifyContent="space-between"><Typography component="span" fontWeight={unread ? 700 : 400} noWrap>{titleOf(conversation, t, resolveManagedLabel, resolveDirectUserName)}</Typography>{relativeTime(conversation.last_message_at, i18n?.language) && <Typography component="span" variant="caption" color="text.secondary" noWrap>{relativeTime(conversation.last_message_at, i18n?.language)}</Typography>}</Stack>} secondary={conversation.last_message?.excerpt || t('MessagingList.NO_MESSAGES')} secondaryTypographyProps={{ noWrap: true }} />
|
|
82
90
|
<IconButton aria-label={t('MessagingList.ACTIONS')} onClick={(event) => { event.stopPropagation(); setMenuAnchor(event.currentTarget); setMenuConversation(conversation); }}><MoreVert /></IconButton>
|
|
83
91
|
</ListItemButton>;
|
|
84
92
|
})}
|
|
@@ -129,8 +129,8 @@ export function MessageBubble({ message, replyTo, conversation, onReply, onJumpT
|
|
|
129
129
|
{attachments.length > 0 && <AttachmentList attachments={attachments} />}
|
|
130
130
|
{message.poll && <PollCard message={message} />}
|
|
131
131
|
</Stack>}
|
|
132
|
-
{hasMeta && <Stack data-testid="message-meta" direction="row" spacing={0.5} justifyContent="flex-end" alignItems="center" sx={{ color: 'text.disabled', '& .MuiSvgIcon-root': { fontSize: '1rem' } }}>
|
|
133
|
-
{message.created_at && <Typography component="span" variant="caption" color="inherit">{new Date(message.created_at).toLocaleString()}</Typography>}
|
|
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).toLocaleString()}</Typography>}
|
|
134
134
|
{message.edited_at && !deleted && <Typography component="span" variant="caption" color="inherit">{t('MessagingThread.EDITED')}</Typography>}
|
|
135
135
|
{children}
|
|
136
136
|
</Stack>}
|
package/src/messaging/Thread.jsx
CHANGED
|
@@ -40,11 +40,12 @@ function canShowReadTicks(message, user) {
|
|
|
40
40
|
export function Thread({ conversationId, onReplyTargetChange, canModerateMessages = false, onAnnouncementLink }) {
|
|
41
41
|
const { t } = useTranslation();
|
|
42
42
|
const { user } = useContext(AuthContext) || {};
|
|
43
|
-
const { cache, loadMoreMessages, loadThreadReplies, markConversationRead, markThreadRead } = useMessaging();
|
|
43
|
+
const { cache, loadMoreMessages, loadThreadReplies, markConversationRead, markThreadRead, refreshThread } = useMessaging();
|
|
44
44
|
const scrollRef = useRef(null);
|
|
45
45
|
const wasNearBottomRef = useRef(true);
|
|
46
46
|
const messageCountRef = useRef(0);
|
|
47
47
|
const [loadingOlder, setLoadingOlder] = useState(false);
|
|
48
|
+
const [loadingInitial, setLoadingInitial] = useState(false);
|
|
48
49
|
const [error, setError] = useState(null);
|
|
49
50
|
const [replyingTo, setReplyingTo] = useState(null);
|
|
50
51
|
const [openThreads, setOpenThreads] = useState({});
|
|
@@ -54,6 +55,27 @@ export function Thread({ conversationId, onReplyTargetChange, canModerateMessage
|
|
|
54
55
|
|
|
55
56
|
useEffect(() => { if (conversationId != null) markConversationRead(conversationId).catch(() => {}); }, [conversationId, markConversationRead]);
|
|
56
57
|
useEffect(() => { wasNearBottomRef.current = true; messageCountRef.current = 0; }, [conversationId]);
|
|
58
|
+
// Nothing else in this component ever fetches messages for the conversation
|
|
59
|
+
// actually being opened — `cache.messages` is otherwise only populated by a
|
|
60
|
+
// live realtime frame arriving while mounted, or this browser's own
|
|
61
|
+
// optimistic send. A conversation whose history predates this session (the
|
|
62
|
+
// common case — every DM someone didn't have open when a message arrived)
|
|
63
|
+
// rendered as permanently empty despite the conversation list correctly
|
|
64
|
+
// showing a last-message preview, which comes from a completely different
|
|
65
|
+
// request. Fetch on open, every time — a stale first page is worse than a
|
|
66
|
+
// redundant one.
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (conversationId == null) return undefined;
|
|
69
|
+
let cancelled = false;
|
|
70
|
+
setLoadingInitial(true);
|
|
71
|
+
setError(null);
|
|
72
|
+
refreshThread(conversationId)
|
|
73
|
+
.catch(() => { if (!cancelled) setError(t('MessagingThread.LOAD_ERROR')); })
|
|
74
|
+
.finally(() => { if (!cancelled) setLoadingInitial(false); });
|
|
75
|
+
return () => { cancelled = true; };
|
|
76
|
+
// `t` deliberately not in deps: a language switch mid-flight must not re-trigger the fetch.
|
|
77
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
78
|
+
}, [conversationId, refreshThread]);
|
|
57
79
|
useEffect(() => {
|
|
58
80
|
if (replyingTo && cache.messages[replyingTo.id]?.deleted_at) {
|
|
59
81
|
setReplyingTo(null);
|
|
@@ -95,10 +117,26 @@ export function Thread({ conversationId, onReplyTargetChange, canModerateMessage
|
|
|
95
117
|
<Stack spacing={1} sx={{ minWidth: 0 }}>
|
|
96
118
|
{error && <Alert severity="error" role="alert">{error}</Alert>}
|
|
97
119
|
{replyingTo && <Alert severity="info" onClose={() => { setReplyingTo(null); onReplyTargetChange?.(null); }}>{t('MessagingThread.REPLYING_TO', { sender: replyingTo.sender?.display_name || t('MessagingThread.UNKNOWN_SENDER') })}</Alert>}
|
|
98
|
-
<Box ref={scrollRef} onScroll={onScroll} sx={{ maxHeight: 560, overflowY: 'auto', px: 0.5 }} aria-label={t('MessagingThread.TIMELINE')}>
|
|
120
|
+
<Box ref={scrollRef} onScroll={onScroll} sx={{ maxHeight: 560, overflowY: 'auto', overflowX: 'hidden', px: 0.5 }} aria-label={t('MessagingThread.TIMELINE')}>
|
|
99
121
|
{cache.cursors.messages[conversationId] && <Box textAlign="center" py={1}><Button onClick={loadOlder} disabled={loadingOlder}>{loadingOlder ? <CircularProgress size={18} /> : t('MessagingThread.LOAD_OLDER')}</Button></Box>}
|
|
100
|
-
{
|
|
101
|
-
|
|
122
|
+
{/* Only show the initial-load spinner when there's nothing to show yet
|
|
123
|
+
— re-opening a conversation whose messages are already cached (an
|
|
124
|
+
earlier visit this session, or a realtime frame that arrived while
|
|
125
|
+
a different conversation was open) still re-fetches in the
|
|
126
|
+
background, but the already-visible messages must not be replaced
|
|
127
|
+
by a spinner while that redundant refetch is in flight. */}
|
|
128
|
+
{loadingInitial && !roots.length ? <Box textAlign="center" py={4}><CircularProgress size={20} /></Box> : (!roots.length && <Typography color="text.secondary" textAlign="center" py={4}>{t('MessagingThread.EMPTY')}</Typography>)}
|
|
129
|
+
{/* pr wider than pl, on this Stack only (not the scroll container
|
|
130
|
+
above, which stays symmetric so the centered LOAD_OLDER button
|
|
131
|
+
isn't skewed): an own (right-aligned) bubble sits flush against
|
|
132
|
+
this Stack's right edge, and MessageBubble's hover/tap "more
|
|
133
|
+
actions" icon (MessageBubble.jsx, `top: -12, right: -12`) floats
|
|
134
|
+
12px past the bubble's own right edge — without enough padding to
|
|
135
|
+
absorb that overhang, the scroll container's overflowX: hidden
|
|
136
|
+
(the scrollbar fix above) would clip the icon whenever there's no
|
|
137
|
+
vertical scrollbar reserving extra width (a short conversation,
|
|
138
|
+
no vertical overflow). */}
|
|
139
|
+
<Stack spacing={1} sx={{ pr: 2 }}>{roots.map((message) => {
|
|
102
140
|
const replies = chronological(Object.values(cache.messages).filter((item) => String(replyToId(item)) === String(message.id)));
|
|
103
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>
|
|
104
142
|
{(message.reply_count || replies.length) > 0 && (() => {
|
|
@@ -53,6 +53,32 @@ describe('ConversationList', () => {
|
|
|
53
53
|
await waitFor(() => expect(screen.queryByRole('button', { name: 'Announcements' })).toBeNull());
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
+
it('resolves a direct conversation\'s title via the host-supplied resolveDirectUserName, falling back to the generic label when absent', async () => {
|
|
57
|
+
api.listConversations.mockResolvedValueOnce({
|
|
58
|
+
results: [
|
|
59
|
+
{ id: 6, kind: 'direct', other_user_id: 42, last_message: { excerpt: 'Hi' } },
|
|
60
|
+
{ id: 7, kind: 'direct', other_user_id: 99, last_message: { excerpt: 'Hey' } },
|
|
61
|
+
],
|
|
62
|
+
next_cursor: null,
|
|
63
|
+
});
|
|
64
|
+
const resolveDirectUserName = vi.fn((userId) => (userId === 42 ? 'Alex' : null));
|
|
65
|
+
renderList(api, { resolveDirectUserName });
|
|
66
|
+
await screen.findByText('Alex');
|
|
67
|
+
expect(resolveDirectUserName).toHaveBeenCalledWith(42);
|
|
68
|
+
expect(screen.getByText('MessagingList.UNTITLED')).toBeTruthy();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('does not call resolveDirectUserName for non-direct conversations', async () => {
|
|
72
|
+
api.listConversations.mockResolvedValueOnce({
|
|
73
|
+
results: [{ id: 8, kind: 'group', title: 'Ops', other_user_id: 42, last_message: { excerpt: 'Hi' } }],
|
|
74
|
+
next_cursor: null,
|
|
75
|
+
});
|
|
76
|
+
const resolveDirectUserName = vi.fn(() => 'Should not be used');
|
|
77
|
+
renderList(api, { resolveDirectUserName });
|
|
78
|
+
await screen.findByText('Ops');
|
|
79
|
+
expect(resolveDirectUserName).not.toHaveBeenCalled();
|
|
80
|
+
});
|
|
81
|
+
|
|
56
82
|
it('wires mute and archive through participant APIs', async () => {
|
|
57
83
|
renderList(api);
|
|
58
84
|
await screen.findByText('First');
|
package/tests/Thread.test.jsx
CHANGED
|
@@ -21,10 +21,16 @@ function makeApi() {
|
|
|
21
21
|
getReadStatus: vi.fn().mockResolvedValue({ all_read: false, delivered_count: 2 }),
|
|
22
22
|
};
|
|
23
23
|
}
|
|
24
|
+
// No `activeConversationId` on the provider: this matches how jg-ferien
|
|
25
|
+
// actually mounts it (the provider sits above the page that tracks which
|
|
26
|
+
// conversation is open, with no way to keep that prop in sync) — `Thread`'s
|
|
27
|
+
// own conversationId-driven fetch is the one exercised here, not the
|
|
28
|
+
// provider's separate activeConversationId-driven mount fetch (that path has
|
|
29
|
+
// its own dedicated coverage in MessagingProvider.test.jsx).
|
|
24
30
|
function renderThread(api, { userId = 9 } = {}) {
|
|
25
31
|
return render(
|
|
26
32
|
<AuthContext.Provider value={{ user: { id: userId } }}>
|
|
27
|
-
<MessagingProvider api={api}
|
|
33
|
+
<MessagingProvider api={api}><Thread conversationId={1} /></MessagingProvider>
|
|
28
34
|
</AuthContext.Provider>,
|
|
29
35
|
);
|
|
30
36
|
}
|
|
@@ -97,4 +103,48 @@ describe('messaging Thread surfaces', () => {
|
|
|
97
103
|
render(<MessagingProvider api={api} active={false}><ReadTicks messageId={2} conversation={{ id: 1, kind: 'group' }} /></MessagingProvider>);
|
|
98
104
|
await waitFor(() => expect(screen.getByLabelText('MessagingReadTicks.DELIVERED:2')).toBeTruthy());
|
|
99
105
|
});
|
|
106
|
+
|
|
107
|
+
it('fetches and shows a conversation\'s historical messages on open, with no realtime frame or optimistic send involved', async () => {
|
|
108
|
+
// The bug this pins: nothing else in Thread/MessagingProvider ever calls
|
|
109
|
+
// listMessages for the conversation actually opened when the host (like
|
|
110
|
+
// jg-ferien) never wires `activeConversationId` into the provider itself
|
|
111
|
+
// — cache.messages was otherwise only ever populated by a live frame or
|
|
112
|
+
// this browser's own optimistic send, so any conversation whose only
|
|
113
|
+
// messages predate the current session rendered as permanently empty
|
|
114
|
+
// despite the conversation list correctly showing a last-message preview.
|
|
115
|
+
renderThread(api);
|
|
116
|
+
expect(await screen.findByText('Visible message')).toBeTruthy();
|
|
117
|
+
expect(screen.queryByText('MessagingThread.EMPTY')).toBeNull();
|
|
118
|
+
expect(api.listMessages).toHaveBeenCalledWith(1);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('does not flash the empty state while the initial fetch is still pending', async () => {
|
|
122
|
+
let resolveMessages;
|
|
123
|
+
api.listMessages.mockReturnValueOnce(new Promise((resolve) => { resolveMessages = resolve; }));
|
|
124
|
+
renderThread(api);
|
|
125
|
+
expect(screen.queryByText('MessagingThread.EMPTY')).toBeNull();
|
|
126
|
+
resolveMessages({ results: [{ id: 2, conversation_id: 1, body: 'Visible message' }], next_cursor: null });
|
|
127
|
+
await screen.findByText('Visible message');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('does not cover already-cached messages with the initial-load spinner while a redundant re-fetch is in flight', async () => {
|
|
131
|
+
function Switcher({ conversationId }) {
|
|
132
|
+
return <AuthContext.Provider value={{ user: { id: 9 } }}><MessagingProvider api={api}><Thread conversationId={conversationId} /></MessagingProvider></AuthContext.Provider>;
|
|
133
|
+
}
|
|
134
|
+
// First open (conversation 1): messages load and cache normally.
|
|
135
|
+
const { rerender } = render(<Switcher conversationId={1} />);
|
|
136
|
+
await screen.findByText('Visible message');
|
|
137
|
+
// Switch away, then back to conversation 1 — the effect re-fires (a real
|
|
138
|
+
// "close and reopen" or focus-regain), triggering a redundant re-fetch
|
|
139
|
+
// against a cache that already has this conversation's messages. Make
|
|
140
|
+
// that second fetch (the one for the switch BACK to 1) hang so the
|
|
141
|
+
// assertion below is meaningful.
|
|
142
|
+
rerender(<Switcher conversationId={2} />);
|
|
143
|
+
let resolveSecondFetch;
|
|
144
|
+
api.listMessages.mockImplementationOnce(() => new Promise((resolve) => { resolveSecondFetch = resolve; }));
|
|
145
|
+
rerender(<Switcher conversationId={1} />);
|
|
146
|
+
expect(screen.getByText('Visible message')).toBeTruthy();
|
|
147
|
+
resolveSecondFetch({ results: [{ id: 2, conversation_id: 1, body: 'Visible message' }], next_cursor: null });
|
|
148
|
+
await waitFor(() => expect(screen.getByText('Visible message')).toBeTruthy());
|
|
149
|
+
});
|
|
100
150
|
});
|
|
@@ -92,6 +92,21 @@ describe('messaging reactions and polls', () => {
|
|
|
92
92
|
expect(screen.queryByRole('button', { name: 'MessagingAnnouncement.CREATE' })).toBeNull();
|
|
93
93
|
});
|
|
94
94
|
|
|
95
|
+
it('never offers announcement composing in a direct conversation, even when the host allows it', () => {
|
|
96
|
+
// A host's allowAnnouncement is typically an event-manager permission check
|
|
97
|
+
// with no notion of which conversation is currently open — broadcasting to
|
|
98
|
+
// a 1:1 direct chat is never meaningful regardless of that permission.
|
|
99
|
+
const api = baseApi();
|
|
100
|
+
render(<MessagingProvider api={api} active={false}><Composer conversationId={4} allowAnnouncement conversation={{ kind: 'direct' }} /></MessagingProvider>);
|
|
101
|
+
expect(screen.queryByRole('button', { name: 'MessagingAnnouncement.CREATE' })).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('still offers announcement composing for a non-direct conversation when the host allows it', () => {
|
|
105
|
+
const api = baseApi();
|
|
106
|
+
render(<MessagingProvider api={api} active={false}><Composer conversationId={4} allowAnnouncement conversation={{ kind: 'group' }} /></MessagingProvider>);
|
|
107
|
+
expect(screen.getByRole('button', { name: 'MessagingAnnouncement.CREATE' })).toBeTruthy();
|
|
108
|
+
});
|
|
109
|
+
|
|
95
110
|
it('disables the chip while pending, then rolls back and re-enables it on API failure', async () => {
|
|
96
111
|
let rejectAdd; const api = baseApi(); api.addReaction.mockReturnValue(new Promise((_, reject) => { rejectAdd = reject; }));
|
|
97
112
|
render(<MessagingProvider api={api} active={false}><ReactionBar message={{ id: 7, reactions: [{ emoji: '👍', count: 1, reacted: false }] }} /></MessagingProvider>);
|