@micha.bigler/ui-core-micha 2.16.0 → 2.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/WORK_ORDERS.md +3 -1
- package/dev/entries.jsx +5 -1
- package/dist/i18n/messagingTranslations.js +27 -0
- package/dist/index.js +1 -0
- package/dist/messaging/Composer.js +41 -5
- package/dist/messaging/ConversationList.js +10 -3
- package/dist/messaging/DirectMessageLauncher.js +48 -0
- package/dist/messaging/MessageBubble.js +84 -4
- package/dist/messaging/MessagingProvider.js +102 -21
- package/dist/messaging/PollCard.js +22 -5
- package/dist/messaging/ReactionBar.js +1 -1
- package/dist/messaging/ReadTicks.js +7 -4
- package/dist/messaging/Thread.js +45 -8
- package/dist/messaging/api.js +3 -3
- package/dist/messaging/composerImageCompression.js +36 -0
- package/docs/messaging-deviations.md +108 -26
- package/package.json +1 -1
- package/src/i18n/messagingTranslations.ts +27 -0
- package/src/index.js +1 -0
- package/src/messaging/Composer.jsx +54 -8
- package/src/messaging/ConversationList.jsx +10 -4
- package/src/messaging/DirectMessageLauncher.jsx +96 -0
- package/src/messaging/MessageBubble.jsx +74 -7
- package/src/messaging/MessagingProvider.jsx +124 -15
- package/src/messaging/PollCard.jsx +19 -3
- package/src/messaging/ReactionBar.jsx +1 -1
- package/src/messaging/ReadTicks.jsx +5 -3
- package/src/messaging/Thread.jsx +39 -8
- package/src/messaging/api.js +3 -2
- package/src/messaging/composerImageCompression.js +35 -0
- package/tests/ConversationList.test.jsx +1 -1
- package/tests/Thread.test.jsx +1 -1
- package/tests/messagingChunk6.test.jsx +109 -0
- package/tests/messagingComposerChunk5.test.jsx +110 -0
- package/tests/messagingContractConformance.test.js +69 -0
- package/tests/messagingDirectMessageLauncher.test.jsx +78 -0
- package/tests/messagingExports.test.js +2 -1
- package/tests/messagingInteractions.test.jsx +3 -1
- package/tests/messagingLastMessagePreview.test.jsx +93 -0
- package/tests/messagingMessageActions.test.jsx +93 -0
- package/tests/messagingPollRendering.test.jsx +115 -0
- package/tests/messagingReactionMerge.test.jsx +79 -0
- package/tests/messagingThreadingQuoting.test.jsx +86 -0
- package/tests/messagingUnreadLifecycle.test.jsx +63 -0
- package/work-orders/MSG-3b.md +1002 -0
- package/work-orders/MSG-3c.md +378 -0
|
@@ -1,36 +1,118 @@
|
|
|
1
|
-
# Messaging
|
|
1
|
+
# Messaging parity outcomes (MSG-3b, extended by MSG-3c)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
This is the authoritative parity record for the ucm messaging surfaces. It
|
|
4
|
+
replaces the former file-level “final parity confirmation,” which was not
|
|
5
|
+
sufficient evidence of feature parity. Each entry maps directly to the
|
|
6
|
+
numbered checklist in Part A of `work-orders/MSG-3b.md`. `OK` means the
|
|
7
|
+
capability is present; `DEV` means an intentional, documented redesign or
|
|
8
|
+
non-parity; `BLOCKED` means the dependency (dcm) does not expose the data or
|
|
9
|
+
realtime contract needed to implement it truthfully.
|
|
4
10
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
11
|
+
**Binding rule, added by MSG-3c:** every `BLOCKED` entry below must name the
|
|
12
|
+
dcm version it was verified blocked against. MSG-3b was itself blocked
|
|
13
|
+
against dcm 2.36.1 and recorded seven rows as `BLOCKED` on "a future dcm work
|
|
14
|
+
order" — dcm 2.37.0 shipped that work mid-flight, and MSG-3b's own WO-end
|
|
15
|
+
review closed without noticing, because it cross-checked the checklist
|
|
16
|
+
against ucm's landed code and took the dependency's state from the WO text
|
|
17
|
+
instead of the live dependency. A version pin turns that re-check into a
|
|
18
|
+
two-minute job instead of an archaeology exercise; `tests/messagingContractConformance.test.js`
|
|
19
|
+
now fails the build if a `BLOCKED` line omits one.
|
|
10
20
|
|
|
11
|
-
##
|
|
21
|
+
## Unread lifecycle
|
|
12
22
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
23
|
+
1. **OK** — Selecting a conversation calls the existing mark-read endpoint and clears its local unread count.
|
|
24
|
+
2. **OK** — Opening a `Thread` also marks its conversation read; the duplicate call is idempotent.
|
|
25
|
+
3. **OK** — Marking a conversation read decrements the global count by that conversation’s prior unread value, clamped at zero.
|
|
26
|
+
4. **OK** — Expanding a reply thread calls the existing thread-read endpoint for its root message.
|
|
27
|
+
5. **OK** — Realtime incoming messages increment unread only outside the active conversation and never double-count optimistic-send reconciliation.
|
|
16
28
|
|
|
17
|
-
##
|
|
29
|
+
## Message actions
|
|
18
30
|
|
|
19
|
-
|
|
20
|
-
|
|
31
|
+
6. **DEV** — Desktop hover and right-click open the MUI action menu. jg’s dedicated mobile long-press gesture is not reproduced; the same actions remain available through the regular MUI affordance.
|
|
32
|
+
7. **OK** — Authors can edit their own messages inline. Moderator edit affordance is host-supplied because dcm exposes no per-viewer capability field; server authorization remains authoritative.
|
|
33
|
+
8. **OK** — An active editor closes when the message is deleted by another update.
|
|
34
|
+
9. **OK** — Authors and host-designated moderators can delete; the host boolean is UX gating only and dcm enforces the actual permission.
|
|
35
|
+
10. **OK** — Deletion is confirmed in a MUI dialog before the request is sent.
|
|
36
|
+
11. **OK** — A deleted reply/edit target clears dependent composer or editor state.
|
|
37
|
+
12. **OK** — Message text can be copied through the action menu.
|
|
38
|
+
13. **OK** — Reply targeting is present.
|
|
39
|
+
14. **DEV** — Reactions use five compact presets rather than jg’s six-preset presentation.
|
|
40
|
+
15. **DEV** — No full third-party emoji picker is added; the compact preset menu keeps the package dependency-free.
|
|
41
|
+
16. **OK** — Deleted and poll messages do not render the generic action menu.
|
|
21
42
|
|
|
22
|
-
##
|
|
43
|
+
## Direct conversations
|
|
23
44
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
45
|
+
17. **OK** — `DirectMessageLauncher` starts a direct conversation.
|
|
46
|
+
18. **OK** — The launcher displays the host-supplied candidate list and selection state.
|
|
47
|
+
19. **OK** — It has distinct empty and in-flight creation states; candidate discovery remains host-owned because dcm has no directory endpoint.
|
|
48
|
+
20. **OK** — Start is disabled until a candidate is selected and during creation.
|
|
49
|
+
21. **OK** — Policy rejections render an API-derived readable error in the dialog.
|
|
50
|
+
22. **OK** — The picker uses a fullscreen MUI dialog at the small breakpoint.
|
|
29
51
|
|
|
30
|
-
##
|
|
52
|
+
## Threading and quoting
|
|
31
53
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
54
|
+
23. **OK** — Both dcm’s `reply_to_id` and optimistic rows’ `reply_to` are read, so REST and realtime replies group under their root.
|
|
55
|
+
24. **OK** — Replies show a sender and short quoted snippet.
|
|
56
|
+
25. **OK** — Clicking a quote scrolls its source message into view.
|
|
57
|
+
26. **OK** — Quotes of deleted messages render the deleted placeholder rather than stale content.
|
|
58
|
+
27. **BLOCKED** (blocked as of dcm 2.37.0) — dcm writes a thread receipt but exposes no prior thread receipt state, unread flag, or unread count to read. A truthful cross-device unread-reply dot cannot be rendered without a dcm contract addition. Re-verified against 2.37.0 (MSG-3c): still absent. Waits on dcm `MSG-2d`.
|
|
59
|
+
28. **OK** — Reply threads load lazily on expand and collapse locally.
|
|
60
|
+
29. **DEV** — Reply composition is supplied through the independently mountable `Composer` target prop rather than jg’s per-thread embedded composer.
|
|
61
|
+
|
|
62
|
+
## Composer
|
|
63
|
+
|
|
64
|
+
30. **OK** — Enter sends and Shift+Enter preserves a newline.
|
|
65
|
+
31. **OK** — Staged files show image thumbnails or file labels, with independent removal.
|
|
66
|
+
32. **OK** — Attachment uploads show real byte-percentage progress when the browser reports an upload total; otherwise an indeterminate indicator is shown while the upload is in flight.
|
|
67
|
+
33. **OK** — Images over 2 MB are compressed client-side to a maximum 2560 px JPEG at quality 0.85, with silent original-file fallback.
|
|
68
|
+
34. **DEV** — Composer emoji insertion uses the existing compact preset set instead of jg’s full picker, while preserving insertion at the cursor.
|
|
69
|
+
35. **OK** — Sending is optimistic and exposes retry plus inline error state.
|
|
70
|
+
36. **OK** — The poll button is disabled while an image is staged as a UI convenience matching jg’s composer behavior; no dcm-side constraint requires it. Poll creation and attachment sending are separate API calls and separate message rows.
|
|
71
|
+
37. **DEV** — Drafts are deliberately reset on conversation changes. jg’s cross-conversation draft leak is not reproduced.
|
|
72
|
+
|
|
73
|
+
## Conversation list
|
|
74
|
+
|
|
75
|
+
38. **OK** (delivered in MSG-3c, was BLOCKED as of dcm 2.36.1) — dcm 2.37.0 serializes `last_message` (`excerpt`/`created_at`) on the conversation payload and emits it live via `conversation_upsert` on new-message send. `ConversationList` was found reading the wrong field name (`last_message.body` instead of the real `excerpt`) — fixed, so the preview now actually renders against the real contract instead of always falling back to the empty state. Reorder and live preview refresh work for new messages; dcm does not publish `conversation_upsert` on message edit/delete, so those cases update on the next REST refresh rather than live (see row 58).
|
|
76
|
+
39. **OK** — `last_message_at` renders through browser-native, locale-aware `Intl.RelativeTimeFormat` labels.
|
|
77
|
+
40. **OK** — The existing MUI `selected={conversation.id === activeConversationId}` wiring produces the `Mui-selected` active-row highlight; it was verified and needs no redundant styling.
|
|
78
|
+
41. **OK** — Unread badges and bold unread titles are present.
|
|
79
|
+
42. **BLOCKED** (blocked as of dcm 2.37.0) — dcm stores the managed all/team distinction in `external_key`, but `serialize_conversation_core` still omits it from every conversation payload. The UI must not guess from titles or other unrelated fields. Re-verified against 2.37.0 (MSG-3c): still absent. Waits on dcm `MSG-2d`.
|
|
80
|
+
43. **DEV** — Generic MUI kind icons replace jg’s richer per-kind/person avatar treatment while retaining type identification.
|
|
81
|
+
44. **OK** — Mute and archive actions are present.
|
|
82
|
+
|
|
83
|
+
## Timeline
|
|
84
|
+
|
|
85
|
+
45. **OK** — New active-conversation messages auto-scroll when the reader was already near the bottom. Scrolled-up readers are not pulled away from history; this near-bottom gate is an intentional usability refinement.
|
|
86
|
+
46. **OK** — Announcement messages render their deep-link action only when a host supplies `onAnnouncementLink`; routing remains a host-app responsibility.
|
|
87
|
+
47. **OK** — Sender names remain visible for group and managed conversations and are suppressed in direct conversations. No sender-side bubble alignment scheme was added because it is not required for this capability.
|
|
88
|
+
48. **OK** — Deleted tombstones, edited marker, and timestamps are rendered.
|
|
89
|
+
|
|
90
|
+
## Read receipts
|
|
91
|
+
|
|
92
|
+
49. **OK** — Read ticks mount only for the current user’s own persisted messages.
|
|
93
|
+
50. **OK** — Non-direct recipient detail supplied by dcm is shown through a click/keyboard-openable popover list rather than hover-only tooltip. Direct conversations always suppress the detail control; dcm’s recipient-detail payload remains the moderator authorization boundary because no generic client moderator field exists.
|
|
94
|
+
|
|
95
|
+
## Polls
|
|
96
|
+
|
|
97
|
+
51. **OK** (delivered in MSG-3c, was BLOCKED as of dcm 2.36.1) — dcm 2.37.0's `serialize_poll` renders question, options and per-option `vote_count` through `PollCard`. Fixed against the real shape: the initial-selection effect previously read non-existent per-option `selected`/`voted` flags — it now derives from `voted_option_ids`, which dcm adds only to the create/vote/close REST responses (never to `serialize_poll` itself or the `poll_updated` frame), and correctly shows no known selection when that field is absent rather than guessing.
|
|
98
|
+
52. **DEV** (delivered in MSG-3c, was BLOCKED as of dcm 2.36.1) — dcm 2.37.0's poll options carry `voters` as user ids only, never names, and there is no user-directory endpoint (the same structural limit already established for the DM candidate list, row 19). `PollCard` renders the vote count and marks the viewer's own vote (derived from `voted_option_ids`) rather than a per-voter name list, which the dependency cannot supply.
|
|
99
|
+
53. **OK** (delivered in MSG-3c, was BLOCKED as of dcm 2.36.1) — `closed_at` is in the real poll projection; voting is disabled once set. The poll creator's close permission was found broken against the real contract (`created_by_id` is the real field; the prior code read a non-existent `created_by.id`) — fixed.
|
|
100
|
+
54. **OK** — Composer starts at two options and disables adding options once it reaches ten; the server’s two-option minimum remains enforced.
|
|
101
|
+
55. **OK** — Vote and close write paths are wired; full read rendering is now delivered per rows 51-53.
|
|
102
|
+
|
|
103
|
+
## Live synchronization
|
|
104
|
+
|
|
105
|
+
56. **OK** (delivered in MSG-3c, was BLOCKED as of dcm 2.36.1) — dcm 2.37.0 emits `reaction` frames. The handler had never run against a real frame; verification found it wholesale-replacing the message's reaction list with the server's aggregate-only projection (`{emoji, count}`, never a per-viewer `reacted` flag, by design) on every arrival — silently wiping every previously-known `reacted` flag on that message, not just whichever emoji changed. The same defect existed in the REST-confirm path for the viewer's own toggle. Both fixed to merge by emoji, preserving previously-known `reacted` state instead of discarding it.
|
|
106
|
+
57. **OK** (delivered in MSG-3c, was BLOCKED as of dcm 2.36.1) — dcm 2.37.0 emits `poll_updated` frames. Same defect class as row 56, found while implementing row 51-53: the frame handler wholesale-replaced a message's `poll` object, silently dropping a REST-known `voted_option_ids` the moment any `poll_updated` frame arrived (e.g. a vote-count bump from another viewer). Fixed to carry the previous value forward when the incoming frame is silent about it, per the WO's explicit "must not clear or invert the viewer's own vote state" requirement.
|
|
107
|
+
58. **OK** (delivered in MSG-3c, was BLOCKED as of dcm 2.36.1) — new messages live-reorder the conversation list and refresh its preview via `conversation_upsert` (row 38). dcm does not publish `conversation_upsert` on message edit or delete (only on new-message send — verified against every `_publish(..., "conversation_upsert", ...)` call site in `services.py`), so an edit/delete of a conversation's last message updates the preview on the next REST refresh rather than live; this is a dcm-side gap outside this WO's scope, not a ucm limitation.
|
|
108
|
+
59. **OK** — dcm’s emitted message, message-edited, and message-deleted frames are handled live.
|
|
109
|
+
|
|
110
|
+
## Required backend follow-up
|
|
111
|
+
|
|
112
|
+
Rows 27 and 42 require a future dcm work order (`MSG-2d`): expose readable thread receipt state, and
|
|
113
|
+
serialize `external_key` where managed all/team labeling is required. Until then, these are explicit
|
|
114
|
+
BLOCKED outcomes, each pinned to the dcm version they were verified blocked against, rather than
|
|
115
|
+
simulated client behavior. MSG-2c (dcm 2.37.0) already delivered what MSG-3b's own audit had recorded
|
|
116
|
+
as blocking rows 38, 51–53 and 56–58 — MSG-3c re-verified each against the live dependency and
|
|
117
|
+
delivered them; see those rows above for what was actually built (in three cases, fixing a real
|
|
118
|
+
client-side bug found only once real server data was checked against, not just unblocked).
|
package/package.json
CHANGED
|
@@ -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' },
|
|
@@ -60,6 +85,7 @@ export const messagingTranslations = {
|
|
|
60
85
|
'MessagingPoll.ADD_OPTION': { de: 'Option hinzufügen', en: 'Add option', fr: 'Ajouter une option' },
|
|
61
86
|
'MessagingPoll.ALLOW_MULTIPLE': { de: 'Mehrfachauswahl erlauben', en: 'Allow multiple choices', fr: 'Autoriser plusieurs choix' },
|
|
62
87
|
'MessagingPoll.CANCEL': { de: 'Abbrechen', en: 'Cancel', fr: 'Annuler' },
|
|
88
|
+
'MessagingPoll.YOU_VOTED': { de: 'von dir gewählt', en: 'voted by you', fr: 'voté par vous' },
|
|
63
89
|
'MessagingConfig.LABEL': { de: 'Nachrichten-Einstellungen', en: 'Messaging settings', fr: 'Paramètres de messagerie' },
|
|
64
90
|
'MessagingConfig.TITLE': { de: 'Nachrichten-Einstellungen', en: 'Messaging settings', fr: 'Paramètres de messagerie' },
|
|
65
91
|
'MessagingConfig.LOADING': { de: 'Einstellungen werden geladen…', en: 'Loading settings…', fr: 'Chargement des paramètres…' },
|
|
@@ -78,4 +104,5 @@ export const messagingTranslations = {
|
|
|
78
104
|
'MessagingAnnouncement.CANCEL': { de: 'Abbrechen', en: 'Cancel', fr: 'Annuler' },
|
|
79
105
|
'MessagingAnnouncement.VALIDATION': { de: 'Ein Titel ist erforderlich.', en: 'A title is required.', fr: 'Un titre est requis.' },
|
|
80
106
|
'MessagingAnnouncement.ERROR': { de: 'Ankündigung konnte nicht gesendet werden: {{message}}', en: 'Announcement could not be sent: {{message}}', fr: 'Impossible d’envoyer l’annonce : {{message}}' },
|
|
107
|
+
'MessagingAnnouncement.OPEN_LINK': { de: 'Link öffnen', en: 'Open link', fr: 'Ouvrir le lien' },
|
|
81
108
|
};
|
package/src/index.js
CHANGED
|
@@ -69,6 +69,7 @@ export * from './messaging/api';
|
|
|
69
69
|
export { MessagingProvider, useMessaging } from './messaging/MessagingProvider';
|
|
70
70
|
export { ConversationList } from './messaging/ConversationList';
|
|
71
71
|
export { ConversationLaunchers } from './messaging/ConversationLaunchers';
|
|
72
|
+
export { DirectMessageLauncher } from './messaging/DirectMessageLauncher';
|
|
72
73
|
export { Thread } from './messaging/Thread';
|
|
73
74
|
export { MessageBubble } from './messaging/MessageBubble';
|
|
74
75
|
export { ReadTicks } from './messaging/ReadTicks';
|
|
@@ -1,13 +1,18 @@
|
|
|
1
|
-
import { Alert, Button, Checkbox, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, IconButton, Stack, TextField, Typography } from '@mui/material';
|
|
1
|
+
import { Alert, Box, Button, Checkbox, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, IconButton, LinearProgress, Menu, MenuItem, Stack, TextField, Typography } from '@mui/material';
|
|
2
2
|
import AttachFileIcon from '@mui/icons-material/AttachFile';
|
|
3
3
|
import CampaignOutlinedIcon from '@mui/icons-material/CampaignOutlined';
|
|
4
|
+
import CloseIcon from '@mui/icons-material/Close';
|
|
5
|
+
import EmojiEmotionsOutlinedIcon from '@mui/icons-material/EmojiEmotionsOutlined';
|
|
4
6
|
import PollOutlinedIcon from '@mui/icons-material/PollOutlined';
|
|
5
|
-
import { useRef, useState } from 'react';
|
|
7
|
+
import { useEffect, useRef, useState } from 'react';
|
|
6
8
|
import { useTranslation } from 'react-i18next';
|
|
7
9
|
|
|
10
|
+
import { compressImageForUpload } from './composerImageCompression';
|
|
8
11
|
import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
|
|
12
|
+
import { QUICK_EMOJIS } from './ReactionBar';
|
|
9
13
|
|
|
10
14
|
function newRequestId() { return globalThis.crypto?.randomUUID?.() || `message-${Date.now()}-${Math.random().toString(36).slice(2)}`; }
|
|
15
|
+
function isImage(file) { return file.type?.startsWith('image/'); }
|
|
11
16
|
|
|
12
17
|
/**
|
|
13
18
|
* A provider-backed, independently mountable REST composer.
|
|
@@ -34,7 +39,20 @@ export function Composer({ conversationId, replyTarget = null, onReplyTargetChan
|
|
|
34
39
|
const [announcementOpen, setAnnouncementOpen] = useState(false);
|
|
35
40
|
const [announcementTitle, setAnnouncementTitle] = useState('');
|
|
36
41
|
const [announcementBody, setAnnouncementBody] = useState('');
|
|
42
|
+
const [emojiAnchor, setEmojiAnchor] = useState(null);
|
|
43
|
+
const [previews, setPreviews] = useState([]);
|
|
44
|
+
const [uploadProgress, setUploadProgress] = useState(null);
|
|
37
45
|
const inputRef = useRef(null);
|
|
46
|
+
const messageInputRef = useRef(null);
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (replyTarget?.deleted_at) 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]);
|
|
38
56
|
const submit = async (requestId = newRequestId(), retry = false) => {
|
|
39
57
|
if (!body.trim() && !files.length) return;
|
|
40
58
|
setSending(true); setError(null);
|
|
@@ -42,18 +60,29 @@ export function Composer({ conversationId, replyTarget = null, onReplyTargetChan
|
|
|
42
60
|
try {
|
|
43
61
|
if (files.length) {
|
|
44
62
|
const formData = new FormData();
|
|
45
|
-
|
|
63
|
+
const uploadFiles = await Promise.all(files.map(compressImageForUpload));
|
|
64
|
+
uploadFiles.forEach((file) => formData.append('files[]', file));
|
|
46
65
|
if (payload.body) formData.append('body', payload.body);
|
|
47
66
|
if (payload.reply_to) formData.append('reply_to', String(payload.reply_to));
|
|
48
67
|
formData.append('client_request_id', requestId);
|
|
49
|
-
|
|
68
|
+
setUploadProgress(0);
|
|
69
|
+
const onUploadProgress = (event) => setUploadProgress(event.total ? Math.round((event.loaded / event.total) * 100) : null);
|
|
70
|
+
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 })) } });
|
|
50
71
|
} else await sendMessage(conversationId, payload, { clientRequestId: requestId, retry });
|
|
51
72
|
setBody(''); setFiles([]); setFailed(null); onReplyTargetChange?.(null);
|
|
52
73
|
} catch (sendError) {
|
|
53
74
|
const message = extractApiErrorMessage(sendError);
|
|
54
75
|
setError(files.length ? t('MessagingComposer.UPLOAD_ERROR', { message }) : t('MessagingComposer.SEND_ERROR', { message }));
|
|
55
76
|
setFailed({ requestId, retry: true });
|
|
56
|
-
} finally { setSending(false); }
|
|
77
|
+
} finally { setSending(false); setUploadProgress(null); }
|
|
78
|
+
};
|
|
79
|
+
const removeFile = (index) => setFiles((current) => current.filter((_, fileIndex) => fileIndex !== index));
|
|
80
|
+
const insertEmoji = (emoji) => {
|
|
81
|
+
const input = messageInputRef.current?.querySelector?.('textarea') || messageInputRef.current;
|
|
82
|
+
const start = input?.selectionStart ?? body.length;
|
|
83
|
+
const end = input?.selectionEnd ?? start;
|
|
84
|
+
setBody(`${body.slice(0, start)}${emoji}${body.slice(end)}`); setEmojiAnchor(null);
|
|
85
|
+
requestAnimationFrame(() => { input?.focus(); input?.setSelectionRange(start + emoji.length, start + emoji.length); });
|
|
57
86
|
};
|
|
58
87
|
const submitPoll = async () => {
|
|
59
88
|
const options = pollOptions.map((option) => option.trim()).filter(Boolean);
|
|
@@ -82,21 +111,38 @@ export function Composer({ conversationId, replyTarget = null, onReplyTargetChan
|
|
|
82
111
|
return <Stack component="form" spacing={1} onSubmit={(event) => { event.preventDefault(); submit(); }} aria-label={t('MessagingComposer.LABEL')}>
|
|
83
112
|
{replyTarget && <Alert severity="info" onClose={() => onReplyTargetChange?.(null)}>{t('MessagingComposer.REPLYING_TO', { sender: replyTarget.sender?.display_name || t('MessagingThread.UNKNOWN_SENDER') })}</Alert>}
|
|
84
113
|
{error && <Alert severity="error" role="alert">{error}</Alert>}
|
|
114
|
+
{files.length > 0 && <Stack direction="row" spacing={0.5} flexWrap="wrap" aria-label={t('MessagingComposer.STAGED_FILES')}>
|
|
115
|
+
{files.map((file, index) => <Stack key={`${file.name}-${index}`} direction="row" alignItems="center" spacing={0.25} sx={{ border: 1, borderColor: 'divider', borderRadius: 1, p: 0.25 }}>
|
|
116
|
+
{previews[index] && <Box component="img" src={previews[index]} alt={file.name} width={36} height={36} sx={{ objectFit: 'cover' }} />}
|
|
117
|
+
<Typography variant="caption">{file.name}</Typography><IconButton type="button" size="small" aria-label={t('MessagingComposer.REMOVE_ATTACHMENT', { name: file.name })} onClick={() => removeFile(index)}><CloseIcon fontSize="inherit" /></IconButton>
|
|
118
|
+
</Stack>)}
|
|
119
|
+
</Stack>}
|
|
85
120
|
{files.length > 0 && <Typography variant="caption">{t('MessagingComposer.FILES_SELECTED', { count: files.length })}</Typography>}
|
|
86
121
|
<Stack direction="row" spacing={1} alignItems="flex-end">
|
|
87
122
|
<input ref={inputRef} hidden type="file" multiple onChange={(event) => setFiles(Array.from(event.target.files || []))} />
|
|
88
123
|
<IconButton type="button" aria-label={t('MessagingComposer.ADD_ATTACHMENT')} onClick={() => inputRef.current?.click()}><AttachFileIcon /></IconButton>
|
|
89
|
-
<IconButton type="button" aria-label={t('
|
|
124
|
+
<IconButton type="button" aria-label={t('MessagingComposer.ADD_EMOJI')} onClick={(event) => setEmojiAnchor(event.currentTarget)}><EmojiEmotionsOutlinedIcon /></IconButton>
|
|
125
|
+
<IconButton type="button" aria-label={t('MessagingPoll.CREATE')} disabled={pollOpen || files.some(isImage)} onClick={() => setPollOpen(true)}><PollOutlinedIcon /></IconButton>
|
|
90
126
|
{allowAnnouncement && <IconButton type="button" aria-label={t('MessagingAnnouncement.CREATE')} onClick={() => setAnnouncementOpen(true)}><CampaignOutlinedIcon /></IconButton>}
|
|
91
|
-
<TextField fullWidth multiline minRows={2} value={body} onChange={(event) => setBody(event.target.value)} label={t('MessagingComposer.MESSAGE')} disabled={sending} />
|
|
127
|
+
<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} />
|
|
92
128
|
<Button type="submit" variant="contained" disabled={sending || (!body.trim() && !files.length)}>{t('MessagingComposer.SEND')}</Button>
|
|
93
129
|
</Stack>
|
|
130
|
+
{sending && files.length > 0 && <Stack spacing={0.5} role="status">
|
|
131
|
+
<Stack direction="row" spacing={1} alignItems="center">
|
|
132
|
+
{uploadProgress == null ? <CircularProgress size={16} /> : <CircularProgress size={16} variant="determinate" value={uploadProgress} />}
|
|
133
|
+
<Typography variant="caption">{t('MessagingComposer.UPLOADING')}</Typography>
|
|
134
|
+
</Stack>
|
|
135
|
+
{uploadProgress != null && <LinearProgress variant="determinate" value={uploadProgress} />}
|
|
136
|
+
</Stack>}
|
|
94
137
|
{failed && <Button type="button" onClick={() => submit(failed.requestId, true)} disabled={sending}>{t('MessagingComposer.RETRY')}</Button>}
|
|
138
|
+
<Menu anchorEl={emojiAnchor} open={Boolean(emojiAnchor)} onClose={() => setEmojiAnchor(null)}>
|
|
139
|
+
{QUICK_EMOJIS.map((emoji) => <MenuItem key={emoji} onClick={() => insertEmoji(emoji)}>{emoji}</MenuItem>)}
|
|
140
|
+
</Menu>
|
|
95
141
|
<Dialog open={pollOpen} onClose={() => !sending && setPollOpen(false)} fullWidth maxWidth="sm">
|
|
96
142
|
<DialogTitle>{t('MessagingPoll.CREATE')}</DialogTitle><DialogContent><Stack spacing={1} sx={{ pt: 1 }}>
|
|
97
143
|
<TextField label={t('MessagingPoll.QUESTION')} value={pollQuestion} onChange={(event) => setPollQuestion(event.target.value)} autoFocus />
|
|
98
144
|
{pollOptions.map((option, index) => <TextField key={index} label={t('MessagingPoll.OPTION', { count: index + 1 })} value={option} onChange={(event) => setPollOptions((current) => current.map((value, optionIndex) => optionIndex === index ? event.target.value : value))} />)}
|
|
99
|
-
<Button type="button" onClick={() => setPollOptions((current) => [...current, ''])}>{t('MessagingPoll.ADD_OPTION')}</Button>
|
|
145
|
+
<Button type="button" disabled={pollOptions.length >= 10} onClick={() => setPollOptions((current) => [...current, ''])}>{t('MessagingPoll.ADD_OPTION')}</Button>
|
|
100
146
|
<FormControlLabel control={<Checkbox checked={allowMultiple} onChange={(event) => setAllowMultiple(event.target.checked)} />} label={t('MessagingPoll.ALLOW_MULTIPLE')} />
|
|
101
147
|
</Stack></DialogContent><DialogActions><Button onClick={() => setPollOpen(false)}>{t('MessagingPoll.CANCEL')}</Button><Button variant="contained" onClick={submitPoll} disabled={sending}>{t('MessagingPoll.CREATE')}</Button></DialogActions>
|
|
102
148
|
</Dialog>
|
|
@@ -10,6 +10,12 @@ function titleOf(conversation, t) { return conversation.title || conversation.ot
|
|
|
10
10
|
function ordered(conversations) {
|
|
11
11
|
return [...conversations].sort((left, right) => new Date(right.last_message_at || 0) - new Date(left.last_message_at || 0));
|
|
12
12
|
}
|
|
13
|
+
function relativeTime(value, locale) {
|
|
14
|
+
if (!value) 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
|
+
}
|
|
13
19
|
// jg's ConversationList renders a distinct icon per conversation kind so a
|
|
14
20
|
// user can identify type/person at a glance (getConversationAvatar); that
|
|
15
21
|
// glanceable identification is a feature, not layout, so it's reproduced
|
|
@@ -22,8 +28,8 @@ function KindIcon({ conversation }) {
|
|
|
22
28
|
|
|
23
29
|
/** A standalone, provider-backed list; scope picker metadata is supplied as launcher props. */
|
|
24
30
|
export function ConversationList({ onOpen, groupLaunchers, broadcastLauncher, autoOpenBroadcast = false, includeArchived = false }) {
|
|
25
|
-
const { t } = useTranslation();
|
|
26
|
-
const { cache, activeConversationId, loadMoreConversations, setConversationArchived, setConversationPreferences } = useMessaging();
|
|
31
|
+
const { t, i18n } = useTranslation();
|
|
32
|
+
const { cache, activeConversationId, loadMoreConversations, setConversationArchived, setConversationPreferences, markConversationRead } = useMessaging();
|
|
27
33
|
const [menuConversation, setMenuConversation] = useState(null);
|
|
28
34
|
const [menuAnchor, setMenuAnchor] = useState(null);
|
|
29
35
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
@@ -58,11 +64,11 @@ export function ConversationList({ onOpen, groupLaunchers, broadcastLauncher, au
|
|
|
58
64
|
{conversations.map((conversation) => {
|
|
59
65
|
const unread = unreadFor(conversation);
|
|
60
66
|
const archived = Boolean(conversation.archived || conversation.archived_at);
|
|
61
|
-
return <ListItemButton key={conversation.id} selected={conversation.id === activeConversationId} onClick={() => onOpen?.(conversation)} sx={{ minHeight: 64, opacity: archived ? 0.6 : 1 }}>
|
|
67
|
+
return <ListItemButton key={conversation.id} selected={conversation.id === activeConversationId} onClick={() => { markConversationRead(conversation.id).catch(() => {}); onOpen?.(conversation); }} sx={{ minHeight: 64, opacity: archived ? 0.6 : 1 }}>
|
|
62
68
|
<Badge color="error" badgeContent={unread || null} max={99} sx={{ mr: 2 }}>
|
|
63
69
|
{archived ? <Archive fontSize="small" color="disabled" /> : <KindIcon conversation={conversation} />}
|
|
64
70
|
</Badge>
|
|
65
|
-
<ListItemText primary={titleOf(conversation, t)} secondary={conversation.last_message?.
|
|
71
|
+
<ListItemText primary={<Stack direction="row" spacing={1} justifyContent="space-between"><Typography component="span" fontWeight={unread ? 700 : 400} noWrap>{titleOf(conversation, t)}</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 }} />
|
|
66
72
|
<IconButton aria-label={t('MessagingList.ACTIONS')} onClick={(event) => { event.stopPropagation(); setMenuAnchor(event.currentTarget); setMenuConversation(conversation); }}><MoreVert /></IconButton>
|
|
67
73
|
</ListItemButton>;
|
|
68
74
|
})}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { useId, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Alert,
|
|
4
|
+
Button,
|
|
5
|
+
CircularProgress,
|
|
6
|
+
Dialog,
|
|
7
|
+
DialogActions,
|
|
8
|
+
DialogContent,
|
|
9
|
+
DialogTitle,
|
|
10
|
+
List,
|
|
11
|
+
ListItemButton,
|
|
12
|
+
ListItemText,
|
|
13
|
+
Typography,
|
|
14
|
+
useMediaQuery,
|
|
15
|
+
} from '@mui/material';
|
|
16
|
+
import { useTheme } from '@mui/material/styles';
|
|
17
|
+
import { useTranslation } from 'react-i18next';
|
|
18
|
+
|
|
19
|
+
import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Starts direct conversations from host-provided people. Candidate discovery is
|
|
23
|
+
* intentionally outside messaging because memberships are host-domain data.
|
|
24
|
+
*/
|
|
25
|
+
export function DirectMessageLauncher({ candidates = [], scope, onOpen }) {
|
|
26
|
+
const { t } = useTranslation();
|
|
27
|
+
const { openDirectConversation } = useMessaging();
|
|
28
|
+
const theme = useTheme();
|
|
29
|
+
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
|
30
|
+
const [open, setOpen] = useState(false);
|
|
31
|
+
const [selectedId, setSelectedId] = useState(null);
|
|
32
|
+
const [starting, setStarting] = useState(false);
|
|
33
|
+
const [error, setError] = useState(null);
|
|
34
|
+
const titleId = useId();
|
|
35
|
+
|
|
36
|
+
const showPicker = () => {
|
|
37
|
+
setSelectedId(null);
|
|
38
|
+
setError(null);
|
|
39
|
+
setOpen(true);
|
|
40
|
+
};
|
|
41
|
+
const closePicker = () => {
|
|
42
|
+
if (!starting) setOpen(false);
|
|
43
|
+
};
|
|
44
|
+
const start = async () => {
|
|
45
|
+
if (selectedId == null || starting) return;
|
|
46
|
+
setStarting(true);
|
|
47
|
+
setError(null);
|
|
48
|
+
try {
|
|
49
|
+
const conversation = await openDirectConversation({
|
|
50
|
+
target_user_id: selectedId,
|
|
51
|
+
...(scope == null ? {} : { scope }),
|
|
52
|
+
});
|
|
53
|
+
setOpen(false);
|
|
54
|
+
onOpen?.(conversation);
|
|
55
|
+
} catch (requestError) {
|
|
56
|
+
setError(extractApiErrorMessage(requestError) || t('MessagingDirect.START_ERROR'));
|
|
57
|
+
} finally {
|
|
58
|
+
setStarting(false);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<>
|
|
64
|
+
<Button variant="outlined" onClick={showPicker}>{t('MessagingDirect.LAUNCH')}</Button>
|
|
65
|
+
<Dialog open={open} onClose={closePicker} fullScreen={fullScreen} aria-labelledby={titleId}>
|
|
66
|
+
<DialogTitle id={titleId}>{t('MessagingDirect.TITLE')}</DialogTitle>
|
|
67
|
+
<DialogContent>
|
|
68
|
+
{error && <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert>}
|
|
69
|
+
{starting && <Typography role="status" sx={{ mb: 1 }}><CircularProgress size={16} sx={{ mr: 1 }} />{t('MessagingDirect.STARTING')}</Typography>}
|
|
70
|
+
{candidates.length === 0 ? (
|
|
71
|
+
<Typography>{t('MessagingDirect.EMPTY')}</Typography>
|
|
72
|
+
) : (
|
|
73
|
+
<List aria-label={t('MessagingDirect.CANDIDATES')}>
|
|
74
|
+
{candidates.map((candidate) => (
|
|
75
|
+
<ListItemButton
|
|
76
|
+
key={candidate.id}
|
|
77
|
+
selected={candidate.id === selectedId}
|
|
78
|
+
disabled={starting}
|
|
79
|
+
onClick={() => setSelectedId(candidate.id)}
|
|
80
|
+
>
|
|
81
|
+
<ListItemText primary={candidate.display_name || candidate.name || candidate.label || candidate.id} />
|
|
82
|
+
</ListItemButton>
|
|
83
|
+
))}
|
|
84
|
+
</List>
|
|
85
|
+
)}
|
|
86
|
+
</DialogContent>
|
|
87
|
+
<DialogActions>
|
|
88
|
+
<Button onClick={closePicker} disabled={starting}>{t('MessagingDirect.CANCEL')}</Button>
|
|
89
|
+
<Button variant="contained" onClick={start} disabled={selectedId == null || starting}>
|
|
90
|
+
{t('MessagingDirect.START')}
|
|
91
|
+
</Button>
|
|
92
|
+
</DialogActions>
|
|
93
|
+
</Dialog>
|
|
94
|
+
</>
|
|
95
|
+
);
|
|
96
|
+
}
|