@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
@@ -1,16 +1,18 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { createContext, useCallback, useContext, useEffect, useMemo, useReducer, useRef } from 'react';
3
3
  import { AuthContext } from '../auth/AuthContext';
4
- import { archiveConversation, createBroadcastConversation, createGroupConversation, getReadStatus, getUnreadCount, listConversations, listMessages, listThread, patchConversationPreferences, createMessage, uploadAttachments, getAttachment, getAttachmentThumbnail, addReaction, removeReaction, createPoll, votePoll, closePoll, getConversationConfig, patchConversationConfig, } from './api';
4
+ import { archiveConversation, createBroadcastConversation, createDirectConversation, createGroupConversation, getReadStatus, getUnreadCount, listConversations, listMessages, listThread, patchConversationPreferences, markConversationRead, markThreadRead, createMessage, uploadAttachments, getAttachment, getAttachmentThumbnail, addReaction, removeReaction, createPoll, votePoll, closePoll, getConversationConfig, patchConversationConfig, patchMessage as patchMessageRequest, deleteMessage as deleteMessageRequest, } from './api';
5
5
  import { useRealtime } from '../notifications/realtime';
6
6
  const MessagingContext = createContext(null);
7
7
  export const MESSAGING_ENVELOPE = 'messaging';
8
8
  const EMPTY_CACHE = { conversations: {}, messages: {}, threads: {}, polls: {}, reactions: {}, receipts: {}, unread: { unread_count: 0, by_conversation: {} }, cursors: { conversations: null, messages: {}, threads: {} } };
9
9
  const DEFAULT_API = {
10
10
  listConversations, listMessages, listThread, getReadStatus, getUnreadCount, archiveConversation,
11
- patchConversationPreferences, createGroupConversation, createBroadcastConversation,
11
+ patchConversationPreferences, createGroupConversation, createBroadcastConversation, createDirectConversation,
12
+ markConversationRead, markThreadRead,
12
13
  createMessage, uploadAttachments, getAttachment, getAttachmentThumbnail,
13
14
  addReaction, removeReaction, createPoll, votePoll, closePoll, getConversationConfig, patchConversationConfig,
15
+ patchMessage: patchMessageRequest, deleteMessage: deleteMessageRequest,
14
16
  };
15
17
  function idOf(item) { var _a, _b, _c; return (_c = (_b = (_a = item === null || item === void 0 ? void 0 : item.id) !== null && _a !== void 0 ? _a : item === null || item === void 0 ? void 0 : item.conversation_id) !== null && _b !== void 0 ? _b : item === null || item === void 0 ? void 0 : item.message_id) !== null && _c !== void 0 ? _c : item === null || item === void 0 ? void 0 : item.poll_id; }
16
18
  function mergeById(slice, item) { const id = idOf(item); return id == null ? slice : Object.assign(Object.assign({}, slice), { [id]: Object.assign(Object.assign({}, slice[id]), item) }); }
@@ -53,7 +55,7 @@ function reconcileMessage(messages, message) {
53
55
  return next;
54
56
  }
55
57
  export function messagingReducer(state, action) {
56
- var _a, _b, _c;
58
+ var _a, _b, _c, _d, _e, _f;
57
59
  switch (action.type) {
58
60
  case 'conversationsLoaded': {
59
61
  const conversations = action.results.reduce((next, item) => mergeById(next, item), state.conversations);
@@ -79,27 +81,40 @@ export function messagingReducer(state, action) {
79
81
  return Object.assign(Object.assign({}, state), { messages: Object.assign(Object.assign({}, state.messages), { [key]: Object.assign(Object.assign({}, message), { status: 'error', error: action.error }) }) });
80
82
  }
81
83
  case 'messagePatched': return Object.assign(Object.assign({}, state), { messages: mergeById(state.messages, action.message) });
82
- case 'frame': return applyFrame(state, action.frame);
84
+ case 'conversationRead': {
85
+ const previousCount = ((_e = (_d = state.unread) === null || _d === void 0 ? void 0 : _d.by_conversation) === null || _e === void 0 ? void 0 : _e[action.conversationId]) || 0;
86
+ if (!previousCount)
87
+ return state;
88
+ return Object.assign(Object.assign({}, state), { unread: Object.assign(Object.assign({}, state.unread), { unread_count: Math.max(0, (((_f = state.unread) === null || _f === void 0 ? void 0 : _f.unread_count) || 0) - previousCount), by_conversation: Object.assign(Object.assign({}, state.unread.by_conversation), { [action.conversationId]: 0 }) }) });
89
+ }
90
+ case 'frame': return applyFrame(state, action.frame, action.activeConversationId);
83
91
  default: return state;
84
92
  }
85
93
  }
86
- export function applyFrame(state, frame) {
87
- var _a, _b, _c;
94
+ export function applyFrame(state, frame, activeConversationId = null) {
95
+ var _a, _b, _c, _d;
88
96
  const payload = frame.message || frame.conversation || frame.participant || frame;
89
97
  const conversationId = (_a = frame.conversation_id) !== null && _a !== void 0 ? _a : payload.conversation_id;
90
98
  if (frame.type === 'message') {
91
99
  const message = Object.assign(Object.assign({}, payload), { conversation_id: conversationId });
92
- return Object.assign(Object.assign({}, state), { messages: reconcileMessage(state.messages, message) });
100
+ const requestId = clientRequestIdOf(message);
101
+ const reconcilesOptimisticMessage = Boolean(requestId && Object.values(state.messages).some((item) => clientRequestIdOf(item) === requestId));
102
+ const messages = reconcileMessage(state.messages, message);
103
+ if (conversationId == null || String(conversationId) === String(activeConversationId) || reconcilesOptimisticMessage)
104
+ return Object.assign(Object.assign({}, state), { messages });
105
+ const unread = state.unread || EMPTY_CACHE.unread;
106
+ const previousCount = ((_b = unread.by_conversation) === null || _b === void 0 ? void 0 : _b[conversationId]) || 0;
107
+ return Object.assign(Object.assign({}, state), { messages, unread: Object.assign(Object.assign({}, unread), { unread_count: (unread.unread_count || 0) + 1, by_conversation: Object.assign(Object.assign({}, unread.by_conversation), { [conversationId]: previousCount + 1 }) }) });
93
108
  }
94
109
  if (frame.type === 'message_edited')
95
110
  return Object.assign(Object.assign({}, state), { messages: mergeById(state.messages, payload) });
96
111
  if (frame.type === 'reaction') {
97
- const messageId = (_b = frame.message_id) !== null && _b !== void 0 ? _b : payload.message_id;
112
+ const messageId = (_c = frame.message_id) !== null && _c !== void 0 ? _c : payload.message_id;
98
113
  const previous = state.messages[messageId] || { id: messageId };
99
114
  return Object.assign(Object.assign({}, state), { messages: mergeById(state.messages, Object.assign(Object.assign({}, previous), { reactions: payload.reactions || frame.reactions || previous.reactions || [] })) });
100
115
  }
101
116
  if (frame.type === 'poll_updated') {
102
- const messageId = (_c = frame.message_id) !== null && _c !== void 0 ? _c : payload.message_id;
117
+ const messageId = (_d = frame.message_id) !== null && _d !== void 0 ? _d : payload.message_id;
103
118
  const previous = state.messages[messageId] || { id: messageId };
104
119
  const poll = payload.poll || frame.poll || payload;
105
120
  return Object.assign(Object.assign({}, state), { messages: mergeById(state.messages, Object.assign(Object.assign({}, previous), { poll })), polls: mergeById(state.polls, poll) });
@@ -180,6 +195,12 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
180
195
  const conversation = await api.patchConversationPreferences(conversationId, patch);
181
196
  return updateConversation(Object.assign(Object.assign(Object.assign({}, conversation), { id: (_a = conversation === null || conversation === void 0 ? void 0 : conversation.id) !== null && _a !== void 0 ? _a : conversationId }), patch));
182
197
  }, [api, updateConversation]);
198
+ const markConversationAsRead = useCallback(async (conversationId, readAt) => {
199
+ const result = await api.markConversationRead(conversationId, readAt);
200
+ dispatch({ type: 'conversationRead', conversationId });
201
+ return result;
202
+ }, [api]);
203
+ const markReplyThreadRead = useCallback((rootId, readAt) => api.markThreadRead(rootId, readAt), [api]);
183
204
  const openGroupConversation = useCallback(async (payload) => {
184
205
  const conversation = await api.createGroupConversation(payload);
185
206
  return updateConversation(conversation);
@@ -188,6 +209,10 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
188
209
  const conversation = await api.createBroadcastConversation(payload);
189
210
  return updateConversation(conversation);
190
211
  }, [api, updateConversation]);
212
+ const openDirectConversation = useCallback(async (payload) => {
213
+ const conversation = await api.createDirectConversation(payload);
214
+ return updateConversation(conversation);
215
+ }, [api, updateConversation]);
191
216
  const sendMessage = useCallback(async (conversationId, payload, { clientRequestId, retry = false } = {}) => {
192
217
  var _a, _b;
193
218
  const requestId = clientRequestId || payload.client_request_id;
@@ -206,7 +231,7 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
206
231
  throw error;
207
232
  }
208
233
  }, [api, user]);
209
- const sendAttachments = useCallback(async (conversationId, formData, { clientRequestId, optimisticMessage, retry = false } = {}) => {
234
+ const sendAttachments = useCallback(async (conversationId, formData, { clientRequestId, optimisticMessage, retry = false, onUploadProgress } = {}) => {
210
235
  var _a, _b;
211
236
  const optimistic = {
212
237
  id: `local-${clientRequestId}`, conversation_id: conversationId, kind: 'chat', body: (optimisticMessage === null || optimisticMessage === void 0 ? void 0 : optimisticMessage.body) || '',
@@ -219,7 +244,7 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
219
244
  else
220
245
  dispatch({ type: 'messageReconciled', message: Object.assign(Object.assign({}, optimistic), { status: 'pending', error: null }) });
221
246
  try {
222
- const message = await api.uploadAttachments(conversationId, formData, { idempotencyKey: clientRequestId });
247
+ const message = await api.uploadAttachments(conversationId, formData, { idempotencyKey: clientRequestId, onUploadProgress });
223
248
  dispatch({ type: 'messageReconciled', message: Object.assign(Object.assign({}, message), { conversation_id: (_a = message === null || message === void 0 ? void 0 : message.conversation_id) !== null && _a !== void 0 ? _a : conversationId, client_request_id: (_b = message === null || message === void 0 ? void 0 : message.client_request_id) !== null && _b !== void 0 ? _b : clientRequestId }) });
224
249
  return message;
225
250
  }
@@ -229,6 +254,23 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
229
254
  }
230
255
  }, [api, user]);
231
256
  const patchMessage = useCallback((message) => dispatch({ type: 'messagePatched', message }), []);
257
+ // Keep this API-backed action deliberately distinct from the cache-merge
258
+ // helper above. The server remains the authority for author/moderator rights.
259
+ const editMessage = useCallback(async (messageId, patch) => {
260
+ var _a;
261
+ const result = await api.patchMessage(messageId, patch);
262
+ const message = (result === null || result === void 0 ? void 0 : result.message) || result;
263
+ patchMessage(Object.assign(Object.assign(Object.assign({}, (cache.messages[messageId] || { id: messageId })), (message || {})), { id: (_a = message === null || message === void 0 ? void 0 : message.id) !== null && _a !== void 0 ? _a : messageId }));
264
+ return result;
265
+ }, [api, cache.messages, patchMessage]);
266
+ const removeMessage = useCallback(async (messageId) => {
267
+ var _a, _b, _c, _d, _e;
268
+ const result = await api.deleteMessage(messageId);
269
+ const message = (result === null || result === void 0 ? void 0 : result.message) || result;
270
+ const previous = cache.messages[messageId] || { id: messageId };
271
+ patchMessage(Object.assign(Object.assign(Object.assign({}, previous), (message || {})), { id: (_a = message === null || message === void 0 ? void 0 : message.id) !== null && _a !== void 0 ? _a : messageId, deleted_at: (_c = (_b = message === null || message === void 0 ? void 0 : message.deleted_at) !== null && _b !== void 0 ? _b : previous.deleted_at) !== null && _c !== void 0 ? _c : new Date().toISOString(), deleted_by: (_e = (_d = message === null || message === void 0 ? void 0 : message.deleted_by) !== null && _d !== void 0 ? _d : previous.deleted_by) !== null && _e !== void 0 ? _e : user === null || user === void 0 ? void 0 : user.id, body: null, title: null, link_target: null }));
272
+ return result;
273
+ }, [api, cache.messages, patchMessage, user]);
232
274
  const toggleReaction = useCallback(async (messageId, emoji, active) => {
233
275
  var _a;
234
276
  const message = cache.messages[messageId] || { id: messageId };
@@ -290,7 +332,7 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
290
332
  if (seenEventsRef.current.size > 500)
291
333
  seenEventsRef.current.delete(seenEventsRef.current.values().next().value);
292
334
  }
293
- dispatch({ type: 'frame', frame });
335
+ dispatch({ type: 'frame', frame, activeConversationId: activeIdRef.current });
294
336
  }), [subscribe]);
295
337
  useEffect(() => {
296
338
  if (!active || !onReconnect)
@@ -300,18 +342,22 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
300
342
  const value = useMemo(() => ({
301
343
  cache, refresh, refreshConversations, refreshUnread, refreshThread, loadMoreMessages, loadThreadReplies, getMessageReadStatus, loadMoreConversations,
302
344
  setConversationArchived, setConversationPreferences, openGroupConversation,
303
- openBroadcastConversation, sendMessage, sendAttachments, toggleReaction, createConversationPoll, castPollVote, closeConversationPoll,
345
+ markConversationRead: markConversationAsRead, markThreadRead: markReplyThreadRead,
346
+ openBroadcastConversation, openDirectConversation, sendMessage, sendAttachments, editMessage, removeMessage, toggleReaction, createConversationPoll, castPollVote, closeConversationPoll,
304
347
  loadConversationConfig, saveConversationConfig, currentUser: user, getAttachment: api.getAttachment,
305
348
  getAttachmentThumbnail: api.getAttachmentThumbnail, activeConversationId,
306
349
  }), [cache, refresh, refreshConversations, refreshUnread, refreshThread, loadMoreMessages, loadThreadReplies, getMessageReadStatus, loadMoreConversations,
307
- setConversationArchived, setConversationPreferences, openGroupConversation,
308
- openBroadcastConversation, sendMessage, sendAttachments, toggleReaction, createConversationPoll, castPollVote, closeConversationPoll,
350
+ setConversationArchived, setConversationPreferences, openGroupConversation, markConversationAsRead, markReplyThreadRead,
351
+ openBroadcastConversation, openDirectConversation, sendMessage, sendAttachments, editMessage, removeMessage, toggleReaction, createConversationPoll, castPollVote, closeConversationPoll,
309
352
  loadConversationConfig, saveConversationConfig, user, api.getAttachment, api.getAttachmentThumbnail, activeConversationId]);
310
353
  return _jsx(MessagingContext.Provider, { value: value, children: children });
311
354
  }
312
355
  export function useMessaging() {
313
- const context = useContext(MessagingContext);
356
+ const context = useOptionalMessaging();
314
357
  if (!context)
315
358
  throw new Error('useMessaging must be used within a MessagingProvider');
316
359
  return context;
317
360
  }
361
+ export function useOptionalMessaging() {
362
+ return useContext(MessagingContext);
363
+ }
@@ -4,7 +4,7 @@ import { Alert, Chip, IconButton, Stack } from '@mui/material';
4
4
  import { useState } from 'react';
5
5
  import { useTranslation } from 'react-i18next';
6
6
  import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
7
- const QUICK_EMOJIS = ['👍', '❤️', '😂', '🎉', '👀'];
7
+ export const QUICK_EMOJIS = ['👍', '❤️', '😂', '🎉', '👀'];
8
8
  /** Independently mountable aggregate reaction controls for one message. */
9
9
  export function ReactionBar({ message }) {
10
10
  const { t } = useTranslation();
@@ -1,5 +1,5 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- import { Tooltip, Typography } from '@mui/material';
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ButtonBase, List, ListItem, Popover, Typography } from '@mui/material';
3
3
  import { useEffect, useState } from 'react';
4
4
  import { useTranslation } from 'react-i18next';
5
5
  import { useMessaging } from './MessagingProvider';
@@ -8,6 +8,7 @@ export function ReadTicks({ messageId, conversation }) {
8
8
  const { t } = useTranslation();
9
9
  const { getMessageReadStatus } = useMessaging();
10
10
  const [status, setStatus] = useState(null);
11
+ const [anchorEl, setAnchorEl] = useState(null);
11
12
  useEffect(() => {
12
13
  let mounted = true;
13
14
  getMessageReadStatus(messageId).then((next) => { if (mounted)
@@ -20,6 +21,8 @@ export function ReadTicks({ messageId, conversation }) {
20
21
  const label = status.all_read ? t('MessagingReadTicks.ALL_READ') : t('MessagingReadTicks.DELIVERED', { count: status.delivered_count || 0 });
21
22
  // The server never returns DM detail; enforce the same carve-out defensively.
22
23
  const detail = (conversation === null || conversation === void 0 ? void 0 : conversation.kind) === 'direct' ? null : status.recipient_detail;
23
- const detailText = detail === null || detail === void 0 ? void 0 : detail.map((recipient) => recipient.display_name || recipient.username || recipient.user_id).filter(Boolean).join(', ');
24
- return _jsx(Tooltip, { title: detailText || label, children: _jsx(Typography, { component: "span", variant: "caption", color: "text.secondary", "aria-label": label, children: label }) });
24
+ const recipients = (detail === null || detail === void 0 ? void 0 : detail.map((recipient) => recipient.display_name || recipient.username || recipient.user_id).filter(Boolean)) || [];
25
+ if (!recipients.length)
26
+ return _jsx(Typography, { component: "span", variant: "caption", color: "text.secondary", "aria-label": label, children: label });
27
+ return _jsxs(_Fragment, { children: [_jsx(ButtonBase, { "aria-label": label, "aria-haspopup": "dialog", "aria-expanded": Boolean(anchorEl), onClick: (event) => setAnchorEl(event.currentTarget), sx: { typography: 'caption', color: 'text.secondary', textDecoration: 'underline' }, children: label }), _jsx(Popover, { open: Boolean(anchorEl), anchorEl: anchorEl, onClose: () => setAnchorEl(null), children: _jsx(List, { "aria-label": t('MessagingReadTicks.RECIPIENTS'), children: recipients.map((recipient) => _jsx(ListItem, { children: recipient }, recipient)) }) })] });
25
28
  }
@@ -1,12 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Alert, Box, Button, CircularProgress, Divider, Stack, Typography } from '@mui/material';
3
- import { useCallback, useContext, useMemo, useRef, useState } from 'react';
3
+ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
4
4
  import { useTranslation } from 'react-i18next';
5
5
  import { AuthContext } from '../auth/AuthContext';
6
6
  import { MessageBubble } from './MessageBubble';
7
7
  import { useMessaging } from './MessagingProvider';
8
8
  import { ReadTicks } from './ReadTicks';
9
9
  function chronological(messages) { return [...messages].sort((left, right) => new Date(left.created_at || 0) - new Date(right.created_at || 0)); }
10
+ function replyToId(message) { var _a; return (_a = message.reply_to_id) !== null && _a !== void 0 ? _a : message.reply_to; }
10
11
  // A still-pending optimistic row (chunk 4) has a fake `local-<requestId>` id
11
12
  // and no durable server row yet — mounting ReadTicks against it would fire a
12
13
  // getMessageReadStatus REST call for an id the server has never heard of.
@@ -26,18 +27,42 @@ function canShowReadTicks(message, user) {
26
27
  * `getMessageReadStatus` REST calls a rendered timeline fires — one per own
27
28
  * message shown, not one per message in the conversation.
28
29
  */
29
- export function Thread({ conversationId, onReplyTargetChange }) {
30
+ export function Thread({ conversationId, onReplyTargetChange, canModerateMessages = false, onAnnouncementLink }) {
30
31
  var _a;
31
32
  const { t } = useTranslation();
32
33
  const { user } = useContext(AuthContext) || {};
33
- const { cache, loadMoreMessages, loadThreadReplies } = useMessaging();
34
+ const { cache, loadMoreMessages, loadThreadReplies, markConversationRead, markThreadRead } = useMessaging();
34
35
  const scrollRef = useRef(null);
36
+ const wasNearBottomRef = useRef(true);
37
+ const messageCountRef = useRef(0);
35
38
  const [loadingOlder, setLoadingOlder] = useState(false);
36
39
  const [error, setError] = useState(null);
37
40
  const [replyingTo, setReplyingTo] = useState(null);
38
41
  const [openThreads, setOpenThreads] = useState({});
39
42
  const conversation = cache.conversations[conversationId];
40
- const roots = useMemo(() => chronological(Object.values(cache.messages).filter((message) => message.conversation_id === conversationId && !message.reply_to)), [cache.messages, conversationId]);
43
+ const conversationMessages = useMemo(() => chronological(Object.values(cache.messages).filter((message) => message.conversation_id === conversationId)), [cache.messages, conversationId]);
44
+ const roots = useMemo(() => chronological(Object.values(cache.messages).filter((message) => message.conversation_id === conversationId && !replyToId(message))), [cache.messages, conversationId]);
45
+ useEffect(() => { if (conversationId != null)
46
+ markConversationRead(conversationId).catch(() => { }); }, [conversationId, markConversationRead]);
47
+ useEffect(() => { wasNearBottomRef.current = true; messageCountRef.current = 0; }, [conversationId]);
48
+ useEffect(() => {
49
+ var _a;
50
+ if (replyingTo && ((_a = cache.messages[replyingTo.id]) === null || _a === void 0 ? void 0 : _a.deleted_at)) {
51
+ setReplyingTo(null);
52
+ onReplyTargetChange === null || onReplyTargetChange === void 0 ? void 0 : onReplyTargetChange(null);
53
+ }
54
+ }, [cache.messages, onReplyTargetChange, replyingTo]);
55
+ useEffect(() => {
56
+ const isNewMessage = conversationMessages.length > messageCountRef.current;
57
+ messageCountRef.current = conversationMessages.length;
58
+ if (!isNewMessage || !wasNearBottomRef.current || !scrollRef.current)
59
+ return;
60
+ const timeline = scrollRef.current;
61
+ if (typeof timeline.scrollTo === 'function')
62
+ timeline.scrollTo({ top: timeline.scrollHeight, behavior: 'smooth' });
63
+ else
64
+ timeline.scrollTop = timeline.scrollHeight;
65
+ }, [conversationMessages]);
41
66
  const loadOlder = useCallback(async () => {
42
67
  setLoadingOlder(true);
43
68
  setError(null);
@@ -51,9 +76,20 @@ export function Thread({ conversationId, onReplyTargetChange }) {
51
76
  setLoadingOlder(false);
52
77
  }
53
78
  }, [conversationId, loadMoreMessages, t]);
54
- const onScroll = () => { var _a; if (((_a = scrollRef.current) === null || _a === void 0 ? void 0 : _a.scrollTop) === 0 && cache.cursors.messages[conversationId] && !loadingOlder)
55
- loadOlder(); };
79
+ const onScroll = () => {
80
+ const timeline = scrollRef.current;
81
+ if (timeline)
82
+ wasNearBottomRef.current = timeline.scrollHeight - timeline.scrollTop - timeline.clientHeight < 80;
83
+ if ((timeline === null || timeline === void 0 ? void 0 : timeline.scrollTop) === 0 && cache.cursors.messages[conversationId] && !loadingOlder)
84
+ loadOlder();
85
+ };
56
86
  const reply = (message) => { setReplyingTo(message); onReplyTargetChange === null || onReplyTargetChange === void 0 ? void 0 : onReplyTargetChange(message); };
87
+ const jumpToMessage = (messageId) => {
88
+ var _a, _b;
89
+ const target = [...(((_a = scrollRef.current) === null || _a === void 0 ? void 0 : _a.querySelectorAll('[data-message-id]')) || [])]
90
+ .find((element) => String(element.dataset.messageId) === String(messageId));
91
+ (_b = target === null || target === void 0 ? void 0 : target.scrollIntoView) === null || _b === void 0 ? void 0 : _b.call(target, { block: 'center', behavior: 'smooth' });
92
+ };
57
93
  const toggleReplies = async (root) => {
58
94
  if (!openThreads[root.id]) {
59
95
  try {
@@ -63,11 +99,12 @@ export function Thread({ conversationId, onReplyTargetChange }) {
63
99
  setError(t('MessagingThread.REPLIES_ERROR'));
64
100
  return;
65
101
  }
102
+ markThreadRead(root.id).catch(() => { });
66
103
  }
67
104
  setOpenThreads((current) => (Object.assign(Object.assign({}, current), { [root.id]: !current[root.id] })));
68
105
  };
69
106
  return (_jsxs(Stack, { spacing: 1, sx: { minWidth: 0 }, children: [error && _jsx(Alert, { severity: "error", role: "alert", children: error }), replyingTo && _jsx(Alert, { severity: "info", onClose: () => { setReplyingTo(null); onReplyTargetChange === null || onReplyTargetChange === void 0 ? void 0 : onReplyTargetChange(null); }, children: t('MessagingThread.REPLYING_TO', { sender: ((_a = replyingTo.sender) === null || _a === void 0 ? void 0 : _a.display_name) || t('MessagingThread.UNKNOWN_SENDER') }) }), _jsxs(Box, { ref: scrollRef, onScroll: onScroll, sx: { maxHeight: 560, overflowY: 'auto', px: 0.5 }, "aria-label": t('MessagingThread.TIMELINE'), children: [cache.cursors.messages[conversationId] && _jsx(Box, { textAlign: "center", py: 1, children: _jsx(Button, { onClick: loadOlder, disabled: loadingOlder, children: loadingOlder ? _jsx(CircularProgress, { size: 18 }) : t('MessagingThread.LOAD_OLDER') }) }), !roots.length && _jsx(Typography, { color: "text.secondary", textAlign: "center", py: 4, children: t('MessagingThread.EMPTY') }), _jsx(Stack, { spacing: 1, children: roots.map((message) => {
70
- const replies = chronological(Object.values(cache.messages).filter((item) => item.reply_to === message.id));
71
- return _jsxs(Stack, { spacing: 0.75, children: [_jsx(MessageBubble, { message: message, onReply: reply, children: canShowReadTicks(message, user) && _jsx(ReadTicks, { messageId: message.id, conversation: conversation }) }), (message.reply_count || replies.length) > 0 && _jsx(Button, { size: "small", onClick: () => toggleReplies(message), children: openThreads[message.id] ? t('MessagingThread.HIDE_REPLIES') : t('MessagingThread.SHOW_REPLIES', { count: message.reply_count || replies.length }) }), openThreads[message.id] && _jsx(Stack, { spacing: 0.75, sx: { pl: 3, borderLeft: 2, borderColor: 'divider' }, children: replies.map((item) => _jsx(MessageBubble, { message: item, replyTo: message, onReply: reply, children: canShowReadTicks(item, user) && _jsx(ReadTicks, { messageId: item.id, conversation: conversation }) }, item.id)) }), _jsx(Divider, {})] }, message.id);
107
+ const replies = chronological(Object.values(cache.messages).filter((item) => String(replyToId(item)) === String(message.id)));
108
+ return _jsxs(Stack, { spacing: 0.75, children: [_jsx(MessageBubble, { message: message, conversation: conversation, onReply: reply, onJumpToMessage: jumpToMessage, onAnnouncementLink: onAnnouncementLink, canModerateMessages: canModerateMessages, children: canShowReadTicks(message, user) && _jsx(ReadTicks, { messageId: message.id, conversation: conversation }) }), (message.reply_count || replies.length) > 0 && _jsx(Button, { size: "small", onClick: () => toggleReplies(message), children: openThreads[message.id] ? t('MessagingThread.HIDE_REPLIES') : t('MessagingThread.SHOW_REPLIES', { count: message.reply_count || replies.length }) }), openThreads[message.id] && _jsx(Stack, { spacing: 0.75, sx: { pl: 3, borderLeft: 2, borderColor: 'divider' }, children: replies.map((item) => _jsx(MessageBubble, { message: item, replyTo: message, conversation: conversation, onReply: reply, onJumpToMessage: jumpToMessage, onAnnouncementLink: onAnnouncementLink, canModerateMessages: canModerateMessages, children: canShowReadTicks(item, user) && _jsx(ReadTicks, { messageId: item.id, conversation: conversation }) }, item.id)) }), _jsx(Divider, {})] }, message.id);
72
109
  }) })] })] }));
73
110
  }
@@ -15,8 +15,8 @@ const conversationsUrl = `${BASE_URL}conversations/`;
15
15
  function data(response) {
16
16
  return response.data;
17
17
  }
18
- function requestConfig({ cursor, params, headers } = {}) {
19
- return Object.assign(Object.assign(Object.assign({}, (params ? { params } : {})), (cursor ? { params: Object.assign(Object.assign({}, (params || {})), { cursor }) } : {})), (headers ? { headers } : {}));
18
+ function requestConfig({ cursor, params, headers, onUploadProgress } = {}) {
19
+ return Object.assign(Object.assign(Object.assign(Object.assign({}, (params ? { params } : {})), (cursor ? { params: Object.assign(Object.assign({}, (params || {})), { cursor }) } : {})), (headers ? { headers } : {})), (onUploadProgress ? { onUploadProgress } : {}));
20
20
  }
21
21
  export function listConversations(_a = {}) {
22
22
  var { cursor } = _a, filters = __rest(_a, ["cursor"]);
@@ -33,7 +33,7 @@ export function archiveConversation(conversationId, archived) { return apiClient
33
33
  export function patchConversationPreferences(conversationId, patch) { return apiClient.post(`${conversationsUrl}${conversationId}/preferences/`, patch).then(data); }
34
34
  export function listMessages(conversationId, { cursor } = {}) { return apiClient.get(`${conversationsUrl}${conversationId}/messages/`, requestConfig({ cursor })).then(data); }
35
35
  export function createMessage(conversationId, payload, { idempotencyKey } = {}) { return apiClient.post(`${conversationsUrl}${conversationId}/messages/`, payload, requestConfig({ headers: idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : undefined })).then(data); }
36
- export function uploadAttachments(conversationId, formData, { idempotencyKey } = {}) { return apiClient.post(`${conversationsUrl}${conversationId}/attachments/`, formData, requestConfig({ headers: idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : undefined })).then(data); }
36
+ export function uploadAttachments(conversationId, formData, { idempotencyKey, onUploadProgress } = {}) { return apiClient.post(`${conversationsUrl}${conversationId}/attachments/`, formData, requestConfig({ headers: idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : undefined, onUploadProgress })).then(data); }
37
37
  export function createPoll(conversationId, payload, { idempotencyKey } = {}) { return apiClient.post(`${conversationsUrl}${conversationId}/polls/`, payload, requestConfig({ headers: idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : undefined })).then(data); }
38
38
  export function getMessage(messageId) { return apiClient.get(`${BASE_URL}messages/${messageId}/`).then(data); }
39
39
  export function patchMessage(messageId, patch) { return apiClient.patch(`${BASE_URL}messages/${messageId}/`, patch).then(data); }
@@ -0,0 +1,36 @@
1
+ const MAX_IMAGE_BYTES = 2 * 1024 * 1024;
2
+ const MAX_IMAGE_EDGE = 2560;
3
+ function imageDimensions(file) {
4
+ return new Promise((resolve, reject) => {
5
+ const url = URL.createObjectURL(file);
6
+ const image = new Image();
7
+ image.onload = () => { URL.revokeObjectURL(url); resolve(image); };
8
+ image.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Unable to decode image')); };
9
+ image.src = url;
10
+ });
11
+ }
12
+ function canvasBlob(canvas) {
13
+ return new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.85));
14
+ }
15
+ /** Compresses large images opportunistically; callers always receive a sendable file. */
16
+ export async function compressImageForUpload(file) {
17
+ var _a, _b;
18
+ if (!((_a = file === null || file === void 0 ? void 0 : file.type) === null || _a === void 0 ? void 0 : _a.startsWith('image/')) || file.size <= MAX_IMAGE_BYTES)
19
+ return file;
20
+ try {
21
+ const image = await imageDimensions(file);
22
+ const largestEdge = Math.max(image.naturalWidth || image.width, image.naturalHeight || image.height);
23
+ if (!largestEdge || largestEdge <= MAX_IMAGE_EDGE)
24
+ return file;
25
+ const scale = MAX_IMAGE_EDGE / largestEdge;
26
+ const canvas = document.createElement('canvas');
27
+ canvas.width = Math.round((image.naturalWidth || image.width) * scale);
28
+ canvas.height = Math.round((image.naturalHeight || image.height) * scale);
29
+ (_b = canvas.getContext('2d')) === null || _b === void 0 ? void 0 : _b.drawImage(image, 0, 0, canvas.width, canvas.height);
30
+ const blob = await canvasBlob(canvas);
31
+ return blob ? new File([blob], file.name.replace(/\.[^.]+$/, '') + '.jpg', { type: 'image/jpeg', lastModified: file.lastModified }) : file;
32
+ }
33
+ catch (_c) {
34
+ return file;
35
+ }
36
+ }
@@ -1,36 +1,101 @@
1
- # Messaging deviations from jg
1
+ # Messaging parity outcomes (MSG-3b)
2
2
 
3
- ## Chunk 5 — reactions, polls and scope configuration
3
+ This is the authoritative MSG-3b parity record. It replaces the former
4
+ file-level “final parity confirmation,” which was not sufficient evidence of
5
+ feature parity. Each entry maps directly to the numbered checklist in Part A
6
+ of `work-orders/MSG-3b.md`. `OK` means the capability is present; `DEV` means
7
+ an intentional, documented redesign or non-parity; `BLOCKED` means dcm 2.36.1
8
+ does not expose the data or realtime contract needed to implement it truthfully.
4
9
 
5
- - Reactions use a compact set of common emoji choices plus the aggregate chips, rather than jg's full third-party emoji palette. Adding and toggling every reaction remains available through the same REST mutations; the shared package avoids a new picker dependency.
6
- - Poll creation is a Composer dialog instead of jg's Thread-coupled dialog. It retains question, two-or-more options and multiple-choice selection, while keeping composition independently mountable.
7
- - Poll results are rendered inline as an accessible option list with live aggregate counts. Closing is only offered when the serialized creator/capability allows it; a server-side 403 remains visible as a validation error.
8
- - Scope messaging configuration is an independently mountable component addressed by conversation id, rather than jg's event-id-specific settings panel. The host supplies placement and capability gating; it retains dm policy, group-chat enablement and everyone-can-post controls.
9
- - **Announcement composing (jg's `AnnouncementDialog.jsx`) was missing from the first chunk-5 pass — flagged by independent review and corrected before commit, not silently accepted as a gap.** `Composer` now offers an announcement mode (title + body + a host-supplied `link_target`) behind an `allowAnnouncement` prop, since whether to offer it at all is a host/capability decision the client cannot make on its own; only the target-link *value* is host-specific navigation context ucm cannot compute (jg pre-fills it from the currently open event-info section — same reasoning as `linkTarget`'s framing here). Uses the same `sendMessage`/`kind` plumbing chunk 1 already built, with `kind: 'announcement'`.
10
+ ## Unread lifecycle
10
11
 
11
- ## Final parity confirmation
12
+ 1. **OK** — Selecting a conversation calls the existing mark-read endpoint and clears its local unread count.
13
+ 2. **OK** — Opening a `Thread` also marks its conversation read; the duplicate call is idempotent.
14
+ 3. **OK** — Marking a conversation read decrements the global count by that conversation’s prior unread value, clamped at zero.
15
+ 4. **OK** — Expanding a reply thread calls the existing thread-read endpoint for its root message.
16
+ 5. **OK** — Realtime incoming messages increment unread only outside the active conversation and never double-count optimistic-send reconciliation.
12
17
 
13
- - The provider/cache, conversation list and launchers, timeline/replies/read ticks, composer/attachments/announcements, reactions, polls and scope configuration are all represented by exported ucm surfaces. Host routing and scope selection remain host responsibilities by design.
14
- - The known broadcast-list pre-open unread/preview gap is retained as the accepted Chunk 2 deviation.
15
- - Every jg messaging file named in the work order's parity inventory (`Thread.jsx`, `ConversationList.jsx`, `MessagingContext.jsx`, `AnnouncementDialog.jsx`, `MessagingConfig.jsx`, `NewDirectMessageDialog.jsx` — unblocked by MSG-2b, `messagingApi.js`, `EmojiPickerButton.jsx`, `conversationHelpers.js`, `MessagesPage.jsx`'s launcher behavior) is represented by an exported ucm surface or an explicitly named deviation above — this line was corrected during chunk 5's independent review, which is exactly the check it exists to catch.
18
+ ## Message actions
16
19
 
17
- ## Chunk 4 — composer and attachments
20
+ 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.
21
+ 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.
22
+ 8. **OK** — An active editor closes when the message is deleted by another update.
23
+ 9. **OK** — Authors and host-designated moderators can delete; the host boolean is UX gating only and dcm enforces the actual permission.
24
+ 10. **OK** — Deletion is confirmed in a MUI dialog before the request is sent.
25
+ 11. **OK** — A deleted reply/edit target clears dependent composer or editor state.
26
+ 12. **OK** — Message text can be copied through the action menu.
27
+ 13. **OK** — Reply targeting is present.
28
+ 14. **DEV** — Reactions use five compact presets rather than jg’s six-preset presentation.
29
+ 15. **DEV** — No full third-party emoji picker is added; the compact preset menu keeps the package dependency-free.
30
+ 16. **OK** — Deleted and poll messages do not render the generic action menu.
18
31
 
19
- - The composer is an independently mountable provider collaborator and accepts the timeline's reply target as a small placement prop, instead of being coupled into jg's monolithic `Thread`. It preserves reply selection and clears that selection after a successful send.
20
- - Attachment previews are compact authenticated thumbnail fetches with explicit download actions rather than jg's gallery/lightbox. Preview and download retain viewer-gated byte access; the licensed interaction redesign avoids a second media-viewer state machine in this shared surface.
32
+ ## Direct conversations
21
33
 
22
- ## Chunk 3 — timeline, replies and receipts
34
+ 17. **OK** — `DirectMessageLauncher` starts a direct conversation.
35
+ 18. **OK** — The launcher displays the host-supplied candidate list and selection state.
36
+ 19. **OK** — It has distinct empty and in-flight creation states; candidate discovery remains host-owned because dcm has no directory endpoint.
37
+ 20. **OK** — Start is disabled until a candidate is selected and during creation.
38
+ 21. **OK** — Policy rejections render an API-derived readable error in the dialog.
39
+ 22. **OK** — The picker uses a fullscreen MUI dialog at the small breakpoint.
23
40
 
24
- - The timeline is split into `Thread`, `MessageBubble` and `ReadTicks`, with a simple expandable reply view rather than jg's coupled full-screen/thread-pane state. This preserves one-level reply viewing while allowing each surface to mount independently.
25
- - Reply selection is exposed through `onReplyTargetChange` rather than embedding a temporary composer. The next Composer surface consumes that context, avoiding a second compose state machine.
26
- - Older history automatically loads when the timeline reaches the top and also provides an explicit accessible button. Both use the server-issued opaque cursor; jg's timeline interaction is redesigned, not removed.
27
- - This chunk intentionally leaves reactions, polls and attachments as non-interactive count/type chips. Their existing jg capabilities are scheduled for chunks 4/5, rather than reproduced as disposable interaction implementations here.
28
- - Read ticks reproduce jg's `shouldShowReadTick(msg, isOwn)` semantics exactly: a `ReadTicks` collaborator is only mounted for the current user's own messages, matching jg's meaning of a read receipt (whether the sender's own message was seen) and bounding the number of `getMessageReadStatus` REST calls a rendered timeline fires to one per own message shown, not one per message in the conversation.
41
+ ## Threading and quoting
29
42
 
30
- ## Chunk 2 — conversation list and launchers
43
+ 23. **OK** — Both dcm’s `reply_to_id` and optimistic rows’ `reply_to` are read, so REST and realtime replies group under their root.
44
+ 24. **OK** — Replies show a sender and short quoted snippet.
45
+ 25. **OK** — Clicking a quote scrolls its source message into view.
46
+ 26. **OK** — Quotes of deleted messages render the deleted placeholder rather than stale content.
47
+ 27. **BLOCKED** — 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.
48
+ 28. **OK** — Reply threads load lazily on expand and collapse locally.
49
+ 29. **DEV** — Reply composition is supplied through the independently mountable `Composer` target prop rather than jg’s per-thread embedded composer.
31
50
 
32
- - The list is a standalone, provider-backed component rather than receiving a conversation array and selection state from a page. This removes the page-owned messaging state machine while preserving selection through the host's `onOpen` placement callback.
33
- - Launchable group metadata is an explicit host-supplied `groupLaunchers` input, rather than jg's event-specific `listGroupsAvailable` request. The published dcm REST contract has no generic group-discovery endpoint; apps own scope pickers while ucm owns opening and surfacing the conversation.
34
- - Broadcast opening is exposed as `broadcastLauncher` plus `autoOpenBroadcast`, rather than inspecting jg's `?tab=broadcast` query itself. Routing/query parsing stays host-app scope; auto-*open* is reproduced once the host maps its route to that boolean.
35
- - **Accepted deviation, not full parity:** jg's `MessagesPage.jsx` unconditionally fetches the broadcast conversation on every event load and pins it, with live unread count and message preview, at the top of the same recency-sorted unified list. ucm instead renders a static launcher button (no unread badge, no preview) until it is opened for the first time, at which point it becomes a normal list row with real unread/preview state and the launcher hides itself. Before that first open, a host relying on the broadcast conversation's unread state being visible without a click does not get it from ucm.
36
- - The layout and actions are redesigned as a compact MUI list with per-conversation overflow actions and explicit cursor “Load more,” replacing jg's page sidebar composition. Features retained here are unread indication, opening conversations, unopened-group launches, broadcast launch (see above for the surfacing gap), mute and archive. Per-kind row icon (group/broadcast/DM) is reproduced (jg's `getConversationAvatar`), using generic MUI icons rather than jg's per-kind avatar treatment (incl. the DM partner's actual `UserAvatar`) — the identification capability is kept, the specific visual is redesigned.
51
+ ## Composer
52
+
53
+ 30. **OK** — Enter sends and Shift+Enter preserves a newline.
54
+ 31. **OK** — Staged files show image thumbnails or file labels, with independent removal.
55
+ 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.
56
+ 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.
57
+ 34. **DEV** — Composer emoji insertion uses the existing compact preset set instead of jg’s full picker, while preserving insertion at the cursor.
58
+ 35. **OK** — Sending is optimistic and exposes retry plus inline error state.
59
+ 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.
60
+ 37. **DEV** — Drafts are deliberately reset on conversation changes. jg’s cross-conversation draft leak is not reproduced.
61
+
62
+ ## Conversation list
63
+
64
+ 38. **BLOCKED** — dcm does not serialize `last_message`, so a last-message preview cannot be made correct against the real backend.
65
+ 39. **OK** — `last_message_at` renders through browser-native, locale-aware `Intl.RelativeTimeFormat` labels.
66
+ 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.
67
+ 41. **OK** — Unread badges and bold unread titles are present.
68
+ 42. **BLOCKED** — dcm stores the managed all/team distinction in `external_key` but omits it from conversation serialization. The UI must not guess from titles or other unrelated fields.
69
+ 43. **DEV** — Generic MUI kind icons replace jg’s richer per-kind/person avatar treatment while retaining type identification.
70
+ 44. **OK** — Mute and archive actions are present.
71
+
72
+ ## Timeline
73
+
74
+ 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.
75
+ 46. **OK** — Announcement messages render their deep-link action only when a host supplies `onAnnouncementLink`; routing remains a host-app responsibility.
76
+ 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.
77
+ 48. **OK** — Deleted tombstones, edited marker, and timestamps are rendered.
78
+
79
+ ## Read receipts
80
+
81
+ 49. **OK** — Read ticks mount only for the current user’s own persisted messages.
82
+ 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.
83
+
84
+ ## Polls
85
+
86
+ 51. **BLOCKED** — dcm has no poll read serialization for question, option, or count data; client-side fabrication would hide the backend contract gap.
87
+ 52. **BLOCKED** — Per-option voter names are likewise absent from dcm’s readable poll contract.
88
+ 53. **BLOCKED** — Closed state and disabled-voting rendering depend on that absent poll read contract.
89
+ 54. **OK** — Composer starts at two options and disables adding options once it reaches ten; the server’s two-option minimum remains enforced.
90
+ 55. **OK** — Vote and close write paths are wired; their full read rendering remains subject to rows 51–53.
91
+
92
+ ## Live synchronization
93
+
94
+ 56. **BLOCKED** — Reaction frame handling remains intentionally ready, but dcm 2.36.1 does not emit `reaction` frames.
95
+ 57. **BLOCKED** — Poll-update frame handling remains intentionally ready, but dcm 2.36.1 does not emit `poll_updated` frames.
96
+ 58. **BLOCKED** — Conversation reorder and preview refresh require dcm’s missing `last_message` projection.
97
+ 59. **OK** — dcm’s emitted message, message-edited, and message-deleted frames are handled live.
98
+
99
+ ## Required backend follow-up
100
+
101
+ Rows 27, 38, 42, 51–53, and 56–58 require a future dcm work order: expose readable thread receipt state, serialize `external_key` where managed all/team labeling is required, define and serialize the poll read contract, add `last_message`, and emit the designed realtime frames. Until then, these are explicit BLOCKED outcomes rather than simulated client behavior.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "2.16.0",
3
+ "version": "2.17.0",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "repository": {
@@ -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/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';