@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.
Files changed (46) hide show
  1. package/WORK_ORDERS.md +3 -1
  2. package/dev/entries.jsx +5 -1
  3. package/dist/i18n/messagingTranslations.js +27 -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 +102 -21
  10. package/dist/messaging/PollCard.js +22 -5
  11. package/dist/messaging/ReactionBar.js +1 -1
  12. package/dist/messaging/ReadTicks.js +7 -4
  13. package/dist/messaging/Thread.js +45 -8
  14. package/dist/messaging/api.js +3 -3
  15. package/dist/messaging/composerImageCompression.js +36 -0
  16. package/docs/messaging-deviations.md +108 -26
  17. package/package.json +1 -1
  18. package/src/i18n/messagingTranslations.ts +27 -0
  19. package/src/index.js +1 -0
  20. package/src/messaging/Composer.jsx +54 -8
  21. package/src/messaging/ConversationList.jsx +10 -4
  22. package/src/messaging/DirectMessageLauncher.jsx +96 -0
  23. package/src/messaging/MessageBubble.jsx +74 -7
  24. package/src/messaging/MessagingProvider.jsx +124 -15
  25. package/src/messaging/PollCard.jsx +19 -3
  26. package/src/messaging/ReactionBar.jsx +1 -1
  27. package/src/messaging/ReadTicks.jsx +5 -3
  28. package/src/messaging/Thread.jsx +39 -8
  29. package/src/messaging/api.js +3 -2
  30. package/src/messaging/composerImageCompression.js +35 -0
  31. package/tests/ConversationList.test.jsx +1 -1
  32. package/tests/Thread.test.jsx +1 -1
  33. package/tests/messagingChunk6.test.jsx +109 -0
  34. package/tests/messagingComposerChunk5.test.jsx +110 -0
  35. package/tests/messagingContractConformance.test.js +69 -0
  36. package/tests/messagingDirectMessageLauncher.test.jsx +78 -0
  37. package/tests/messagingExports.test.js +2 -1
  38. package/tests/messagingInteractions.test.jsx +3 -1
  39. package/tests/messagingLastMessagePreview.test.jsx +93 -0
  40. package/tests/messagingMessageActions.test.jsx +93 -0
  41. package/tests/messagingPollRendering.test.jsx +115 -0
  42. package/tests/messagingReactionMerge.test.jsx +79 -0
  43. package/tests/messagingThreadingQuoting.test.jsx +86 -0
  44. package/tests/messagingUnreadLifecycle.test.jsx +63 -0
  45. package/work-orders/MSG-3b.md +1002 -0
  46. package/work-orders/MSG-3c.md +378 -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,29 +81,70 @@ 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, _e, _f;
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 }) }) });
108
+ }
109
+ if (frame.type === 'message_edited') {
110
+ // dcm's publish_messaging_event always attaches a full frame.message
111
+ // (serialize_message) for this event type, so `payload` already carries
112
+ // the message's own `.id` in practice. The explicit `id:` override below
113
+ // is defensive only — it keeps this branch correct even if a future
114
+ // producer ever omits `.message` and falls through to the raw frame,
115
+ // whose `conversation_id` would otherwise win idOf()'s fallback order.
116
+ const messageId = (_c = frame.message_id) !== null && _c !== void 0 ? _c : payload.message_id;
117
+ return Object.assign(Object.assign({}, state), { messages: mergeById(state.messages, Object.assign(Object.assign({}, payload), { id: messageId })) });
93
118
  }
94
- if (frame.type === 'message_edited')
95
- return Object.assign(Object.assign({}, state), { messages: mergeById(state.messages, payload) });
96
119
  if (frame.type === 'reaction') {
97
- const messageId = (_b = frame.message_id) !== null && _b !== void 0 ? _b : payload.message_id;
120
+ const messageId = (_d = frame.message_id) !== null && _d !== void 0 ? _d : payload.message_id;
98
121
  const previous = state.messages[messageId] || { id: messageId };
99
- return Object.assign(Object.assign({}, state), { messages: mergeById(state.messages, Object.assign(Object.assign({}, previous), { reactions: payload.reactions || frame.reactions || previous.reactions || [] })) });
122
+ // serialize_reactions (dcm) returns [{emoji, count}] only, never a
123
+ // per-viewer `reacted` flag, in any context — not REST, not frames, by
124
+ // design (aggregate-only). Wholesale-replacing `reactions` with the
125
+ // incoming server list would silently wipe every previously-known
126
+ // `reacted` flag on this message, not just whichever emoji changed.
127
+ // Merge by emoji instead: carry forward a previously-known `reacted`
128
+ // flag for any emoji still present in the incoming aggregate.
129
+ const incoming = payload.reactions || frame.reactions || previous.reactions || [];
130
+ const previousByEmoji = new Map((previous.reactions || []).map((reaction) => [reaction.emoji, reaction]));
131
+ const reactions = incoming.map((reaction) => { var _a, _b, _c; return (Object.assign(Object.assign({}, reaction), { reacted: (_c = (_a = reaction.reacted) !== null && _a !== void 0 ? _a : (_b = previousByEmoji.get(reaction.emoji)) === null || _b === void 0 ? void 0 : _b.reacted) !== null && _c !== void 0 ? _c : false })); });
132
+ return Object.assign(Object.assign({}, state), { messages: mergeById(state.messages, Object.assign(Object.assign({}, previous), { reactions })) });
100
133
  }
101
134
  if (frame.type === 'poll_updated') {
102
- const messageId = (_c = frame.message_id) !== null && _c !== void 0 ? _c : payload.message_id;
135
+ const messageId = (_e = frame.message_id) !== null && _e !== void 0 ? _e : payload.message_id;
103
136
  const previous = state.messages[messageId] || { id: messageId };
104
- const poll = payload.poll || frame.poll || payload;
137
+ const incomingPoll = payload.poll || frame.poll || payload;
138
+ // dcm's poll_updated frame is a viewer-independent projection
139
+ // (serialize_poll) — it never carries voted_option_ids, which dcm only
140
+ // ever adds to this viewer's own vote/create/close REST response. A
141
+ // frame arriving after a REST-derived vote is known must not wipe that
142
+ // locally-known selection; carry it forward when the frame is silent
143
+ // about it, never invert it from frame data that structurally can't
144
+ // contain it.
145
+ const previousVotedOptionIds = (_f = previous.poll) === null || _f === void 0 ? void 0 : _f.voted_option_ids;
146
+ const poll = incomingPoll.voted_option_ids === undefined && previousVotedOptionIds
147
+ ? Object.assign(Object.assign({}, incomingPoll), { voted_option_ids: previousVotedOptionIds }) : incomingPoll;
105
148
  return Object.assign(Object.assign({}, state), { messages: mergeById(state.messages, Object.assign(Object.assign({}, previous), { poll })), polls: mergeById(state.polls, poll) });
106
149
  }
107
150
  if (frame.type === 'message_deleted') {
@@ -180,6 +223,12 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
180
223
  const conversation = await api.patchConversationPreferences(conversationId, patch);
181
224
  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
225
  }, [api, updateConversation]);
226
+ const markConversationAsRead = useCallback(async (conversationId, readAt) => {
227
+ const result = await api.markConversationRead(conversationId, readAt);
228
+ dispatch({ type: 'conversationRead', conversationId });
229
+ return result;
230
+ }, [api]);
231
+ const markReplyThreadRead = useCallback((rootId, readAt) => api.markThreadRead(rootId, readAt), [api]);
183
232
  const openGroupConversation = useCallback(async (payload) => {
184
233
  const conversation = await api.createGroupConversation(payload);
185
234
  return updateConversation(conversation);
@@ -188,6 +237,10 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
188
237
  const conversation = await api.createBroadcastConversation(payload);
189
238
  return updateConversation(conversation);
190
239
  }, [api, updateConversation]);
240
+ const openDirectConversation = useCallback(async (payload) => {
241
+ const conversation = await api.createDirectConversation(payload);
242
+ return updateConversation(conversation);
243
+ }, [api, updateConversation]);
191
244
  const sendMessage = useCallback(async (conversationId, payload, { clientRequestId, retry = false } = {}) => {
192
245
  var _a, _b;
193
246
  const requestId = clientRequestId || payload.client_request_id;
@@ -206,7 +259,7 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
206
259
  throw error;
207
260
  }
208
261
  }, [api, user]);
209
- const sendAttachments = useCallback(async (conversationId, formData, { clientRequestId, optimisticMessage, retry = false } = {}) => {
262
+ const sendAttachments = useCallback(async (conversationId, formData, { clientRequestId, optimisticMessage, retry = false, onUploadProgress } = {}) => {
210
263
  var _a, _b;
211
264
  const optimistic = {
212
265
  id: `local-${clientRequestId}`, conversation_id: conversationId, kind: 'chat', body: (optimisticMessage === null || optimisticMessage === void 0 ? void 0 : optimisticMessage.body) || '',
@@ -219,7 +272,7 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
219
272
  else
220
273
  dispatch({ type: 'messageReconciled', message: Object.assign(Object.assign({}, optimistic), { status: 'pending', error: null }) });
221
274
  try {
222
- const message = await api.uploadAttachments(conversationId, formData, { idempotencyKey: clientRequestId });
275
+ const message = await api.uploadAttachments(conversationId, formData, { idempotencyKey: clientRequestId, onUploadProgress });
223
276
  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
277
  return message;
225
278
  }
@@ -229,6 +282,23 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
229
282
  }
230
283
  }, [api, user]);
231
284
  const patchMessage = useCallback((message) => dispatch({ type: 'messagePatched', message }), []);
285
+ // Keep this API-backed action deliberately distinct from the cache-merge
286
+ // helper above. The server remains the authority for author/moderator rights.
287
+ const editMessage = useCallback(async (messageId, patch) => {
288
+ var _a;
289
+ const result = await api.patchMessage(messageId, patch);
290
+ const message = (result === null || result === void 0 ? void 0 : result.message) || result;
291
+ 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 }));
292
+ return result;
293
+ }, [api, cache.messages, patchMessage]);
294
+ const removeMessage = useCallback(async (messageId) => {
295
+ var _a, _b, _c, _d, _e;
296
+ const result = await api.deleteMessage(messageId);
297
+ const message = (result === null || result === void 0 ? void 0 : result.message) || result;
298
+ const previous = cache.messages[messageId] || { id: messageId };
299
+ 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 }));
300
+ return result;
301
+ }, [api, cache.messages, patchMessage, user]);
232
302
  const toggleReaction = useCallback(async (messageId, emoji, active) => {
233
303
  var _a;
234
304
  const message = cache.messages[messageId] || { id: messageId };
@@ -242,7 +312,14 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
242
312
  patchMessage(Object.assign(Object.assign({}, message), { reactions: nextReactions }));
243
313
  try {
244
314
  const result = active ? await api.removeReaction(messageId, emoji) : await api.addReaction(messageId, emoji);
245
- patchMessage(Object.assign(Object.assign(Object.assign({}, message), ((result === null || result === void 0 ? void 0 : result.message) || result)), { reactions: (result === null || result === void 0 ? void 0 : result.reactions) || ((_a = result === null || result === void 0 ? void 0 : result.message) === null || _a === void 0 ? void 0 : _a.reactions) || nextReactions }));
315
+ // Same aggregate-only shape as the realtime `reaction` frame (serialize_reactions
316
+ // never returns a per-viewer `reacted` flag) — merge the server's confirmed
317
+ // counts with the reacted flags nextReactions already computed correctly,
318
+ // rather than letting the reacted-less server array wipe them.
319
+ const confirmedCounts = (result === null || result === void 0 ? void 0 : result.reactions) || ((_a = result === null || result === void 0 ? void 0 : result.message) === null || _a === void 0 ? void 0 : _a.reactions);
320
+ const nextByEmoji = new Map(nextReactions.map((reaction) => [reaction.emoji, reaction]));
321
+ const reactions = confirmedCounts ? confirmedCounts.map((reaction) => { var _a, _b, _c; return (Object.assign(Object.assign({}, reaction), { reacted: (_c = (_a = reaction.reacted) !== null && _a !== void 0 ? _a : (_b = nextByEmoji.get(reaction.emoji)) === null || _b === void 0 ? void 0 : _b.reacted) !== null && _c !== void 0 ? _c : false })); }) : nextReactions;
322
+ patchMessage(Object.assign(Object.assign(Object.assign({}, message), ((result === null || result === void 0 ? void 0 : result.message) || result)), { reactions }));
246
323
  return result;
247
324
  }
248
325
  catch (error) {
@@ -290,7 +367,7 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
290
367
  if (seenEventsRef.current.size > 500)
291
368
  seenEventsRef.current.delete(seenEventsRef.current.values().next().value);
292
369
  }
293
- dispatch({ type: 'frame', frame });
370
+ dispatch({ type: 'frame', frame, activeConversationId: activeIdRef.current });
294
371
  }), [subscribe]);
295
372
  useEffect(() => {
296
373
  if (!active || !onReconnect)
@@ -300,18 +377,22 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
300
377
  const value = useMemo(() => ({
301
378
  cache, refresh, refreshConversations, refreshUnread, refreshThread, loadMoreMessages, loadThreadReplies, getMessageReadStatus, loadMoreConversations,
302
379
  setConversationArchived, setConversationPreferences, openGroupConversation,
303
- openBroadcastConversation, sendMessage, sendAttachments, toggleReaction, createConversationPoll, castPollVote, closeConversationPoll,
380
+ markConversationRead: markConversationAsRead, markThreadRead: markReplyThreadRead,
381
+ openBroadcastConversation, openDirectConversation, sendMessage, sendAttachments, editMessage, removeMessage, toggleReaction, createConversationPoll, castPollVote, closeConversationPoll,
304
382
  loadConversationConfig, saveConversationConfig, currentUser: user, getAttachment: api.getAttachment,
305
383
  getAttachmentThumbnail: api.getAttachmentThumbnail, activeConversationId,
306
384
  }), [cache, refresh, refreshConversations, refreshUnread, refreshThread, loadMoreMessages, loadThreadReplies, getMessageReadStatus, loadMoreConversations,
307
- setConversationArchived, setConversationPreferences, openGroupConversation,
308
- openBroadcastConversation, sendMessage, sendAttachments, toggleReaction, createConversationPoll, castPollVote, closeConversationPoll,
385
+ setConversationArchived, setConversationPreferences, openGroupConversation, markConversationAsRead, markReplyThreadRead,
386
+ openBroadcastConversation, openDirectConversation, sendMessage, sendAttachments, editMessage, removeMessage, toggleReaction, createConversationPoll, castPollVote, closeConversationPoll,
309
387
  loadConversationConfig, saveConversationConfig, user, api.getAttachment, api.getAttachmentThumbnail, activeConversationId]);
310
388
  return _jsx(MessagingContext.Provider, { value: value, children: children });
311
389
  }
312
390
  export function useMessaging() {
313
- const context = useContext(MessagingContext);
391
+ const context = useOptionalMessaging();
314
392
  if (!context)
315
393
  throw new Error('useMessaging must be used within a MessagingProvider');
316
394
  return context;
317
395
  }
396
+ export function useOptionalMessaging() {
397
+ return useContext(MessagingContext);
398
+ }
@@ -5,19 +5,27 @@ import { useTranslation } from 'react-i18next';
5
5
  import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
6
6
  /** Independently mountable live poll view. Server authorization remains authoritative. */
7
7
  export function PollCard({ message, canClose: canCloseProp }) {
8
- var _a, _b, _c, _d, _e, _f;
8
+ var _a, _b, _c, _d, _e, _f, _g;
9
9
  const { t } = useTranslation();
10
10
  const { castPollVote, closeConversationPoll, currentUser } = useMessaging();
11
11
  const poll = message === null || message === void 0 ? void 0 : message.poll;
12
12
  const [selected, setSelected] = useState([]);
13
13
  const [error, setError] = useState(null);
14
14
  const [busy, setBusy] = useState(false);
15
- useEffect(() => setSelected(((poll === null || poll === void 0 ? void 0 : poll.options) || []).filter((option) => option.selected || option.voted).map((option) => option.id)), [poll]);
15
+ // voted_option_ids is a view-level enrichment dcm adds ONLY to the
16
+ // create/vote/close poll REST responses (serialize_poll itself, every
17
+ // message-list/thread load, and every poll_updated realtime frame never
18
+ // carry it) — so it's only ever present right after this viewer's own
19
+ // vote/create/close action. When it's absent (a freshly loaded or
20
+ // frame-updated poll), there's no way to know this viewer's vote from the
21
+ // payload alone; default to no selection rather than guessing or carrying
22
+ // over stale local state.
23
+ useEffect(() => setSelected((poll === null || poll === void 0 ? void 0 : poll.voted_option_ids) ? [...poll.voted_option_ids] : []), [poll]);
16
24
  if (!poll)
17
25
  return null;
18
26
  const closed = Boolean(poll.closed_at);
19
- const creatorId = (_b = (_a = poll.created_by) === null || _a === void 0 ? void 0 : _a.id) !== null && _b !== void 0 ? _b : poll.created_by;
20
- const canClose = (_f = (_d = (_c = canCloseProp !== null && canCloseProp !== void 0 ? canCloseProp : poll.can_close) !== null && _c !== void 0 ? _c : message === null || message === void 0 ? void 0 : message.can_close) !== null && _d !== void 0 ? _d : (_e = message === null || message === void 0 ? void 0 : message.permissions) === null || _e === void 0 ? void 0 : _e.can_close) !== null && _f !== void 0 ? _f : (creatorId != null && creatorId === (currentUser === null || currentUser === void 0 ? void 0 : currentUser.id));
27
+ const creatorId = (_c = (_a = poll.created_by_id) !== null && _a !== void 0 ? _a : (_b = poll.created_by) === null || _b === void 0 ? void 0 : _b.id) !== null && _c !== void 0 ? _c : poll.created_by;
28
+ const canClose = (_g = (_e = (_d = canCloseProp !== null && canCloseProp !== void 0 ? canCloseProp : poll.can_close) !== null && _d !== void 0 ? _d : message === null || message === void 0 ? void 0 : message.can_close) !== null && _e !== void 0 ? _e : (_f = message === null || message === void 0 ? void 0 : message.permissions) === null || _f === void 0 ? void 0 : _f.can_close) !== null && _g !== void 0 ? _g : (creatorId != null && creatorId === (currentUser === null || currentUser === void 0 ? void 0 : currentUser.id));
21
29
  const changeSelection = (id) => setSelected((current) => poll.allow_multiple ? (current.includes(id) ? current.filter((value) => value !== id) : [...current, id]) : (current.includes(id) ? [] : [id]));
22
30
  const vote = async () => {
23
31
  if (!selected.length) {
@@ -49,5 +57,14 @@ export function PollCard({ message, canClose: canCloseProp }) {
49
57
  setBusy(false);
50
58
  }
51
59
  };
52
- return _jsx(Paper, { variant: "outlined", sx: { p: 1 }, "aria-label": t('MessagingPoll.LABEL'), children: _jsxs(Stack, { spacing: 0.5, children: [_jsx(Typography, { variant: "subtitle2", children: poll.question }), (poll.options || []).map((option) => { var _a, _b; return _jsx(FormControlLabel, { disabled: closed || busy, control: _jsx(Checkbox, { checked: selected.includes(option.id), onChange: () => changeSelection(option.id) }), label: `${option.text || option.label} (${(_b = (_a = option.vote_count) !== null && _a !== void 0 ? _a : option.votes_count) !== null && _b !== void 0 ? _b : 0})` }, option.id); }), error && _jsx(Alert, { severity: "error", role: "alert", children: error }), _jsxs(Stack, { direction: "row", spacing: 1, children: [_jsx(Button, { size: "small", onClick: vote, disabled: closed || busy, children: t('MessagingPoll.VOTE') }), canClose && !closed && _jsx(Button, { size: "small", color: "warning", onClick: close, disabled: busy, children: t('MessagingPoll.CLOSE') }), closed && _jsx(Typography, { variant: "caption", children: t('MessagingPoll.CLOSED') })] })] }) });
60
+ return _jsx(Paper, { variant: "outlined", sx: { p: 1 }, "aria-label": t('MessagingPoll.LABEL'), children: _jsxs(Stack, { spacing: 0.5, children: [_jsx(Typography, { variant: "subtitle2", children: poll.question }), (poll.options || []).map((option) => {
61
+ var _a, _b, _c, _d, _e, _f, _g;
62
+ // dcm's poll projection gives voter user ids only, never names — there
63
+ // is no user-directory endpoint (same structural limit already
64
+ // established for the DM candidate list). A count is always correct;
65
+ // marking the viewer's own vote is derivable from voted_option_ids.
66
+ const votedByMe = Boolean((_a = poll.voted_option_ids) === null || _a === void 0 ? void 0 : _a.includes(option.id));
67
+ const voterCount = (_e = (_d = (_c = (_b = option.voters) === null || _b === void 0 ? void 0 : _b.length) !== null && _c !== void 0 ? _c : option.vote_count) !== null && _d !== void 0 ? _d : option.votes_count) !== null && _e !== void 0 ? _e : 0;
68
+ return _jsx(FormControlLabel, { disabled: closed || busy, control: _jsx(Checkbox, { checked: selected.includes(option.id), onChange: () => changeSelection(option.id) }), label: `${option.text || option.label} (${(_g = (_f = option.vote_count) !== null && _f !== void 0 ? _f : voterCount) !== null && _g !== void 0 ? _g : 0})${votedByMe ? ` — ${t('MessagingPoll.YOU_VOTED')}` : ''}` }, option.id);
69
+ }), error && _jsx(Alert, { severity: "error", role: "alert", children: error }), _jsxs(Stack, { direction: "row", spacing: 1, children: [_jsx(Button, { size: "small", onClick: vote, disabled: closed || busy, children: t('MessagingPoll.VOTE') }), canClose && !closed && _jsx(Button, { size: "small", color: "warning", onClick: close, disabled: busy, children: t('MessagingPoll.CLOSE') }), closed && _jsx(Typography, { variant: "caption", children: t('MessagingPoll.CLOSED') })] })] }) });
53
70
  }
@@ -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
+ }