@micha.bigler/ui-core-micha 2.16.0 → 2.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/WORK_ORDERS.md +2 -1
  2. package/dev/entries.jsx +4 -0
  3. package/dist/i18n/messagingTranslations.js +26 -0
  4. package/dist/index.js +1 -0
  5. package/dist/messaging/Composer.js +41 -5
  6. package/dist/messaging/ConversationList.js +10 -3
  7. package/dist/messaging/DirectMessageLauncher.js +48 -0
  8. package/dist/messaging/MessageBubble.js +84 -4
  9. package/dist/messaging/MessagingProvider.js +62 -16
  10. package/dist/messaging/ReactionBar.js +1 -1
  11. package/dist/messaging/ReadTicks.js +7 -4
  12. package/dist/messaging/Thread.js +45 -8
  13. package/dist/messaging/api.js +3 -3
  14. package/dist/messaging/composerImageCompression.js +36 -0
  15. package/docs/messaging-deviations.md +91 -26
  16. package/package.json +1 -1
  17. package/src/i18n/messagingTranslations.ts +26 -0
  18. package/src/index.js +1 -0
  19. package/src/messaging/Composer.jsx +54 -8
  20. package/src/messaging/ConversationList.jsx +10 -4
  21. package/src/messaging/DirectMessageLauncher.jsx +96 -0
  22. package/src/messaging/MessageBubble.jsx +74 -7
  23. package/src/messaging/MessagingProvider.jsx +83 -11
  24. package/src/messaging/ReactionBar.jsx +1 -1
  25. package/src/messaging/ReadTicks.jsx +5 -3
  26. package/src/messaging/Thread.jsx +39 -8
  27. package/src/messaging/api.js +3 -2
  28. package/src/messaging/composerImageCompression.js +35 -0
  29. package/tests/Thread.test.jsx +1 -1
  30. package/tests/messagingChunk6.test.jsx +109 -0
  31. package/tests/messagingComposerChunk5.test.jsx +110 -0
  32. package/tests/messagingContractConformance.test.js +42 -0
  33. package/tests/messagingDirectMessageLauncher.test.jsx +78 -0
  34. package/tests/messagingExports.test.js +2 -1
  35. package/tests/messagingMessageActions.test.jsx +93 -0
  36. package/tests/messagingThreadingQuoting.test.jsx +86 -0
  37. package/tests/messagingUnreadLifecycle.test.jsx +63 -0
  38. package/work-orders/MSG-3b.md +1002 -0
package/WORK_ORDERS.md CHANGED
@@ -29,4 +29,5 @@ prefixed ID; never reuse a bare flat number across workstreams.
29
29
  | NOTIF-13 | Layer-1 transport extraction (pluggable realtime) | Extract the socket out of `NotificationsProvider` into a generic **subscribe-by-envelope** realtime primitive (shipped API: `useRealtime() -> subscribe(envelope, handler)`; the WO said "by message-type", but routing is on the `envelope` discriminator, deliberately NOT on the overloaded `type` field — that overload was the bug) so a second domain (messaging) can ride the same socket; the catch-all that turns every non-`notification.status` message into a feed entry is replaced by explicit dispatch with unknown types ignored. Additive: `NotificationsProvider` stays the sole mount point, its context value unchanged. Full spec: [work-orders/NOTIF-13.md](work-orders/NOTIF-13.md). | 2026-07-29 | done | ucm: 7a83ee9; dcm: de77335 | Tier 2 (cockpit consumes `NotificationsProvider` at 2.12.0 in prod). Phase A, the hinge: unblocks NOTIF-15 (jg bell adoption) and is the substrate for Phase B messaging. Canonical register row: django-core-micha `WORK_ORDERS.md`. Operator decision 2026-07-29: additive only, NO standalone `RealtimeProvider` apps must mount. Scope E (envelope contract + helper + doc) also touches dcm; note that the roadmap wording "dcm multi-stream consumer" overstates it — `push_to_users`/`NotificationConsumer` are already stream-agnostic. Codex failed twice on this WO with a genuine CLI/model-version error (`gpt-5.6-terra requires a newer version of Codex`), not a transient issue — orchestrator fell back to direct Claude implementation per the Codex→Claude fallback rule. Independent `reviewer` (sec_reviewer not required per WO): found R1 (P2, real bug — decoupling the REST seed from the socket connect opened a race where a live push arriving before the initial feed loaded would be silently discarded by `refresh()`'s unconditional `replaceNotifications()`; fixed via a `seeded` state gate restoring the exact original seed-then-connect ordering, re-verified 70/70 green), R2 (P3 — the WO's "must be called out in the CHANGELOG" has no home since ucm has no CHANGELOG.md convention at all; operator decision: document via commit message + this register entry only, no new file), R3 (register housekeeping, closed by this update). ucm full suite 70/70 + `pnpm build` clean; dcm notifications suite 92/92, full dcm suite 392/392. Published: ucm 2.13.0 (npm), dcm 2.33.0 (PyPI, verified live before this record) — no app pin bumps in this WO per spec. |
30
30
  | NOTIF-12 | Popup channel via the wizard renderer | Make `popup` a real delivery channel: extract the presentational dialog shell out of `OnboardingWizard`, render popup-eligible notifications through it, and record seen/dismissed on `NotificationRecipient` (design D-F7: renderer reuse only, NOT the onboarding progress store); dcm `PopupDispatcher` stops being a logging stub. Full spec: [work-orders/NOTIF-12.md](work-orders/NOTIF-12.md). | 2026-07-29 | done | ucm: c8e222f, d7bdcbe; dcm: django-core-micha 1612429, 82fd89f | Tier 2 (extracts a renderer cockpit+jg run in production; the roadmap label "uncritical" is about urgency, not blast radius). **HARD GATE: starts only after NOTIF-13 landed + published** — consumes its envelope contract and `subscribe()`, and edits the same ucm files (AGENTS.md overlap rule). **SHIPS WITH ZERO PRODUCERS, deliberately** (operator decision 2026-07-29, made with the evidence): no notification type declares `popup` in `eligible_channels` anywhere in dcm/jg/cockpit/hram/spesix/survey_app, so the channel is wired but inactive until an app opts a type in. WO therefore requires (a) CHANGELOG wording that does not overclaim (NOTIF-4 R1 precedent) and (b) a test-local producer type proving the path end-to-end, so it is not untested dead code. Was NOTIF-11 (renumbered 2026-07-29 for the jg cutover/drop split). **Gate satisfied 2026-07-30** (ucm 2.13.0 / dcm 2.33.0); WO re-verified against the landed NOTIF-13 code and corrected in `345631d`: NOTIF-13 shipped the envelope as a **domain**-level discriminator, so a popup payload built the same way as a chip payload would be indistinguishable from it - the WO now mandates a `channel` field inside the envelope (never a second envelope value) plus feed de-duplication by `notification_id`, without which chip+popup double-counts the unread badge. **Codex failed twice with the same genuine CLI/model-version error as NOTIF-13** (`gpt-5.6-terra requires a newer version of Codex`) — orchestrator implemented directly in Claude per the Codex→Claude fallback rule, both scopes. Independent `reviewer` (background agent, `fork_context=false`, full diff of both repos): found R1 (P1, real bug — the WS payload only carried `notification_id`, but `feed/mark/` resolves ids against `NotificationRecipient.pk`; every `markSeen`/`markDismissed` from a session-live-only popup would either no-op or corrupt an unrelated recipient row once a producer opts in — fixed by adding `recipient_id` to both `ChipDispatcher` and `PopupDispatcher` payloads, dcm-side, with regression tests), R2 (P2 — the reused dialog shell always showed the onboarding "Setup"/step-counter header even for a popup, a real mislabeling once any producer exists — fixed via an optional `title` override on `WizardDialogShell`, defaulting to the prior exact text so `OnboardingWizard` stays bit-for-bit unchanged; popup now shows its own notification title), R3 (P2 — coexistence only gated on a *blocking* onboarding step, so a non-blocking step (e.g. `browser_push`) and a popup could stack two simultaneous MUI dialogs — fixed by broadening the gate to any active onboarding step, with a new regression test). All three fixed and re-verified: ucm 88/88 vitest + `pnpm`/tsc build clean, dcm 95/95 pytest (notifications package). Published (once pushed): ucm 2.14.0 (npm, CI-gated on push to `main`), dcm 2.34.0 (PyPI, same). No app pin bumps in this WO per spec. Own orchestrator security/exposure pass in parallel with the reviewer: no findings (content rendering follows the existing `NotificationBell` i18n-interpolation pattern, no new WS consumer, `prefs.py`/`router.py`/`resolve_channels` untouched). |
31
31
  | DX-1 | Minimal Vite dev harness for ucm components | Give ucm a way to **render** its components during development: a `dev` script starting a Vite dev page that mounts surfaces with the repo's MUI theme, i18n and router context, driven by fixtures through an injectable mock API/realtime adapter, with light/dark and viewport switching. Harness code stays out of the published package (not exported, excluded from the build, zero runtime deps). | 2026-07-31 | done | 7ea7b26 | New `DX-*` prefix (ucm-local). Envelope: [work-orders/DX-1.md](work-orders/DX-1.md). Tier 1, but **adds devDependencies → approval-gated**; operator approval granted 2026-07-31 as part of the MSG-3 scoping decision. Gap found while scoping MSG-3: `package.json` had only `build` (tsc) and `test` (vitest) — no Storybook, no demo, no dev page, so every ucm surface ever shipped (auth, onboarding, notifications, charts) was verified only in jsdom or after a consuming app pinned it. Storybook deliberately not chosen (config surface vs. a repo with no story-writing habit). **Runs before MSG-3**, which is ~3700 LOC of UI with no consumer until MSG-5 and an operator-granted redesign licence that is only iterable if the result can be looked at. No version bump, no publish (dev-only). **Implemented (Codex, one pass):** `pnpm dev` (`vite`) boots a harness (`dev/main.jsx`) with MUI theme/i18n/router, light/dark + mobile/tablet/desktop switching, entries for a notifications surface (`NotificationBell`) and a standalone chart (`BarChart`, proving single-component mounting), plus a dedicated realtime-adapter demo entry. Mock transport (`dev/mockTransport.jsx`) overrides `apiClient`'s axios adapter for REST and provides a `RealtimeContext.Provider` for context-consuming components. Independent `reviewer`: one P2 — `NotificationsProvider` opens its own WebSocket internally rather than reading one from context, so the notifications entry opened a real, always-failing socket; fixed by the orchestrator with a `MockWebSocket` class registered as `window.WebSocket` while the harness is mounted, covering both consumption paths. Verified in the browser (both entries, dispatch button, no console errors/WS connection attempts). Build clean, `dist/` unaffected, `dependencies`/`peerDependencies` untouched (only `devDependencies` + `scripts.dev`), 104/104 tests green. |
32
- | MSG-3 | ucm messaging surfaces (full parity) | `MessagingProvider` + API adapter + normalized cache, `ConversationList`/`Thread`/`Composer`/`ReadTicks`/`ReactionBar`/`PollCard`/`AttachmentList` + config/preferences and launchers, on the Layer-1 transport (`subscribe('messaging', …)`, `event_id` dedup, reconnect refetch); opaque-cursor pagination with reverse infinite scroll and optimistic send from day one; de/en/fr. Host apps supply routing/display/scope pickers, not a forked state machine. | 2026-07-31 | in-review | affa5ac, 5e87315, a12c9c6, c7100b1, 95b69d4 | ucm-side mirror of the canonical dcm `MSG-3` row. Envelope: [work-orders/MSG-3.md](work-orders/MSG-3.md). Tier 2, 5 proposed chunks (register's "3-5" treated as a floor — jg's parity target is ~3700 LOC excluding tests, `Thread.jsx` alone 2470). Two operator decisions 2026-07-31: (1) **redesign permitted** for layout/composition/interaction, but the design's "No jg feature is lost" stays binding and the WO must ship a written **deviation list** — consequence: MSG-5 becomes a visible change for jg users and needs its own UX review; (2) validation runs through the **DX-1** harness, which is therefore a precondition. Carries `ui_reviewer` at WO end in addition to the per-chunk independent `reviewer`. **Decomposition is a binding requirement, not a preference:** jg's UI is one 2470-LOC component with a 2173-LOC test file, and reproducing that shape is a scope violation even with every feature present — each named component is separately exported, independently mountable and independently testable, state lives in the provider rather than being prop-drilled through `Thread`, every component gets its own DX-1 harness entry (the forcing function — a monolith cannot be mounted piecewise), and any component file above ~400 LOC must be justified in that chunk's review. Depends on dcm 2.36.1 (MSG-2 + MSG-2b). **All 5 chunks implemented, independently reviewed and committed** (chunk 1: additive Layer-1 `onReconnect` extension per operator decision, provider/cache/transport; chunk 2: conversation list + launchers, R1 menu-anchor fix, R2 per-kind icon, R3 broadcast-launcher visibility; chunk 3: Thread/MessageBubble/ReadTicks, R1 N+1 ReadTicks gating fix; chunk 4: Composer/attachments, R1 `extractApiErrorMessage` for dcm's `{files:[...]}` shape, R2 optimistic sender, R3 own-message pending-ReadTicks fix, R4 i18n diacritics; chunk 5: reactions/polls/scope-config/announcements, R1-R4 + a follow-up verification pass V1-V3 including building jg's previously-missing `AnnouncementDialog` parity per explicit operator decision). Status `in-review`, not yet `done`: WO-end `ui_reviewer` pass and the version-bump/publish step are still outstanding. |
32
+ | MSG-3 | ucm messaging surfaces (full parity) | `MessagingProvider` + API adapter + normalized cache, `ConversationList`/`Thread`/`Composer`/`ReadTicks`/`ReactionBar`/`PollCard`/`AttachmentList` + config/preferences and launchers, on the Layer-1 transport (`subscribe('messaging', …)`, `event_id` dedup, reconnect refetch); opaque-cursor pagination with reverse infinite scroll and optimistic send from day one; de/en/fr. Host apps supply routing/display/scope pickers, not a forked state machine. | 2026-07-31 | done | affa5ac, 5e87315, a12c9c6, c7100b1, 95b69d4, 974e24c, 4369400 | ucm-side mirror of the canonical dcm `MSG-3` row. Envelope: [work-orders/MSG-3.md](work-orders/MSG-3.md). Tier 2, 5 proposed chunks (register's "3-5" treated as a floor — jg's parity target is ~3700 LOC excluding tests, `Thread.jsx` alone 2470). Two operator decisions 2026-07-31: (1) **redesign permitted** for layout/composition/interaction, but the design's "No jg feature is lost" stays binding and the WO must ship a written **deviation list** — consequence: MSG-5 becomes a visible change for jg users and needs its own UX review; (2) validation runs through the **DX-1** harness, which is therefore a precondition. Carries `ui_reviewer` at WO end in addition to the per-chunk independent `reviewer`. **Decomposition is a binding requirement, not a preference:** jg's UI is one 2470-LOC component with a 2173-LOC test file, and reproducing that shape is a scope violation even with every feature present — each named component is separately exported, independently mountable and independently testable, state lives in the provider rather than being prop-drilled through `Thread`, every component gets its own DX-1 harness entry (the forcing function — a monolith cannot be mounted piecewise), and any component file above ~400 LOC must be justified in that chunk's review. Depends on dcm 2.36.1 (MSG-2 + MSG-2b). **All 5 chunks implemented, independently reviewed and committed** (chunk 1: additive Layer-1 `onReconnect` extension per operator decision, provider/cache/transport; chunk 2: conversation list + launchers, R1 menu-anchor fix, R2 per-kind icon, R3 broadcast-launcher visibility; chunk 3: Thread/MessageBubble/ReadTicks, R1 N+1 ReadTicks gating fix; chunk 4: Composer/attachments, R1 `extractApiErrorMessage` for dcm's `{files:[...]}` shape, R2 optimistic sender, R3 own-message pending-ReadTicks fix, R4 i18n diacritics; chunk 5: reactions/polls/scope-config/announcements, R1-R4 + a follow-up verification pass V1-V3 including building jg's previously-missing `AnnouncementDialog` parity per explicit operator decision). **WO-end `ui_reviewer`** (independent, full assembled feature not one chunk's diff): found U1-U5, all P2/P3, none blocking — raw-`<button>` reply action instead of MUI `Button` (U1), PollCard packed into the same wrapping flex row as reaction chips instead of its own full-width row (U2), MessagingScopeConfig's text-only loading state instead of the package's `CircularProgress` convention (U3), a raw inline-style attachment thumbnail instead of theme spacing (U4), and `MessageBubble`/`ConversationLaunchers` missing their required DX-1 harness entries (U5). All five fixed by the orchestrator (`974e24c`), re-verified 139/139 tests + clean build. Translation coverage, dark-mode tokens, deviation-doc accuracy and primitive reuse checked clean, no findings. Published: **ucm 2.16.0** (npm, CI-gated on push to `main`, `4369400`). No CHANGELOG.md in this repo (same as noted in NOTIF-13/NOTIF-12) — documented via commit messages + this register entry only. No app pin bumps in this WO. |
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 | in-review | c164db3, db8cd92, 61ed3b8, 7253172, fadb60a, 646fd01 | 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. Status `in-review`, not yet `done`: WO-end `ui_reviewer` pass and the version-bump/publish step are still outstanding. |
package/dev/entries.jsx CHANGED
@@ -10,6 +10,7 @@ import { useRealtime } from '../src/notifications/realtime';
10
10
  import { MessagingProvider, useMessaging } from '../src/messaging/MessagingProvider';
11
11
  import { ConversationList } from '../src/messaging/ConversationList';
12
12
  import { ConversationLaunchers } from '../src/messaging/ConversationLaunchers';
13
+ import { DirectMessageLauncher } from '../src/messaging/DirectMessageLauncher';
13
14
  import { Thread } from '../src/messaging/Thread';
14
15
  import { MessageBubble } from '../src/messaging/MessageBubble';
15
16
  import { ReadTicks } from '../src/messaging/ReadTicks';
@@ -72,6 +73,7 @@ const messagingHarnessApi = {
72
73
  archiveConversation: async (id, archived) => ({ id, archived }),
73
74
  patchConversationPreferences: async (id, patch) => ({ id, ...patch }),
74
75
  createGroupConversation: async (payload) => ({ id: 20, title: payload.title || 'Opened group', kind: 'group' }),
76
+ createDirectConversation: async (payload) => ({ id: 21, title: `Direct message with ${payload.target_user_id}`, kind: 'direct' }),
75
77
  createBroadcastConversation: async () => ({ id: 21, title: 'Announcements', kind: 'broadcast' }),
76
78
  createMessage: async (conversationId, payload) => ({ id: 46, conversation_id: conversationId, body: payload.body, client_request_id: payload.client_request_id, sender: { display_name: 'Harness user' } }),
77
79
  uploadAttachments: async (conversationId, formData) => ({ id: 47, conversation_id: conversationId, body: formData.get('body'), attachments: [{ id: 5, filename: 'example.png', content_type: 'image/png' }] }),
@@ -122,6 +124,7 @@ function ConversationListEntry() {
122
124
  function ThreadEntry() { return <MessagingProvider api={messagingHarnessApi} activeConversationId={12}><Thread conversationId={12} /></MessagingProvider>; }
123
125
  function MessageBubbleEntry() { return <MessagingProvider api={messagingHarnessApi} active={false}><MessageBubble message={{ id: 44, body: 'Standalone bubble mount', sender: { display_name: 'Alex' }, created_at: '2026-07-31T09:00:00Z', reactions: [{ emoji: '👍', count: 1, reacted: false }] }} onReply={() => window.alert('Reply requested')} /></MessagingProvider>; }
124
126
  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>; }
127
+ 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>; }
125
128
  function ReadTicksEntry() { return <MessagingProvider api={messagingHarnessApi} active={false}><ReadTicks messageId={44} conversation={{ id: 12, kind: 'group' }} /></MessagingProvider>; }
126
129
  function ComposerEntry() { return <MessagingProvider api={messagingHarnessApi} active={false}><Composer conversationId={12} replyTarget={{ id: 44, sender: { display_name: 'Alex' } }} allowAnnouncement linkTarget="/events/12/info" /></MessagingProvider>; }
127
130
  function AttachmentListEntry() { return <MessagingProvider api={messagingHarnessApi} active={false}><AttachmentList attachments={[{ id: 5, filename: 'example.png', content_type: 'image/png' }, { id: 6, filename: 'notes.pdf', content_type: 'application/pdf' }]} /></MessagingProvider>; }
@@ -138,6 +141,7 @@ export const entries = [
138
141
  { id: 'messaging-thread', label: 'Messaging / Thread', Component: ThreadEntry },
139
142
  { id: 'messaging-message-bubble', label: 'Messaging / Message bubble', Component: MessageBubbleEntry },
140
143
  { id: 'messaging-conversation-launchers', label: 'Messaging / Conversation launchers', Component: ConversationLaunchersEntry },
144
+ { id: 'messaging-direct-message-launcher', label: 'Messaging / Direct message launcher', Component: DirectMessageLauncherEntry },
141
145
  { id: 'messaging-read-ticks', label: 'Messaging / Read ticks', Component: ReadTicksEntry },
142
146
  { id: 'messaging-composer', label: 'Messaging / Composer', Component: ComposerEntry },
143
147
  { id: 'messaging-attachment-list', label: 'Messaging / Attachment list', Component: AttachmentListEntry },
@@ -1,4 +1,8 @@
1
1
  export const messagingTranslations = {
2
+ 'MessagingComposer.ADD_EMOJI': { de: 'Emoji hinzufügen', en: 'Add emoji', fr: 'Ajouter un emoji' },
3
+ 'MessagingComposer.STAGED_FILES': { de: 'Vorgemerkte Anhänge', en: 'Staged attachments', fr: 'Pièces jointes préparées' },
4
+ 'MessagingComposer.REMOVE_ATTACHMENT': { de: '{{name}} entfernen', en: 'Remove {{name}}', fr: 'Supprimer {{name}}' },
5
+ 'MessagingComposer.UPLOADING': { de: 'Anhänge werden hochgeladen…', en: 'Uploading attachments…', fr: 'Téléversement des pièces jointes…' },
2
6
  'MessagingList.LABEL': { de: 'Unterhaltungen', en: 'Conversations', fr: 'Conversations' },
3
7
  'MessagingList.EMPTY': { de: 'Noch keine Unterhaltungen.', en: 'No conversations yet.', fr: 'Aucune conversation pour le moment.' },
4
8
  'MessagingList.NO_MESSAGES': { de: 'Noch keine Nachrichten', en: 'No messages yet', fr: 'Aucun message pour le moment' },
@@ -28,8 +32,29 @@ export const messagingTranslations = {
28
32
  'MessagingThread.SHOW_REPLIES': { de: '{{count}} Antworten anzeigen', en: 'Show {{count}} replies', fr: 'Afficher {{count}} réponses' },
29
33
  'MessagingThread.HIDE_REPLIES': { de: 'Antworten ausblenden', en: 'Hide replies', fr: 'Masquer les réponses' },
30
34
  'MessagingThread.UNKNOWN_SENDER': { de: 'Unbekannte Person', en: 'Unknown person', fr: 'Personne inconnue' },
35
+ 'MessagingThread.QUOTE_ATTACHMENT': { de: 'Anhang', en: 'Attachment', fr: 'Pièce jointe' },
36
+ 'MessagingActions.MENU': { de: 'Nachrichtenaktionen', en: 'Message actions', fr: 'Actions du message' },
37
+ 'MessagingActions.EDIT': { de: 'Bearbeiten', en: 'Edit', fr: 'Modifier' },
38
+ 'MessagingActions.SAVE': { de: 'Speichern', en: 'Save', fr: 'Enregistrer' },
39
+ 'MessagingActions.CANCEL': { de: 'Abbrechen', en: 'Cancel', fr: 'Annuler' },
40
+ 'MessagingActions.DELETE': { de: 'Löschen', en: 'Delete', fr: 'Supprimer' },
41
+ 'MessagingActions.COPY': { de: 'Kopieren', en: 'Copy', fr: 'Copier' },
42
+ 'MessagingActions.DELETE_CONFIRM_TITLE': { de: 'Nachricht löschen?', en: 'Delete message?', fr: 'Supprimer le message ?' },
43
+ 'MessagingActions.DELETE_CONFIRM_BODY': { de: 'Diese Nachricht wird für alle entfernt.', en: 'This message will be removed for everyone.', fr: 'Ce message sera supprimé pour tout le monde.' },
44
+ 'MessagingActions.EDIT_ERROR': { de: 'Nachricht konnte nicht bearbeitet werden: {{message}}', en: 'Message could not be edited: {{message}}', fr: 'Impossible de modifier le message : {{message}}' },
45
+ 'MessagingActions.DELETE_ERROR': { de: 'Nachricht konnte nicht gelöscht werden: {{message}}', en: 'Message could not be deleted: {{message}}', fr: 'Impossible de supprimer le message : {{message}}' },
46
+ 'MessagingActions.COPY_ERROR': { de: 'Nachricht konnte nicht kopiert werden: {{message}}', en: 'Message could not be copied: {{message}}', fr: 'Impossible de copier le message : {{message}}' },
47
+ 'MessagingDirect.LAUNCH': { de: 'Direktnachricht starten', en: 'Start direct message', fr: 'Démarrer un message direct' },
48
+ 'MessagingDirect.TITLE': { de: 'Direktnachricht starten', en: 'Start direct message', fr: 'Démarrer un message direct' },
49
+ 'MessagingDirect.CANDIDATES': { de: 'Empfängerinnen und Empfänger', en: 'Recipients', fr: 'Destinataires' },
50
+ 'MessagingDirect.EMPTY': { de: 'Keine Personen verfügbar.', en: 'No people are available.', fr: 'Aucune personne n’est disponible.' },
51
+ 'MessagingDirect.START': { de: 'Starten', en: 'Start', fr: 'Démarrer' },
52
+ 'MessagingDirect.STARTING': { de: 'Direktnachricht wird gestartet…', en: 'Starting direct message…', fr: 'Démarrage du message direct…' },
53
+ 'MessagingDirect.CANCEL': { de: 'Abbrechen', en: 'Cancel', fr: 'Annuler' },
54
+ 'MessagingDirect.START_ERROR': { de: 'Direktnachricht konnte nicht gestartet werden.', en: 'Direct message could not be started.', fr: 'Impossible de démarrer le message direct.' },
31
55
  'MessagingReadTicks.ALL_READ': { de: 'Von allen gelesen', en: 'Read by everyone', fr: 'Lu par tout le monde' },
32
56
  'MessagingReadTicks.DELIVERED': { de: 'Zugestellt an {{count}}', en: 'Delivered to {{count}}', fr: 'Distribué à {{count}}' },
57
+ 'MessagingReadTicks.RECIPIENTS': { de: 'Lesebestätigungen', en: 'Read recipients', fr: 'Destinataires lus' },
33
58
  'MessagingComposer.LABEL': { de: 'Nachricht verfassen', en: 'Compose message', fr: 'Rédiger un message' },
34
59
  'MessagingComposer.MESSAGE': { de: 'Nachricht', en: 'Message', fr: 'Message' },
35
60
  'MessagingComposer.SEND': { de: 'Senden', en: 'Send', fr: 'Envoyer' },
@@ -78,4 +103,5 @@ export const messagingTranslations = {
78
103
  'MessagingAnnouncement.CANCEL': { de: 'Abbrechen', en: 'Cancel', fr: 'Annuler' },
79
104
  'MessagingAnnouncement.VALIDATION': { de: 'Ein Titel ist erforderlich.', en: 'A title is required.', fr: 'Un titre est requis.' },
80
105
  'MessagingAnnouncement.ERROR': { de: 'Ankündigung konnte nicht gesendet werden: {{message}}', en: 'Announcement could not be sent: {{message}}', fr: 'Impossible d’envoyer l’annonce : {{message}}' },
106
+ 'MessagingAnnouncement.OPEN_LINK': { de: 'Link öffnen', en: 'Open link', fr: 'Ouvrir le lien' },
81
107
  };
package/dist/index.js CHANGED
@@ -48,6 +48,7 @@ export * from './messaging/api';
48
48
  export { MessagingProvider, useMessaging } from './messaging/MessagingProvider';
49
49
  export { ConversationList } from './messaging/ConversationList';
50
50
  export { ConversationLaunchers } from './messaging/ConversationLaunchers';
51
+ export { DirectMessageLauncher } from './messaging/DirectMessageLauncher';
51
52
  export { Thread } from './messaging/Thread';
52
53
  export { MessageBubble } from './messaging/MessageBubble';
53
54
  export { ReadTicks } from './messaging/ReadTicks';
@@ -1,12 +1,17 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Alert, Button, Checkbox, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, IconButton, Stack, TextField, Typography } from '@mui/material';
2
+ import { Alert, Box, Button, Checkbox, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, IconButton, LinearProgress, Menu, MenuItem, Stack, TextField, Typography } from '@mui/material';
3
3
  import AttachFileIcon from '@mui/icons-material/AttachFile';
4
4
  import CampaignOutlinedIcon from '@mui/icons-material/CampaignOutlined';
5
+ import CloseIcon from '@mui/icons-material/Close';
6
+ import EmojiEmotionsOutlinedIcon from '@mui/icons-material/EmojiEmotionsOutlined';
5
7
  import PollOutlinedIcon from '@mui/icons-material/PollOutlined';
6
- import { useRef, useState } from 'react';
8
+ import { useEffect, useRef, useState } from 'react';
7
9
  import { useTranslation } from 'react-i18next';
10
+ import { compressImageForUpload } from './composerImageCompression';
8
11
  import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
12
+ import { QUICK_EMOJIS } from './ReactionBar';
9
13
  function newRequestId() { var _a, _b; return ((_b = (_a = globalThis.crypto) === null || _a === void 0 ? void 0 : _a.randomUUID) === null || _b === void 0 ? void 0 : _b.call(_a)) || `message-${Date.now()}-${Math.random().toString(36).slice(2)}`; }
14
+ function isImage(file) { var _a; return (_a = file.type) === null || _a === void 0 ? void 0 : _a.startsWith('image/'); }
10
15
  /**
11
16
  * A provider-backed, independently mountable REST composer.
12
17
  *
@@ -33,7 +38,21 @@ export function Composer({ conversationId, replyTarget = null, onReplyTargetChan
33
38
  const [announcementOpen, setAnnouncementOpen] = useState(false);
34
39
  const [announcementTitle, setAnnouncementTitle] = useState('');
35
40
  const [announcementBody, setAnnouncementBody] = useState('');
41
+ const [emojiAnchor, setEmojiAnchor] = useState(null);
42
+ const [previews, setPreviews] = useState([]);
43
+ const [uploadProgress, setUploadProgress] = useState(null);
36
44
  const inputRef = useRef(null);
45
+ const messageInputRef = useRef(null);
46
+ useEffect(() => {
47
+ if (replyTarget === null || replyTarget === void 0 ? void 0 : replyTarget.deleted_at)
48
+ onReplyTargetChange === null || onReplyTargetChange === void 0 ? void 0 : onReplyTargetChange(null);
49
+ }, [replyTarget, onReplyTargetChange]);
50
+ useEffect(() => { setBody(''); setFiles([]); }, [conversationId]);
51
+ useEffect(() => {
52
+ const urls = files.map((file) => isImage(file) ? URL.createObjectURL(file) : null);
53
+ setPreviews(urls);
54
+ return () => urls.filter(Boolean).forEach((url) => URL.revokeObjectURL(url));
55
+ }, [files]);
37
56
  const submit = async (requestId = newRequestId(), retry = false) => {
38
57
  if (!body.trim() && !files.length)
39
58
  return;
@@ -43,13 +62,16 @@ export function Composer({ conversationId, replyTarget = null, onReplyTargetChan
43
62
  try {
44
63
  if (files.length) {
45
64
  const formData = new FormData();
46
- files.forEach((file) => formData.append('files[]', file));
65
+ const uploadFiles = await Promise.all(files.map(compressImageForUpload));
66
+ uploadFiles.forEach((file) => formData.append('files[]', file));
47
67
  if (payload.body)
48
68
  formData.append('body', payload.body);
49
69
  if (payload.reply_to)
50
70
  formData.append('reply_to', String(payload.reply_to));
51
71
  formData.append('client_request_id', requestId);
52
- await sendAttachments(conversationId, formData, { clientRequestId: requestId, retry, optimisticMessage: { body: payload.body, reply_to: payload.reply_to, attachments: files.map((file, index) => ({ id: `local-${requestId}-${index}`, filename: file.name, content_type: file.type })) } });
72
+ setUploadProgress(0);
73
+ const onUploadProgress = (event) => setUploadProgress(event.total ? Math.round((event.loaded / event.total) * 100) : null);
74
+ await sendAttachments(conversationId, formData, { clientRequestId: requestId, retry, onUploadProgress, optimisticMessage: { body: payload.body, reply_to: payload.reply_to, attachments: files.map((file, index) => ({ id: `local-${requestId}-${index}`, filename: file.name, content_type: file.type })) } });
53
75
  }
54
76
  else
55
77
  await sendMessage(conversationId, payload, { clientRequestId: requestId, retry });
@@ -65,8 +87,19 @@ export function Composer({ conversationId, replyTarget = null, onReplyTargetChan
65
87
  }
66
88
  finally {
67
89
  setSending(false);
90
+ setUploadProgress(null);
68
91
  }
69
92
  };
93
+ const removeFile = (index) => setFiles((current) => current.filter((_, fileIndex) => fileIndex !== index));
94
+ const insertEmoji = (emoji) => {
95
+ var _a, _b, _c, _d;
96
+ const input = ((_b = (_a = messageInputRef.current) === null || _a === void 0 ? void 0 : _a.querySelector) === null || _b === void 0 ? void 0 : _b.call(_a, 'textarea')) || messageInputRef.current;
97
+ const start = (_c = input === null || input === void 0 ? void 0 : input.selectionStart) !== null && _c !== void 0 ? _c : body.length;
98
+ const end = (_d = input === null || input === void 0 ? void 0 : input.selectionEnd) !== null && _d !== void 0 ? _d : start;
99
+ setBody(`${body.slice(0, start)}${emoji}${body.slice(end)}`);
100
+ setEmojiAnchor(null);
101
+ requestAnimationFrame(() => { input === null || input === void 0 ? void 0 : input.focus(); input === null || input === void 0 ? void 0 : input.setSelectionRange(start + emoji.length, start + emoji.length); });
102
+ };
70
103
  const submitPoll = async () => {
71
104
  const options = pollOptions.map((option) => option.trim()).filter(Boolean);
72
105
  if (!pollQuestion.trim() || options.length < 2) {
@@ -116,5 +149,8 @@ export function Composer({ conversationId, replyTarget = null, onReplyTargetChan
116
149
  setSending(false);
117
150
  }
118
151
  };
119
- 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(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('MessagingPoll.CREATE'), onClick: () => setPollOpen(true), children: _jsx(PollOutlinedIcon, {}) }), allowAnnouncement && _jsx(IconButton, { type: "button", "aria-label": t('MessagingAnnouncement.CREATE'), onClick: () => setAnnouncementOpen(true), children: _jsx(CampaignOutlinedIcon, {}) }), _jsx(TextField, { fullWidth: true, multiline: true, minRows: 2, value: body, onChange: (event) => setBody(event.target.value), label: t('MessagingComposer.MESSAGE'), disabled: sending }), _jsx(Button, { type: "submit", variant: "contained", disabled: sending || (!body.trim() && !files.length), children: t('MessagingComposer.SEND') })] }), failed && _jsx(Button, { type: "button", onClick: () => submit(failed.requestId, true), disabled: sending, children: t('MessagingComposer.RETRY') }), _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", 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') })] })] }), allowAnnouncement && _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') })] })] })] });
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, {}) }), allowAnnouncement && _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
+ event.preventDefault();
154
+ 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') })] })] }), allowAnnouncement && _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') })] })] })] });
120
156
  }
@@ -9,6 +9,13 @@ function titleOf(conversation, t) { var _a, _b; return conversation.title || ((_
9
9
  function ordered(conversations) {
10
10
  return [...conversations].sort((left, right) => new Date(right.last_message_at || 0) - new Date(left.last_message_at || 0));
11
11
  }
12
+ function relativeTime(value, locale) {
13
+ if (!value)
14
+ return null;
15
+ const seconds = Math.max(0, Math.floor((Date.now() - new Date(value).getTime()) / 1000));
16
+ const [amount, unit] = seconds < 60 ? [0, 'second'] : seconds < 3600 ? [Math.floor(seconds / 60), 'minute'] : seconds < 86400 ? [Math.floor(seconds / 3600), 'hour'] : [Math.floor(seconds / 86400), 'day'];
17
+ return new Intl.RelativeTimeFormat(locale, { numeric: 'auto', style: 'narrow' }).format(-amount, unit);
18
+ }
12
19
  // jg's ConversationList renders a distinct icon per conversation kind so a
13
20
  // user can identify type/person at a glance (getConversationAvatar); that
14
21
  // glanceable identification is a feature, not layout, so it's reproduced
@@ -22,8 +29,8 @@ function KindIcon({ conversation }) {
22
29
  }
23
30
  /** A standalone, provider-backed list; scope picker metadata is supplied as launcher props. */
24
31
  export function ConversationList({ onOpen, groupLaunchers, broadcastLauncher, autoOpenBroadcast = false, includeArchived = false }) {
25
- const { t } = useTranslation();
26
- const { cache, activeConversationId, loadMoreConversations, setConversationArchived, setConversationPreferences } = useMessaging();
32
+ const { t, i18n } = useTranslation();
33
+ const { cache, activeConversationId, loadMoreConversations, setConversationArchived, setConversationPreferences, markConversationRead } = useMessaging();
27
34
  const [menuConversation, setMenuConversation] = useState(null);
28
35
  const [menuAnchor, setMenuAnchor] = useState(null);
29
36
  const [loadingMore, setLoadingMore] = useState(false);
@@ -70,6 +77,6 @@ export function ConversationList({ onOpen, groupLaunchers, broadcastLauncher, au
70
77
  var _a;
71
78
  const unread = unreadFor(conversation);
72
79
  const archived = Boolean(conversation.archived || conversation.archived_at);
73
- return _jsxs(ListItemButton, { selected: conversation.id === activeConversationId, onClick: () => onOpen === null || onOpen === void 0 ? void 0 : onOpen(conversation), sx: { minHeight: 64, opacity: archived ? 0.6 : 1 }, children: [_jsx(Badge, { color: "error", badgeContent: unread || null, max: 99, sx: { mr: 2 }, children: archived ? _jsx(Archive, { fontSize: "small", color: "disabled" }) : _jsx(KindIcon, { conversation: conversation }) }), _jsx(ListItemText, { primary: titleOf(conversation, t), secondary: ((_a = conversation.last_message) === null || _a === void 0 ? void 0 : _a.body) || t('MessagingList.NO_MESSAGES'), primaryTypographyProps: { fontWeight: unread ? 700 : 400, noWrap: true }, secondaryTypographyProps: { noWrap: true } }), _jsx(IconButton, { "aria-label": t('MessagingList.ACTIONS'), onClick: (event) => { event.stopPropagation(); setMenuAnchor(event.currentTarget); setMenuConversation(conversation); }, children: _jsx(MoreVert, {}) })] }, conversation.id);
80
+ 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) }), 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.body) || 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);
74
81
  }) })), 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')] })] })] }));
75
82
  }
@@ -0,0 +1,48 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useId, useState } from 'react';
3
+ import { Alert, Button, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, List, ListItemButton, ListItemText, Typography, useMediaQuery, } from '@mui/material';
4
+ import { useTheme } from '@mui/material/styles';
5
+ import { useTranslation } from 'react-i18next';
6
+ import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
7
+ /**
8
+ * Starts direct conversations from host-provided people. Candidate discovery is
9
+ * intentionally outside messaging because memberships are host-domain data.
10
+ */
11
+ export function DirectMessageLauncher({ candidates = [], scope, onOpen }) {
12
+ const { t } = useTranslation();
13
+ const { openDirectConversation } = useMessaging();
14
+ const theme = useTheme();
15
+ const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
16
+ const [open, setOpen] = useState(false);
17
+ const [selectedId, setSelectedId] = useState(null);
18
+ const [starting, setStarting] = useState(false);
19
+ const [error, setError] = useState(null);
20
+ const titleId = useId();
21
+ const showPicker = () => {
22
+ setSelectedId(null);
23
+ setError(null);
24
+ setOpen(true);
25
+ };
26
+ const closePicker = () => {
27
+ if (!starting)
28
+ setOpen(false);
29
+ };
30
+ const start = async () => {
31
+ if (selectedId == null || starting)
32
+ return;
33
+ setStarting(true);
34
+ setError(null);
35
+ try {
36
+ const conversation = await openDirectConversation(Object.assign({ target_user_id: selectedId }, (scope == null ? {} : { scope })));
37
+ setOpen(false);
38
+ onOpen === null || onOpen === void 0 ? void 0 : onOpen(conversation);
39
+ }
40
+ catch (requestError) {
41
+ setError(extractApiErrorMessage(requestError) || t('MessagingDirect.START_ERROR'));
42
+ }
43
+ finally {
44
+ setStarting(false);
45
+ }
46
+ };
47
+ return (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "outlined", onClick: showPicker, children: t('MessagingDirect.LAUNCH') }), _jsxs(Dialog, { open: open, onClose: closePicker, fullScreen: fullScreen, "aria-labelledby": titleId, children: [_jsx(DialogTitle, { id: titleId, children: t('MessagingDirect.TITLE') }), _jsxs(DialogContent, { children: [error && _jsx(Alert, { severity: "error", sx: { mb: 1 }, children: error }), starting && _jsxs(Typography, { role: "status", sx: { mb: 1 }, children: [_jsx(CircularProgress, { size: 16, sx: { mr: 1 } }), t('MessagingDirect.STARTING')] }), candidates.length === 0 ? (_jsx(Typography, { children: t('MessagingDirect.EMPTY') })) : (_jsx(List, { "aria-label": t('MessagingDirect.CANDIDATES'), children: candidates.map((candidate) => (_jsx(ListItemButton, { selected: candidate.id === selectedId, disabled: starting, onClick: () => setSelectedId(candidate.id), children: _jsx(ListItemText, { primary: candidate.display_name || candidate.name || candidate.label || candidate.id }) }, candidate.id))) }))] }), _jsxs(DialogActions, { children: [_jsx(Button, { onClick: closePicker, disabled: starting, children: t('MessagingDirect.CANCEL') }), _jsx(Button, { variant: "contained", onClick: start, disabled: selectedId == null || starting, children: t('MessagingDirect.START') })] })] })] }));
48
+ }
@@ -1,17 +1,97 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Button, Paper, Stack, Typography } from '@mui/material';
2
+ import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Menu, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material';
3
+ import MoreVertIcon from '@mui/icons-material/MoreVert';
4
+ import { useEffect, useState } from 'react';
3
5
  import { useTranslation } from 'react-i18next';
4
6
  import { AttachmentList } from './AttachmentList';
7
+ import { extractApiErrorMessage, useOptionalMessaging } from './MessagingProvider';
5
8
  import { ReactionBar } from './ReactionBar';
6
9
  import { PollCard } from './PollCard';
7
10
  function senderName(message) {
8
11
  var _a, _b, _c;
9
12
  return ((_a = message.sender) === null || _a === void 0 ? void 0 : _a.display_name) || ((_b = message.sender) === null || _b === void 0 ? void 0 : _b.username) || message.sender_name || ((_c = message.sender) === null || _c === void 0 ? void 0 : _c.name);
10
13
  }
11
- /** Provider-backed timeline collaborator; it deliberately has no mutation controls. */
12
- export function MessageBubble({ message, replyTo, onReply, children }) {
14
+ function quoteSnippet(message) {
15
+ const content = message.body || message.title;
16
+ if (!content)
17
+ return null;
18
+ return content.length > 120 ? `${content.slice(0, 117)}...` : content;
19
+ }
20
+ // A quoted source can be textless for two different reasons: it was deleted
21
+ // (server blanks body/title/link_target on soft delete), or it always had no
22
+ // text — an attachment-only chat send is a real, reachable case (Composer
23
+ // only requires body OR files). Collapsing both into "This message was
24
+ // deleted" would mislabel the second case, so they're distinguished here.
25
+ function quoteLabel(message, t) {
26
+ var _a;
27
+ if (message.deleted_at)
28
+ return t('MessagingThread.DELETED');
29
+ const snippet = quoteSnippet(message);
30
+ if (snippet)
31
+ return snippet;
32
+ return ((_a = message.attachments) === null || _a === void 0 ? void 0 : _a.length) ? t('MessagingThread.QUOTE_ATTACHMENT') : '';
33
+ }
34
+ /** Provider-backed timeline collaborator, including per-message mutations. */
35
+ export function MessageBubble({ message, replyTo, conversation, onReply, onJumpToMessage, onAnnouncementLink, canModerateMessages = false, children }) {
36
+ var _a;
13
37
  const { t } = useTranslation();
38
+ const messaging = useOptionalMessaging();
39
+ const { currentUser, editMessage, removeMessage } = messaging || {};
14
40
  const deleted = Boolean(message.deleted_at);
15
41
  const attachments = message.attachments || [];
16
- return (_jsx(Paper, { component: "article", variant: "outlined", sx: { p: 1.25, maxWidth: 'min(100%, 680px)' }, "aria-label": t('MessagingThread.MESSAGE'), children: _jsxs(Stack, { spacing: 0.75, children: [replyTo && _jsx(Typography, { variant: "caption", color: "text.secondary", children: t('MessagingThread.REPLY_TO', { sender: senderName(replyTo) || t('MessagingThread.UNKNOWN_SENDER') }) }), _jsxs(Stack, { direction: "row", spacing: 1, alignItems: "baseline", children: [_jsx(Typography, { variant: "subtitle2", children: senderName(message) || t('MessagingThread.UNKNOWN_SENDER') }), message.created_at && _jsx(Typography, { variant: "caption", color: "text.secondary", children: new Date(message.created_at).toLocaleString() }), message.edited_at && !deleted && _jsx(Typography, { variant: "caption", color: "text.secondary", children: t('MessagingThread.EDITED') })] }), !deleted && message.kind === 'announcement' && message.title && _jsx(Typography, { variant: "subtitle1", fontWeight: 700, children: message.title }), _jsx(Typography, { sx: { whiteSpace: 'pre-wrap' }, 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 }), _jsx(ReactionBar, { message: message }), message.poll && _jsx(PollCard, { message: message })] }), !deleted && onReply && _jsx(Button, { type: "button", size: "small", onClick: () => onReply(message), children: t('MessagingThread.REPLY') }), children] }) }));
42
+ const canAct = !deleted && message.kind !== 'poll' && ((Boolean(currentUser === null || currentUser === void 0 ? void 0 : currentUser.id) && ((_a = message.sender) === null || _a === void 0 ? void 0 : _a.id) === (currentUser === null || currentUser === void 0 ? void 0 : currentUser.id)) || canModerateMessages);
43
+ const [menuAnchor, setMenuAnchor] = useState(null);
44
+ const [editing, setEditing] = useState(false);
45
+ const [draft, setDraft] = useState(message.body || '');
46
+ const [confirmingDelete, setConfirmingDelete] = useState(false);
47
+ const [saving, setSaving] = useState(false);
48
+ const [error, setError] = useState(null);
49
+ useEffect(() => {
50
+ if (deleted) {
51
+ setEditing(false);
52
+ setConfirmingDelete(false);
53
+ setMenuAnchor(null);
54
+ }
55
+ }, [deleted]);
56
+ const closeMenu = () => setMenuAnchor(null);
57
+ const openMenu = (event) => { event.preventDefault(); setMenuAnchor(event.currentTarget); };
58
+ const startEdit = () => { setDraft(message.body || ''); setEditing(true); closeMenu(); };
59
+ const saveEdit = async () => {
60
+ setSaving(true);
61
+ setError(null);
62
+ try {
63
+ await (editMessage === null || editMessage === void 0 ? void 0 : editMessage(message.id, { body: draft.trim() }));
64
+ setEditing(false);
65
+ }
66
+ catch (saveError) {
67
+ setError(t('MessagingActions.EDIT_ERROR', { message: extractApiErrorMessage(saveError) }));
68
+ }
69
+ finally {
70
+ setSaving(false);
71
+ }
72
+ };
73
+ const confirmDelete = async () => {
74
+ setSaving(true);
75
+ setError(null);
76
+ try {
77
+ await (removeMessage === null || removeMessage === void 0 ? void 0 : removeMessage(message.id));
78
+ setConfirmingDelete(false);
79
+ }
80
+ catch (deleteError) {
81
+ setError(t('MessagingActions.DELETE_ERROR', { message: extractApiErrorMessage(deleteError) }));
82
+ }
83
+ finally {
84
+ setSaving(false);
85
+ }
86
+ };
87
+ const copyMessage = async () => {
88
+ closeMenu();
89
+ try {
90
+ await navigator.clipboard.writeText(message.body || '');
91
+ }
92
+ catch (copyError) {
93
+ setError(t('MessagingActions.COPY_ERROR', { message: extractApiErrorMessage(copyError) }));
94
+ }
95
+ };
96
+ return (_jsxs(Paper, { component: "article", variant: "outlined", sx: { p: 1.25, maxWidth: 'min(100%, 680px)', '& .message-actions': { opacity: 0 }, '&:hover .message-actions': { opacity: 1 } }, "aria-label": t('MessagingThread.MESSAGE'), "data-message-id": message.id, onContextMenu: canAct ? openMenu : undefined, children: [_jsxs(Stack, { spacing: 0.75, children: [replyTo && _jsx(Button, { type: "button", variant: "text", size: "small", onClick: () => onJumpToMessage === null || onJumpToMessage === void 0 ? void 0 : onJumpToMessage(replyTo.id), sx: { justifyContent: 'flex-start', px: 0, textAlign: 'left', textTransform: 'none' }, 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) })] }) }), _jsxs(Stack, { direction: "row", spacing: 1, alignItems: "baseline", children: [(conversation === null || conversation === void 0 ? void 0 : conversation.kind) !== 'direct' && _jsx(Typography, { variant: "subtitle2", children: senderName(message) || t('MessagingThread.UNKNOWN_SENDER') }), message.created_at && _jsx(Typography, { variant: "caption", color: "text.secondary", children: new Date(message.created_at).toLocaleString() }), message.edited_at && !deleted && _jsx(Typography, { variant: "caption", color: "text.secondary", children: t('MessagingThread.EDITED') })] }), !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' }, 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 }), _jsx(ReactionBar, { message: message }), message.poll && _jsx(PollCard, { message: message })] }), !deleted && onReply && _jsx(Button, { type: "button", size: "small", onClick: () => onReply(message), children: t('MessagingThread.REPLY') }), canAct && _jsx(IconButton, { className: "message-actions", size: "small", "aria-label": t('MessagingActions.MENU'), onClick: openMenu, children: _jsx(MoreVertIcon, {}) }), children] }), _jsxs(Menu, { anchorEl: menuAnchor, open: Boolean(menuAnchor), onClose: closeMenu, children: [_jsx(MenuItem, { onClick: startEdit, children: t('MessagingActions.EDIT') }), _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') })] })] })] }));
17
97
  }