@micha.bigler/ui-core-micha 2.20.0 → 2.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/WORK_ORDERS.md +2 -1
- package/dev/entries.jsx +12 -5
- package/dist/messaging/Composer.js +8 -3
- package/dist/messaging/ConversationList.js +12 -3
- package/dist/messaging/MessageBubble.js +46 -5
- package/dist/messaging/ReactionBar.js +7 -9
- package/dist/messaging/ReadTicks.js +6 -3
- package/dist/messaging/Thread.js +1 -1
- package/package.json +1 -1
- package/src/messaging/Composer.jsx +8 -3
- package/src/messaging/ConversationList.jsx +11 -3
- package/src/messaging/MessageBubble.jsx +82 -32
- package/src/messaging/ReactionBar.jsx +9 -12
- package/src/messaging/ReadTicks.jsx +6 -3
- package/src/messaging/Thread.jsx +12 -2
- package/tests/ConversationList.test.jsx +26 -0
- package/tests/messagingCompactBubble.test.jsx +62 -0
- package/tests/messagingInteractions.test.jsx +15 -0
- package/tests/messagingMessageActions.test.jsx +80 -10
- package/tests/messagingReactionMerge.test.jsx +3 -5
- package/tests/messagingThreadingQuoting.test.jsx +2 -1
- package/work-orders/MSG-3f.md +391 -0
package/WORK_ORDERS.md
CHANGED
|
@@ -33,4 +33,5 @@ prefixed ID; never reuse a bare flat number across workstreams.
|
|
|
33
33
|
| MSG-3b | Close the MSG-3 parity and contract gaps | Capability-checklist WO closing what MSG-3 missed: the entire unread lifecycle (mark-read never called — badges once raised never clear), message edit/delete + action menu, the DM launcher (`createDirectConversation` had no caller — the case MSG-2b unblocked in the backend), broken reply grouping (`serialize_message` emits `reply_to_id`, `Thread.jsx:40,62` reads `reply_to`, so every server-loaded reply renders as a root), quoted reply previews + jump-to-original, copy, client-side image compression, staged-image previews, upload progress, composer emoji insertion, relative timestamps, active-row highlight, announcement deep-link rendering. Plus a contract-conformance test and a corrected deviation list. | 2026-07-31 | done | c164db3, db8cd92, 61ed3b8, 7253172, fadb60a, 646fd01, 9748aca | Extends the landed MSG-3 (2.16.0), convention as MSG-2b/NOTIF-8b. Envelope: [work-orders/MSG-3b.md](work-orders/MSG-3b.md). Tier 2. **Origin: a three-way audit** (jg capability baseline / ucm wired-vs-adapter-only inventory / dcm contract-coverage sweep) run after MSG-3 was marked done. MSG-3 had 139/139 green, five per-chunk reviews and a clean `ui_reviewer`, and still missed all of it — **because both sides were tested against themselves**: dcm against its services, ucm against a mock whose surface mirrors the provider rather than the contract, so an endpoint with no caller is structurally invisible. Second cause: "no jg feature is lost" was made binding without anyone writing the feature list first, and the deviation list meant to prove parity was authored by the party doing the work; its "Final parity confirmation" section claims completeness and is demonstrably false. This WO's checklist IS the spec (59 rows, each OK/DEV/MISSING/BROKEN/PARTIAL/BLOCKED; the review corrected one false OK — row 5, unread never live-updates at all); the contract-conformance test is the systemic fix. **Rows 38, 51-53, 56-58 are BLOCKED on dcm** (`MSG-2c`, not yet cut): no `serialize_poll` exists at all so `PollCard` reads fields no endpoint returns — and the design specifies no poll read path either, so `messaging-platform.md` must be amended first; `services.py` has 3 `_publish` call sites for 12 designed frames, so reaction/poll live sync cannot work; `serialize_conversation` omits `last_message`, so the list preview is permanently empty. Carries `ui_reviewer` at WO end, which must check the corrected deviation list against the checklist row by row. **All 6 chunks implemented, independently reviewed and committed** (chunk 1: unread lifecycle end-to-end + contract-conformance test, own-fix for a markThreadRead failure silently blocking thread-open; chunk 2: `reply_to`/`reply_to_id` fix + quoting/jump-to-original, row 27 found and recorded BLOCKED (no dcm thread-receipt read path) after an independent-review P1 caught it being silently dropped first time; chunk 3: message action menu/edit/delete/copy with a host-supplied moderator capability prop (dcm exposes no server-side capability field), fixed an undefined-vs-undefined author-match bug; chunk 4: `DirectMessageLauncher` wiring `createDirectConversation`, fixed jest-dom-matcher and MUI-dialog-transition-timing test issues after Codex's own verification pass died without a `RESULT:` line; chunk 5: composer shortcuts/previews/compression/emoji, fixed Codex settling for indeterminate-only upload progress by threading real `onUploadProgress` through the adapter as the WO asked, fixed missing diacritics in new translations (same bug class caught once before in MSG-3), corrected an unverified poll/attachment-mutual-exclusivity claim before it could land in the deviation doc; chunk 6: list/timeline polish (relative timestamps, auto-scroll, announcement links, DM sender-suppression, read-receipt popover) plus the full deviation-doc rewrite against all 59 checklist rows — fixed a repeat of an already-once-fixed raw-interactive-element pattern, corrected an overclaiming doc line, fixed a stale-ref edge case in auto-scroll). Row 42 (managed all-vs-team) also verified genuinely BLOCKED — dcm has `external_key` but never serializes it. **WO-end `ui_reviewer`** (independent, row-by-row cross-check of `docs/messaging-deviations.md` against landed code for all 59 checklist rows, per the WO's binding "the review that signed off the false completeness claim is the one this WO exists to correct" instruction): no findings — every OK/DEV/BLOCKED row verified against actual code, not against the doc's own claims; translation coverage, DX-1 harness coverage, design-system/dark-mode/reuse all checked clean. Published: **ucm 2.17.0** (npm, CI-gated on push to `main`, `9748aca`). No CHANGELOG.md in this repo (same as NOTIF-12/NOTIF-13/MSG-3) — documented via commit messages + this register entry only. No app pin bumps in this WO. |
|
|
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
|
-
| 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 |
|
|
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-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. |
|
package/dev/entries.jsx
CHANGED
|
@@ -62,7 +62,7 @@ const messagingHarnessApi = {
|
|
|
62
62
|
listConversations: async ({ cursor } = {}) => cursor
|
|
63
63
|
? ({ results: [{ id: 13, title: 'Archived planning', last_message_at: '2026-07-30T09:00:00Z' }], next_cursor: null })
|
|
64
64
|
: ({ results: [
|
|
65
|
-
{ id: 12, title: 'Support group', unread_count: 1, last_message_at: '2026-07-31T09:00:00Z', last_message: { excerpt: 'Can someone help?' } },
|
|
65
|
+
{ id: 12, kind: 'group', title: 'Support group', unread_count: 1, last_message_at: '2026-07-31T09:00:00Z', last_message: { excerpt: 'Can someone help?' } },
|
|
66
66
|
// Row 42 (MSG-3d): two managed conversations, distinguishable only via
|
|
67
67
|
// external_key — ucm renders whatever ConversationListEntry's
|
|
68
68
|
// resolveManagedLabel resolver below returns, never the raw value.
|
|
@@ -74,8 +74,15 @@ const messagingHarnessApi = {
|
|
|
74
74
|
// reply_count with no thread_last_read_at (MSG-3d row 27): the unread-reply
|
|
75
75
|
// marker on the thread toggle should render visibly by default here.
|
|
76
76
|
listMessages: async (conversationId, { cursor } = {}) => cursor
|
|
77
|
-
? ({ results: [{ id: 42, conversation_id: conversationId, body: 'An older message', created_at: '2026-07-30T09:00:00Z' }], next_cursor: null })
|
|
78
|
-
: ({ results: [
|
|
77
|
+
? ({ results: [{ id: 42, conversation_id: conversationId, body: 'An older message', created_at: '2026-07-30T09:00:00Z', sender: { id: 2, display_name: 'Alex' } }], next_cursor: null })
|
|
78
|
+
: ({ results: [
|
|
79
|
+
{ id: 44, conversation_id: conversationId, body: 'Hallo', created_at: '2026-07-31T09:00:00Z', sender: { id: 1, display_name: 'Harness user' }, reply_count: 1, last_reply_at: '2026-07-31T09:05:00Z', thread_last_read_at: null },
|
|
80
|
+
{ id: 46, conversation_id: conversationId, body: 'A short incoming message', created_at: '2026-07-31T09:01:00Z', sender: { id: 2, display_name: 'Alex' } },
|
|
81
|
+
{ id: 47, conversation_id: conversationId, body: 'averylongunbrokensinglemessagetokenthatshouldwrapcleanlyonasmallviewportandatbrowserzoomwithoutforcingthebubblepastitscap', created_at: '2026-07-31T09:02:00Z', sender: { id: 2, display_name: 'Alex' } },
|
|
82
|
+
{ id: 48, conversation_id: conversationId, kind: 'announcement', title: 'Schedule update', body: 'The meeting starts at 18:30.', link_target: '/events/12', created_at: '2026-07-31T09:03:00Z', sender: { id: 2, display_name: 'Alex' } },
|
|
83
|
+
{ id: 49, conversation_id: conversationId, kind: 'poll', body: 'Poll', poll: { id: 8, question: 'Where should we meet?', options: [{ id: 1, text: 'Library', vote_count: 2 }, { id: 2, text: 'Café', vote_count: 3 }], allow_multiple: false }, created_at: '2026-07-31T09:04:00Z', sender: { id: 2, display_name: 'Alex' } },
|
|
84
|
+
{ id: 50, conversation_id: conversationId, body: 'Attached notes', attachments: [{ id: 5, filename: 'notes.pdf', content_type: 'application/pdf' }], created_at: '2026-07-31T09:05:00Z', sender: { id: 1, display_name: 'Harness user' } },
|
|
85
|
+
], next_cursor: 'signed-older-page' }),
|
|
79
86
|
listThread: async (rootId) => ({ results: [{ id: 45, reply_to: rootId, body: 'A reply', created_at: '2026-07-31T09:05:00Z', sender: { display_name: 'Sam' } }], next_cursor: null }),
|
|
80
87
|
getReadStatus: async () => ({ all_read: false, delivered_count: 2 }),
|
|
81
88
|
getUnreadCount: async () => ({ unread_count: 1, by_conversation: { 12: 1 } }),
|
|
@@ -136,8 +143,8 @@ function ConversationListEntry() {
|
|
|
136
143
|
);
|
|
137
144
|
}
|
|
138
145
|
|
|
139
|
-
function ThreadEntry() { return <MessagingProvider api={messagingHarnessApi} activeConversationId={12}><Thread conversationId={12} /></MessagingProvider>; }
|
|
140
|
-
function MessageBubbleEntry() { return <MessagingProvider api={messagingHarnessApi} active={false}><MessageBubble message={{ id: 44, body: '
|
|
146
|
+
function ThreadEntry() { return <AuthContext.Provider value={{ user: { id: 1, display_name: 'Harness user' } }}><MessagingProvider api={messagingHarnessApi} activeConversationId={12}><Thread conversationId={12} onAnnouncementLink={(target) => window.alert(`Open ${target}`)} /></MessagingProvider></AuthContext.Provider>; }
|
|
147
|
+
function MessageBubbleEntry() { return <AuthContext.Provider value={{ user: { id: 1, display_name: 'Harness user' } }}><MessagingProvider api={messagingHarnessApi} active={false}><Stack spacing={1} alignItems="stretch"><MessageBubble message={{ id: 44, body: 'Hallo', sender: { id: 1, display_name: 'Harness user' }, created_at: '2026-07-31T09:00:00Z', reactions: [{ emoji: '👍', count: 1, reacted: false }] }} conversation={{ kind: 'group' }} onReply={() => window.alert('Reply requested')}><ReadTicks messageId={44} conversation={{ kind: 'group' }} /></MessageBubble><MessageBubble message={{ id: 45, body: 'Incoming direct message', sender: { id: 2, display_name: 'Alex' }, created_at: '2026-07-31T09:01:00Z' }} conversation={{ kind: 'direct' }} onReply={() => window.alert('Reply requested')} /></Stack></MessagingProvider></AuthContext.Provider>; }
|
|
141
148
|
function ConversationLaunchersEntry() { return <MessagingProvider api={messagingHarnessApi} active={false}><ConversationLaunchers groupLaunchers={[{ id: 'volunteers', label: 'Open volunteers group', payload: { title: 'Volunteers', participant_ids: [1, 2] } }]} broadcastLauncher={{ label: 'Open announcements', payload: { scope: { kind: 'global' } } }} onOpen={(conversation) => window.alert(`Opened: ${conversation.title}`)} /></MessagingProvider>; }
|
|
142
149
|
function DirectMessageLauncherEntry() { return <MessagingProvider api={messagingHarnessApi} active={false}><DirectMessageLauncher candidates={[{ id: 2, display_name: 'Alex' }, { id: 3, display_name: 'Sam' }]} onOpen={(conversation) => window.alert(`Opened: ${conversation.title}`)} /></MessagingProvider>; }
|
|
143
150
|
function ReadTicksEntry() { return <MessagingProvider api={messagingHarnessApi} active={false}><ReadTicks messageId={44} conversation={{ id: 12, kind: 'group' }} /></MessagingProvider>; }
|
|
@@ -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
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Menu, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material';
|
|
2
|
+
import { Alert, Box, Button, ButtonBase, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Menu, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material';
|
|
3
3
|
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
|
4
|
-
import {
|
|
4
|
+
import { alpha } from '@mui/material/styles';
|
|
5
|
+
import { useEffect, useRef, useState } from 'react';
|
|
5
6
|
import { useTranslation } from 'react-i18next';
|
|
6
7
|
import { AttachmentList } from './AttachmentList';
|
|
7
8
|
import { extractApiErrorMessage, useOptionalMessaging } from './MessagingProvider';
|
|
@@ -39,23 +40,56 @@ export function MessageBubble({ message, replyTo, conversation, onReply, onJumpT
|
|
|
39
40
|
const { currentUser, editMessage, removeMessage } = messaging || {};
|
|
40
41
|
const deleted = Boolean(message.deleted_at);
|
|
41
42
|
const attachments = message.attachments || [];
|
|
42
|
-
const
|
|
43
|
+
const isOwn = Boolean(currentUser === null || currentUser === void 0 ? void 0 : currentUser.id) && ((_a = message.sender) === null || _a === void 0 ? void 0 : _a.id) === currentUser.id;
|
|
44
|
+
const canAct = !deleted && message.kind !== 'poll' && (isOwn || canModerateMessages);
|
|
45
|
+
const canOpenMenu = !deleted;
|
|
46
|
+
const reactions = message.reactions || [];
|
|
47
|
+
const hasMeta = Boolean(message.created_at || (message.edited_at && !deleted) || children);
|
|
43
48
|
const [menuAnchor, setMenuAnchor] = useState(null);
|
|
49
|
+
const [actionsVisible, setActionsVisible] = useState(false);
|
|
50
|
+
const [reactionPickerVisible, setReactionPickerVisible] = useState(false);
|
|
44
51
|
const [editing, setEditing] = useState(false);
|
|
45
52
|
const [draft, setDraft] = useState(message.body || '');
|
|
46
53
|
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
|
47
54
|
const [saving, setSaving] = useState(false);
|
|
48
55
|
const [error, setError] = useState(null);
|
|
56
|
+
const bubbleRef = useRef(null);
|
|
49
57
|
useEffect(() => {
|
|
50
58
|
if (deleted) {
|
|
51
59
|
setEditing(false);
|
|
52
60
|
setConfirmingDelete(false);
|
|
53
61
|
setMenuAnchor(null);
|
|
62
|
+
setReactionPickerVisible(false);
|
|
54
63
|
}
|
|
55
64
|
}, [deleted]);
|
|
65
|
+
// The touch/tap reveal is a one-way state flip with no dismiss path of its
|
|
66
|
+
// own (unlike hover/focus, which un-reveal on mouseleave/blur automatically)
|
|
67
|
+
// — without this, any ordinary tap on the bubble (selecting text, opening a
|
|
68
|
+
// poll option, following the reply quote) would leave the overflow icon
|
|
69
|
+
// permanently visible, contradicting the at-rest "no visible affordance"
|
|
70
|
+
// requirement. Dismiss on any pointerdown outside this message's own DOM
|
|
71
|
+
// subtree (bubbleRef spans the Paper plus the reaction-picker sibling, so
|
|
72
|
+
// interacting with either never counts as "outside"); a pointerdown that
|
|
73
|
+
// lands on the Menu/Dialog portals does count as outside, but that's
|
|
74
|
+
// harmless — they already close themselves via their own handlers on the
|
|
75
|
+
// same interaction.
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!actionsVisible)
|
|
78
|
+
return undefined;
|
|
79
|
+
const dismissIfOutside = (event) => {
|
|
80
|
+
if (bubbleRef.current && !bubbleRef.current.contains(event.target)) {
|
|
81
|
+
setActionsVisible(false);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
document.addEventListener('pointerdown', dismissIfOutside, true);
|
|
85
|
+
return () => document.removeEventListener('pointerdown', dismissIfOutside, true);
|
|
86
|
+
}, [actionsVisible]);
|
|
56
87
|
const closeMenu = () => setMenuAnchor(null);
|
|
57
|
-
const
|
|
88
|
+
const revealActions = () => setActionsVisible(true);
|
|
89
|
+
const openMenu = (event) => { event.preventDefault(); revealActions(); setMenuAnchor(event.currentTarget); };
|
|
58
90
|
const startEdit = () => { setDraft(message.body || ''); setEditing(true); closeMenu(); };
|
|
91
|
+
const startReply = () => { onReply === null || onReply === void 0 ? void 0 : onReply(message); closeMenu(); };
|
|
92
|
+
const startReact = () => { setReactionPickerVisible(true); closeMenu(); };
|
|
59
93
|
const saveEdit = async () => {
|
|
60
94
|
setSaving(true);
|
|
61
95
|
setError(null);
|
|
@@ -93,5 +127,12 @@ export function MessageBubble({ message, replyTo, conversation, onReply, onJumpT
|
|
|
93
127
|
setError(t('MessagingActions.COPY_ERROR', { message: extractApiErrorMessage(copyError) }));
|
|
94
128
|
}
|
|
95
129
|
};
|
|
96
|
-
return (_jsxs(
|
|
130
|
+
return (_jsxs(Box, { ref: bubbleRef, component: "article", "data-message-id": message.id, "data-message-side": isOwn ? 'own' : 'incoming', "aria-label": t('MessagingThread.MESSAGE'), sx: { alignSelf: isOwn ? 'flex-end' : 'flex-start', width: 'fit-content', maxWidth: 'min(75%, 680px)', minWidth: 0 }, children: [_jsxs(Paper, { variant: "outlined", "data-actions-visible": actionsVisible || undefined, onClick: revealActions, onContextMenu: canOpenMenu ? openMenu : undefined, sx: {
|
|
131
|
+
position: 'relative', p: 1.25, minWidth: 0,
|
|
132
|
+
bgcolor: isOwn ? (theme) => alpha(theme.palette.primary.light, 0.24) : 'background.paper',
|
|
133
|
+
borderColor: isOwn ? (theme) => alpha(theme.palette.primary.main, 0.35) : 'divider',
|
|
134
|
+
overflowWrap: 'anywhere',
|
|
135
|
+
'& .message-actions': { opacity: 0, pointerEvents: 'none', transition: 'opacity 120ms ease-in-out' },
|
|
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).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') })] })] })] }));
|
|
97
138
|
}
|
|
@@ -1,22 +1,20 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import
|
|
3
|
-
import { Alert, Chip, IconButton, Stack } from '@mui/material';
|
|
2
|
+
import { Alert, Chip, Stack } from '@mui/material';
|
|
4
3
|
import { useState } from 'react';
|
|
5
4
|
import { useTranslation } from 'react-i18next';
|
|
6
5
|
import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
|
|
7
6
|
export const QUICK_EMOJIS = ['👍', '❤️', '😂', '🎉', '👀'];
|
|
8
|
-
/**
|
|
9
|
-
export function ReactionBar({ message }) {
|
|
7
|
+
/** Compact aggregate reactions; MessageBubble opens the quick picker from its action menu. */
|
|
8
|
+
export function ReactionBar({ message, expanded = false, onExpandedChange, sx }) {
|
|
10
9
|
const { t } = useTranslation();
|
|
11
10
|
const { toggleReaction } = useMessaging();
|
|
12
|
-
const [expanded, setExpanded] = useState(false);
|
|
13
11
|
const [error, setError] = useState(null);
|
|
14
12
|
// Tracks in-flight emoji toggles for this message so a rapid double-click
|
|
15
13
|
// can't fire two concurrent requests for the same emoji — toggleReaction's
|
|
16
14
|
// rollback-on-failure reverts to a pre-optimistic snapshot, which would
|
|
17
15
|
// silently discard a second, already-succeeded change if both were in
|
|
18
|
-
// flight
|
|
19
|
-
//
|
|
16
|
+
// flight. Disabling the chip while its own request is pending removes the
|
|
17
|
+
// race instead of trying to make concurrent toggles safe.
|
|
20
18
|
const [pending, setPending] = useState(() => new Set());
|
|
21
19
|
const reactions = (message === null || message === void 0 ? void 0 : message.reactions) || [];
|
|
22
20
|
const react = async (emoji, active = false) => {
|
|
@@ -34,11 +32,11 @@ export function ReactionBar({ message }) {
|
|
|
34
32
|
setPending((current) => { const next = new Set(current); next.delete(emoji); return next; });
|
|
35
33
|
}
|
|
36
34
|
};
|
|
37
|
-
return _jsxs(Stack, { spacing: 0.5, "aria-label": t('MessagingReactions.LABEL'), children: [
|
|
35
|
+
return _jsxs(Stack, { spacing: 0.5, "aria-label": t('MessagingReactions.LABEL'), sx: sx, children: [reactions.length > 0 && _jsx(Stack, { direction: "row", spacing: 0.5, flexWrap: "wrap", children: reactions.map((reaction) => _jsx(Chip, { size: "small", disabled: pending.has(reaction.emoji), color: reaction.reacted ? 'primary' : 'default', label: `${reaction.emoji} ${reaction.count || 0}`, onClick: () => react(reaction.emoji, Boolean(reaction.reacted)), "aria-label": t('MessagingReactions.TOGGLE', { emoji: reaction.emoji, count: reaction.count || 0 }) }, reaction.emoji)) }), expanded && _jsx(Stack, { direction: "row", spacing: 0.25, flexWrap: "wrap", children: QUICK_EMOJIS.map((emoji) => {
|
|
38
36
|
// A quick-emoji already reflected in the aggregate row must toggle it
|
|
39
37
|
// off, not re-add it — otherwise clicking an already-reacted emoji here
|
|
40
38
|
// optimistically double-increments the count before the API call.
|
|
41
39
|
const existing = reactions.find((reaction) => reaction.emoji === emoji);
|
|
42
|
-
return _jsx(Chip, { size: "small", disabled: pending.has(emoji), color: (existing === null || existing === void 0 ? void 0 : existing.reacted) ? 'primary' : 'default', label: emoji, onClick: () => { react(emoji, Boolean(existing === null || existing === void 0 ? void 0 : existing.reacted));
|
|
40
|
+
return _jsx(Chip, { size: "small", disabled: pending.has(emoji), color: (existing === null || existing === void 0 ? void 0 : existing.reacted) ? 'primary' : 'default', label: emoji, onClick: () => { react(emoji, Boolean(existing === null || existing === void 0 ? void 0 : existing.reacted)); onExpandedChange === null || onExpandedChange === void 0 ? void 0 : onExpandedChange(false); } }, emoji);
|
|
43
41
|
}) }), error && _jsx(Alert, { severity: "error", role: "alert", children: error })] });
|
|
44
42
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import
|
|
2
|
+
import DoneAllOutlinedIcon from '@mui/icons-material/DoneAllOutlined';
|
|
3
|
+
import DoneOutlinedIcon from '@mui/icons-material/DoneOutlined';
|
|
4
|
+
import { ButtonBase, List, ListItem, Popover } from '@mui/material';
|
|
3
5
|
import { useEffect, useState } from 'react';
|
|
4
6
|
import { useTranslation } from 'react-i18next';
|
|
5
7
|
import { useMessaging } from './MessagingProvider';
|
|
@@ -19,10 +21,11 @@ export function ReadTicks({ messageId, conversation }) {
|
|
|
19
21
|
if (!status)
|
|
20
22
|
return null;
|
|
21
23
|
const label = status.all_read ? t('MessagingReadTicks.ALL_READ') : t('MessagingReadTicks.DELIVERED', { count: status.delivered_count || 0 });
|
|
24
|
+
const StatusIcon = status.all_read ? DoneAllOutlinedIcon : DoneOutlinedIcon;
|
|
22
25
|
// The server never returns DM detail; enforce the same carve-out defensively.
|
|
23
26
|
const detail = (conversation === null || conversation === void 0 ? void 0 : conversation.kind) === 'direct' ? null : status.recipient_detail;
|
|
24
27
|
const recipients = (detail === null || detail === void 0 ? void 0 : detail.map((recipient) => recipient.display_name || recipient.username || recipient.user_id).filter(Boolean)) || [];
|
|
25
28
|
if (!recipients.length)
|
|
26
|
-
return _jsx(
|
|
27
|
-
return _jsxs(_Fragment, { children: [_jsx(ButtonBase, { "aria-label": label, "aria-haspopup": "dialog", "aria-expanded": Boolean(anchorEl), onClick: (event) => setAnchorEl(event.currentTarget), sx: {
|
|
29
|
+
return _jsx(StatusIcon, { role: "img", "aria-label": label, color: status.all_read ? 'primary' : 'inherit' });
|
|
30
|
+
return _jsxs(_Fragment, { children: [_jsx(ButtonBase, { "aria-label": label, "aria-haspopup": "dialog", "aria-expanded": Boolean(anchorEl), onClick: (event) => setAnchorEl(event.currentTarget), sx: { display: 'inline-flex', color: status.all_read ? 'primary.main' : 'inherit' }, children: _jsx(StatusIcon, { "aria-hidden": "true" }) }), _jsx(Popover, { open: Boolean(anchorEl), anchorEl: anchorEl, onClose: () => setAnchorEl(null), children: _jsx(List, { "aria-label": t('MessagingReadTicks.RECIPIENTS'), children: recipients.map((recipient) => _jsx(ListItem, { children: recipient }, recipient)) }) })] });
|
|
28
31
|
}
|
package/dist/messaging/Thread.js
CHANGED
|
@@ -114,7 +114,7 @@ export function Thread({ conversationId, onReplyTargetChange, canModerateMessage
|
|
|
114
114
|
}
|
|
115
115
|
setOpenThreads((current) => (Object.assign(Object.assign({}, current), { [root.id]: !current[root.id] })));
|
|
116
116
|
};
|
|
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) => {
|
|
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', 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') }) }), !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
118
|
const replies = chronological(Object.values(cache.messages).filter((item) => String(replyToId(item)) === String(message.id)));
|
|
119
119
|
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
120
|
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
|
})}
|