@micha.bigler/ui-core-micha 2.14.0 → 2.16.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 (71) hide show
  1. package/README.md +7 -0
  2. package/WORK_ORDERS.md +8 -2
  3. package/dev/entries.jsx +147 -0
  4. package/dev/fixtures.js +26 -0
  5. package/dev/main.jsx +56 -0
  6. package/dev/mockTransport.jsx +128 -0
  7. package/dist/components/charts/BarChart.js +32 -0
  8. package/dist/components/charts/ChartFrame.js +50 -0
  9. package/dist/components/charts/LineChart.js +32 -0
  10. package/dist/components/charts/exportChart.js +67 -0
  11. package/dist/components/charts/formatters.js +23 -0
  12. package/dist/components/charts/palette.js +21 -0
  13. package/dist/i18n/chartsTranslations.js +38 -0
  14. package/dist/i18n/messagingTranslations.js +81 -0
  15. package/dist/index.js +25 -4
  16. package/dist/messaging/AttachmentList.js +43 -0
  17. package/dist/messaging/Composer.js +120 -0
  18. package/dist/messaging/ConversationLaunchers.js +39 -0
  19. package/dist/messaging/ConversationList.js +75 -0
  20. package/dist/messaging/MessageBubble.js +17 -0
  21. package/dist/messaging/MessagingProvider.js +317 -0
  22. package/dist/messaging/MessagingScopeConfig.js +28 -0
  23. package/dist/messaging/PollCard.js +53 -0
  24. package/dist/messaging/ReactionBar.js +44 -0
  25. package/dist/messaging/ReadTicks.js +25 -0
  26. package/dist/messaging/Thread.js +73 -0
  27. package/dist/messaging/api.js +51 -0
  28. package/dist/notifications/NotificationsProvider.js +2 -2
  29. package/dist/notifications/realtime.js +21 -1
  30. package/docs/messaging-deviations.md +36 -0
  31. package/index.html +12 -0
  32. package/package.json +6 -1
  33. package/src/components/charts/BarChart.jsx +44 -0
  34. package/src/components/charts/ChartFrame.jsx +112 -0
  35. package/src/components/charts/LineChart.jsx +44 -0
  36. package/src/components/charts/exportChart.js +71 -0
  37. package/src/components/charts/formatters.js +39 -0
  38. package/src/components/charts/palette.js +24 -0
  39. package/src/i18n/chartsTranslations.ts +38 -0
  40. package/src/i18n/messagingTranslations.ts +81 -0
  41. package/src/index.js +33 -4
  42. package/src/messaging/AttachmentList.jsx +38 -0
  43. package/src/messaging/Composer.jsx +110 -0
  44. package/src/messaging/ConversationLaunchers.jsx +51 -0
  45. package/src/messaging/ConversationList.jsx +78 -0
  46. package/src/messaging/MessageBubble.jsx +37 -0
  47. package/src/messaging/MessagingProvider.jsx +323 -0
  48. package/src/messaging/MessagingScopeConfig.jsx +24 -0
  49. package/src/messaging/PollCard.jsx +36 -0
  50. package/src/messaging/ReactionBar.jsx +46 -0
  51. package/src/messaging/ReadTicks.jsx +23 -0
  52. package/src/messaging/Thread.jsx +71 -0
  53. package/src/messaging/api.js +47 -0
  54. package/src/notifications/NotificationsProvider.jsx +2 -2
  55. package/src/notifications/realtime.jsx +21 -1
  56. package/tests/BarChart.test.jsx +55 -0
  57. package/tests/ChartFrame.test.jsx +122 -0
  58. package/tests/ConversationList.test.jsx +66 -0
  59. package/tests/LineChart.test.jsx +33 -0
  60. package/tests/MessagingProvider.test.jsx +50 -0
  61. package/tests/Thread.test.jsx +100 -0
  62. package/tests/chartsPalette.test.js +39 -0
  63. package/tests/messagingApi.test.js +32 -0
  64. package/tests/messagingComposer.test.jsx +111 -0
  65. package/tests/messagingExports.test.js +20 -0
  66. package/tests/messagingInteractions.test.jsx +112 -0
  67. package/tests/realtime.test.jsx +27 -0
  68. package/vite.config.mjs +6 -0
  69. package/work-orders/CHART-1.md +285 -0
  70. package/work-orders/DX-1.md +166 -0
  71. package/work-orders/MSG-3.md +375 -0
@@ -0,0 +1,317 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useCallback, useContext, useEffect, useMemo, useReducer, useRef } from 'react';
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';
5
+ import { useRealtime } from '../notifications/realtime';
6
+ const MessagingContext = createContext(null);
7
+ export const MESSAGING_ENVELOPE = 'messaging';
8
+ const EMPTY_CACHE = { conversations: {}, messages: {}, threads: {}, polls: {}, reactions: {}, receipts: {}, unread: { unread_count: 0, by_conversation: {} }, cursors: { conversations: null, messages: {}, threads: {} } };
9
+ const DEFAULT_API = {
10
+ listConversations, listMessages, listThread, getReadStatus, getUnreadCount, archiveConversation,
11
+ patchConversationPreferences, createGroupConversation, createBroadcastConversation,
12
+ createMessage, uploadAttachments, getAttachment, getAttachmentThumbnail,
13
+ addReaction, removeReaction, createPoll, votePoll, closePoll, getConversationConfig, patchConversationConfig,
14
+ };
15
+ 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
+ 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) }); }
17
+ function resultsOf(response) { return (response === null || response === void 0 ? void 0 : response.results) || []; }
18
+ function clientRequestIdOf(message) { return message === null || message === void 0 ? void 0 : message.client_request_id; }
19
+ /**
20
+ * dcm's DRF validation errors aren't always {detail}: ConversationAttachmentView
21
+ * translates a rejected upload into a field-keyed {"files": [...]} body (the
22
+ * MSG-2 chunk-4 fix — see django-core-micha messaging/views.py), while other
23
+ * endpoints use {detail}. Check every shape a caller might actually receive
24
+ * before falling back to the generic axios error message.
25
+ */
26
+ export function extractApiErrorMessage(error) {
27
+ var _a;
28
+ const data = (_a = error === null || error === void 0 ? void 0 : error.response) === null || _a === void 0 ? void 0 : _a.data;
29
+ if (typeof (data === null || data === void 0 ? void 0 : data.detail) === 'string')
30
+ return data.detail;
31
+ if (typeof (data === null || data === void 0 ? void 0 : data.message) === 'string')
32
+ return data.message;
33
+ if (Array.isArray(data === null || data === void 0 ? void 0 : data.files))
34
+ return data.files.join(' ');
35
+ if (data && typeof data === 'object') {
36
+ const firstArray = Object.values(data).find((value) => Array.isArray(value) && value.length);
37
+ if (firstArray)
38
+ return firstArray.join(' ');
39
+ }
40
+ return error === null || error === void 0 ? void 0 : error.message;
41
+ }
42
+ function reconcileMessage(messages, message) {
43
+ var _a, _b;
44
+ const requestId = clientRequestIdOf(message);
45
+ const matchingEntry = requestId && Object.entries(messages).find(([, item]) => clientRequestIdOf(item) === requestId);
46
+ if (!matchingEntry)
47
+ return mergeById(messages, message);
48
+ const [previousKey, previous] = matchingEntry;
49
+ const next = Object.assign({}, messages);
50
+ delete next[previousKey];
51
+ const id = (_a = idOf(message)) !== null && _a !== void 0 ? _a : previousKey;
52
+ next[id] = Object.assign(Object.assign(Object.assign({}, previous), message), { id: (_b = idOf(message)) !== null && _b !== void 0 ? _b : previous.id, status: message.status || 'sent', error: null });
53
+ return next;
54
+ }
55
+ export function messagingReducer(state, action) {
56
+ var _a, _b, _c;
57
+ switch (action.type) {
58
+ case 'conversationsLoaded': {
59
+ const conversations = action.results.reduce((next, item) => mergeById(next, item), state.conversations);
60
+ return Object.assign(Object.assign({}, state), { conversations, cursors: Object.assign(Object.assign({}, state.cursors), { conversations: (_a = action.nextCursor) !== null && _a !== void 0 ? _a : null }) });
61
+ }
62
+ case 'unreadLoaded': return Object.assign(Object.assign({}, state), { unread: action.unread || EMPTY_CACHE.unread });
63
+ case 'messagesLoaded': {
64
+ const messages = action.results.reduce((next, item) => mergeById(next, item), state.messages);
65
+ return Object.assign(Object.assign({}, state), { messages, cursors: Object.assign(Object.assign({}, state.cursors), { messages: Object.assign(Object.assign({}, state.cursors.messages), { [action.conversationId]: (_b = action.nextCursor) !== null && _b !== void 0 ? _b : null }) }) });
66
+ }
67
+ case 'threadLoaded': {
68
+ const messages = action.results.reduce((next, item) => mergeById(next, item), state.messages);
69
+ return Object.assign(Object.assign({}, state), { messages, cursors: Object.assign(Object.assign({}, state.cursors), { threads: Object.assign(Object.assign({}, state.cursors.threads), { [action.rootId]: (_c = action.nextCursor) !== null && _c !== void 0 ? _c : null }) }) });
70
+ }
71
+ case 'conversationUpsert': return Object.assign(Object.assign({}, state), { conversations: mergeById(state.conversations, action.conversation) });
72
+ case 'messageOptimistic': return Object.assign(Object.assign({}, state), { messages: mergeById(state.messages, action.message) });
73
+ case 'messageReconciled': return Object.assign(Object.assign({}, state), { messages: reconcileMessage(state.messages, action.message) });
74
+ case 'messageFailed': {
75
+ const matching = Object.entries(state.messages).find(([, item]) => clientRequestIdOf(item) === action.clientRequestId);
76
+ if (!matching)
77
+ return state;
78
+ const [key, message] = matching;
79
+ 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
+ }
81
+ case 'messagePatched': return Object.assign(Object.assign({}, state), { messages: mergeById(state.messages, action.message) });
82
+ case 'frame': return applyFrame(state, action.frame);
83
+ default: return state;
84
+ }
85
+ }
86
+ export function applyFrame(state, frame) {
87
+ var _a, _b, _c;
88
+ const payload = frame.message || frame.conversation || frame.participant || frame;
89
+ const conversationId = (_a = frame.conversation_id) !== null && _a !== void 0 ? _a : payload.conversation_id;
90
+ if (frame.type === 'message') {
91
+ const message = Object.assign(Object.assign({}, payload), { conversation_id: conversationId });
92
+ return Object.assign(Object.assign({}, state), { messages: reconcileMessage(state.messages, message) });
93
+ }
94
+ if (frame.type === 'message_edited')
95
+ return Object.assign(Object.assign({}, state), { messages: mergeById(state.messages, payload) });
96
+ if (frame.type === 'reaction') {
97
+ const messageId = (_b = frame.message_id) !== null && _b !== void 0 ? _b : payload.message_id;
98
+ 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 || [] })) });
100
+ }
101
+ if (frame.type === 'poll_updated') {
102
+ const messageId = (_c = frame.message_id) !== null && _c !== void 0 ? _c : payload.message_id;
103
+ const previous = state.messages[messageId] || { id: messageId };
104
+ const poll = payload.poll || frame.poll || payload;
105
+ return Object.assign(Object.assign({}, state), { messages: mergeById(state.messages, Object.assign(Object.assign({}, previous), { poll })), polls: mergeById(state.polls, poll) });
106
+ }
107
+ if (frame.type === 'message_deleted') {
108
+ const previous = state.messages[frame.message_id] || {};
109
+ return Object.assign(Object.assign({}, state), { messages: Object.assign(Object.assign({}, state.messages), { [frame.message_id]: Object.assign(Object.assign({}, previous), { id: frame.message_id, deleted_at: frame.deleted_at, deleted_by: frame.deleted_by, body: null, title: null, link_target: null }) }) });
110
+ }
111
+ if (frame.type === 'conversation_upsert')
112
+ return Object.assign(Object.assign({}, state), { conversations: mergeById(state.conversations, payload) });
113
+ // conversation_archived/participant_changed: the design doc names these frame
114
+ // types but doesn't pin their exact payload shape. Treating them as merging
115
+ // onto the conversation record (participant-local fields folded flat, an
116
+ // `archived` flag set) is this chunk's best-effort reading, not a confirmed
117
+ // contract — re-verify against dcm's actual WS payload once available, per
118
+ // the WO's "shape fixtures from the design doc, not from imagination" caution.
119
+ if (frame.type === 'conversation_archived')
120
+ return Object.assign(Object.assign({}, state), { conversations: mergeById(state.conversations, Object.assign(Object.assign({}, payload), { id: conversationId, archived: true })) });
121
+ if (frame.type === 'participant_changed')
122
+ return Object.assign(Object.assign({}, state), { conversations: mergeById(state.conversations, Object.assign(Object.assign({}, payload), { id: conversationId })) });
123
+ return state;
124
+ }
125
+ export function MessagingProvider({ children, filters = {}, activeConversationId = null, api = DEFAULT_API, active = true }) {
126
+ const { subscribe, onReconnect } = useRealtime();
127
+ const { user } = useContext(AuthContext) || {};
128
+ const [cache, dispatch] = useReducer(messagingReducer, EMPTY_CACHE);
129
+ const activeIdRef = useRef(activeConversationId);
130
+ const seenEventsRef = useRef(new Set());
131
+ const filtersRef = useRef(filters);
132
+ activeIdRef.current = activeConversationId;
133
+ filtersRef.current = filters;
134
+ const refreshConversations = useCallback(async () => {
135
+ const response = await api.listConversations(filtersRef.current);
136
+ dispatch({ type: 'conversationsLoaded', results: resultsOf(response), nextCursor: response === null || response === void 0 ? void 0 : response.next_cursor });
137
+ return response;
138
+ }, [api]);
139
+ const refreshUnread = useCallback(async () => {
140
+ const unread = await api.getUnreadCount();
141
+ dispatch({ type: 'unreadLoaded', unread });
142
+ return unread;
143
+ }, [api]);
144
+ const refreshThread = useCallback(async (conversationId = activeIdRef.current, { cursor } = {}) => {
145
+ if (conversationId == null)
146
+ return null;
147
+ const response = await api.listMessages(conversationId, ...(cursor ? [{ cursor }] : []));
148
+ dispatch({ type: 'messagesLoaded', conversationId, results: resultsOf(response), nextCursor: response === null || response === void 0 ? void 0 : response.next_cursor });
149
+ return response;
150
+ }, [api]);
151
+ const loadMoreMessages = useCallback((conversationId = activeIdRef.current) => {
152
+ const cursor = cache.cursors.messages[conversationId];
153
+ return cursor ? refreshThread(conversationId, { cursor }) : Promise.resolve(null);
154
+ }, [cache.cursors.messages, refreshThread]);
155
+ const loadThreadReplies = useCallback(async (rootId, { cursor } = {}) => {
156
+ const response = await api.listThread(rootId, { cursor });
157
+ dispatch({ type: 'threadLoaded', rootId, results: resultsOf(response), nextCursor: response === null || response === void 0 ? void 0 : response.next_cursor });
158
+ return response;
159
+ }, [api]);
160
+ const getMessageReadStatus = useCallback((messageId) => api.getReadStatus(messageId), [api]);
161
+ const loadMoreConversations = useCallback(async () => {
162
+ const cursor = cache.cursors.conversations;
163
+ if (!cursor)
164
+ return null;
165
+ const response = await api.listConversations(Object.assign(Object.assign({}, filtersRef.current), { cursor }));
166
+ dispatch({ type: 'conversationsLoaded', results: resultsOf(response), nextCursor: response === null || response === void 0 ? void 0 : response.next_cursor });
167
+ return response;
168
+ }, [api, cache.cursors.conversations]);
169
+ const updateConversation = useCallback((conversation) => {
170
+ dispatch({ type: 'conversationUpsert', conversation });
171
+ return conversation;
172
+ }, []);
173
+ const setConversationArchived = useCallback(async (conversationId, archived) => {
174
+ var _a;
175
+ const conversation = await api.archiveConversation(conversationId, archived);
176
+ return updateConversation(Object.assign(Object.assign({}, conversation), { id: (_a = conversation === null || conversation === void 0 ? void 0 : conversation.id) !== null && _a !== void 0 ? _a : conversationId, archived: Boolean(archived) }));
177
+ }, [api, updateConversation]);
178
+ const setConversationPreferences = useCallback(async (conversationId, patch) => {
179
+ var _a;
180
+ const conversation = await api.patchConversationPreferences(conversationId, patch);
181
+ 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
+ }, [api, updateConversation]);
183
+ const openGroupConversation = useCallback(async (payload) => {
184
+ const conversation = await api.createGroupConversation(payload);
185
+ return updateConversation(conversation);
186
+ }, [api, updateConversation]);
187
+ const openBroadcastConversation = useCallback(async (payload) => {
188
+ const conversation = await api.createBroadcastConversation(payload);
189
+ return updateConversation(conversation);
190
+ }, [api, updateConversation]);
191
+ const sendMessage = useCallback(async (conversationId, payload, { clientRequestId, retry = false } = {}) => {
192
+ var _a, _b;
193
+ const requestId = clientRequestId || payload.client_request_id;
194
+ const optimistic = Object.assign({ id: `local-${requestId}`, conversation_id: conversationId, kind: payload.kind || 'chat', body: payload.body || '', title: payload.title || null, link_target: payload.link_target || null, reply_to: payload.reply_to || null, client_request_id: requestId, created_at: new Date().toISOString(), status: 'pending', sender: user ? { id: user.id, display_name: user.display_name || user.username } : undefined }, (retry ? {} : {}));
195
+ if (!retry)
196
+ dispatch({ type: 'messageOptimistic', message: optimistic });
197
+ else
198
+ dispatch({ type: 'messageReconciled', message: Object.assign(Object.assign({}, optimistic), { status: 'pending', error: null }) });
199
+ try {
200
+ const message = await api.createMessage(conversationId, Object.assign(Object.assign({}, payload), { client_request_id: requestId }), { idempotencyKey: requestId });
201
+ 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 : requestId }) });
202
+ return message;
203
+ }
204
+ catch (error) {
205
+ dispatch({ type: 'messageFailed', clientRequestId: requestId, error: extractApiErrorMessage(error) });
206
+ throw error;
207
+ }
208
+ }, [api, user]);
209
+ const sendAttachments = useCallback(async (conversationId, formData, { clientRequestId, optimisticMessage, retry = false } = {}) => {
210
+ var _a, _b;
211
+ const optimistic = {
212
+ id: `local-${clientRequestId}`, conversation_id: conversationId, kind: 'chat', body: (optimisticMessage === null || optimisticMessage === void 0 ? void 0 : optimisticMessage.body) || '',
213
+ reply_to: (optimisticMessage === null || optimisticMessage === void 0 ? void 0 : optimisticMessage.reply_to) || null, client_request_id: clientRequestId, created_at: new Date().toISOString(), status: 'pending',
214
+ sender: user ? { id: user.id, display_name: user.display_name || user.username } : undefined,
215
+ attachments: (optimisticMessage === null || optimisticMessage === void 0 ? void 0 : optimisticMessage.attachments) || [],
216
+ };
217
+ if (!retry)
218
+ dispatch({ type: 'messageOptimistic', message: optimistic });
219
+ else
220
+ dispatch({ type: 'messageReconciled', message: Object.assign(Object.assign({}, optimistic), { status: 'pending', error: null }) });
221
+ try {
222
+ const message = await api.uploadAttachments(conversationId, formData, { idempotencyKey: clientRequestId });
223
+ 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
+ return message;
225
+ }
226
+ catch (error) {
227
+ dispatch({ type: 'messageFailed', clientRequestId, error: extractApiErrorMessage(error) });
228
+ throw error;
229
+ }
230
+ }, [api, user]);
231
+ const patchMessage = useCallback((message) => dispatch({ type: 'messagePatched', message }), []);
232
+ const toggleReaction = useCallback(async (messageId, emoji, active) => {
233
+ var _a;
234
+ const message = cache.messages[messageId] || { id: messageId };
235
+ const reactions = message.reactions || [];
236
+ const current = reactions.find((reaction) => reaction.emoji === emoji);
237
+ const nextReactions = active
238
+ ? reactions.map((reaction) => reaction.emoji === emoji ? Object.assign(Object.assign({}, reaction), { reacted: false, count: Math.max(0, (reaction.count || 1) - 1) }) : reaction).filter((reaction) => reaction.count !== 0)
239
+ : current
240
+ ? reactions.map((reaction) => reaction.emoji === emoji ? Object.assign(Object.assign({}, reaction), { reacted: true, count: (reaction.count || 0) + 1 }) : reaction)
241
+ : [...reactions, { emoji, count: 1, reacted: true }];
242
+ patchMessage(Object.assign(Object.assign({}, message), { reactions: nextReactions }));
243
+ try {
244
+ 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 }));
246
+ return result;
247
+ }
248
+ catch (error) {
249
+ patchMessage(message);
250
+ throw error;
251
+ }
252
+ }, [api, cache.messages, patchMessage]);
253
+ const createConversationPoll = useCallback(async (conversationId, payload, { clientRequestId } = {}) => {
254
+ var _a;
255
+ const poll = await api.createPoll(conversationId, Object.assign(Object.assign({}, payload), { client_request_id: clientRequestId }), { idempotencyKey: clientRequestId });
256
+ const message = (poll === null || poll === void 0 ? void 0 : poll.message) || poll;
257
+ if ((message === null || message === void 0 ? void 0 : message.id) != null)
258
+ patchMessage(Object.assign(Object.assign({}, message), { conversation_id: (_a = message.conversation_id) !== null && _a !== void 0 ? _a : conversationId, poll: message.poll || poll.poll || poll }));
259
+ return poll;
260
+ }, [api, patchMessage]);
261
+ const castPollVote = useCallback(async (messageId, poll, optionIds) => {
262
+ const result = await api.votePoll(poll.id, optionIds);
263
+ const nextPoll = Object.assign(Object.assign({}, poll), ((result === null || result === void 0 ? void 0 : result.poll) || result));
264
+ patchMessage(Object.assign(Object.assign({}, (cache.messages[messageId] || { id: messageId })), { poll: nextPoll }));
265
+ return result;
266
+ }, [api, cache.messages, patchMessage]);
267
+ const closeConversationPoll = useCallback(async (messageId, poll) => {
268
+ var _a;
269
+ const result = await api.closePoll(poll.id);
270
+ const nextPoll = Object.assign(Object.assign(Object.assign({}, poll), ((result === null || result === void 0 ? void 0 : result.poll) || result)), { closed_at: ((_a = result === null || result === void 0 ? void 0 : result.poll) === null || _a === void 0 ? void 0 : _a.closed_at) || (result === null || result === void 0 ? void 0 : result.closed_at) || new Date().toISOString() });
271
+ patchMessage(Object.assign(Object.assign({}, (cache.messages[messageId] || { id: messageId })), { poll: nextPoll }));
272
+ return result;
273
+ }, [api, cache.messages, patchMessage]);
274
+ const loadConversationConfig = useCallback((conversationId) => api.getConversationConfig(conversationId), [api]);
275
+ const saveConversationConfig = useCallback((conversationId, patch) => api.patchConversationConfig(conversationId, patch), [api]);
276
+ const refresh = useCallback(async () => Promise.all([refreshConversations(), refreshUnread(), refreshThread()]), [refreshConversations, refreshThread, refreshUnread]);
277
+ // `/api/messaging/` is authenticated-only (design §REST). Host apps mount
278
+ // MessagingProvider inside NotificationsProvider, which itself renders its
279
+ // children regardless of auth state — so without this gate a provider
280
+ // mounted before login completes fires three REST calls that spuriously
281
+ // 401. Defaults to true (matches chunk 1's existing tests/harness usage,
282
+ // which don't pass it); a host app gates it with `active={authenticated}`.
283
+ useEffect(() => { if (active)
284
+ refresh().catch(() => { }); }, [active, refresh]);
285
+ useEffect(() => subscribe(MESSAGING_ENVELOPE, (frame) => {
286
+ if (frame.event_id && seenEventsRef.current.has(frame.event_id))
287
+ return;
288
+ if (frame.event_id) {
289
+ seenEventsRef.current.add(frame.event_id);
290
+ if (seenEventsRef.current.size > 500)
291
+ seenEventsRef.current.delete(seenEventsRef.current.values().next().value);
292
+ }
293
+ dispatch({ type: 'frame', frame });
294
+ }), [subscribe]);
295
+ useEffect(() => {
296
+ if (!active || !onReconnect)
297
+ return undefined;
298
+ return onReconnect(() => { refresh().catch(() => { }); });
299
+ }, [active, onReconnect, refresh]);
300
+ const value = useMemo(() => ({
301
+ cache, refresh, refreshConversations, refreshUnread, refreshThread, loadMoreMessages, loadThreadReplies, getMessageReadStatus, loadMoreConversations,
302
+ setConversationArchived, setConversationPreferences, openGroupConversation,
303
+ openBroadcastConversation, sendMessage, sendAttachments, toggleReaction, createConversationPoll, castPollVote, closeConversationPoll,
304
+ loadConversationConfig, saveConversationConfig, currentUser: user, getAttachment: api.getAttachment,
305
+ getAttachmentThumbnail: api.getAttachmentThumbnail, activeConversationId,
306
+ }), [cache, refresh, refreshConversations, refreshUnread, refreshThread, loadMoreMessages, loadThreadReplies, getMessageReadStatus, loadMoreConversations,
307
+ setConversationArchived, setConversationPreferences, openGroupConversation,
308
+ openBroadcastConversation, sendMessage, sendAttachments, toggleReaction, createConversationPoll, castPollVote, closeConversationPoll,
309
+ loadConversationConfig, saveConversationConfig, user, api.getAttachment, api.getAttachmentThumbnail, activeConversationId]);
310
+ return _jsx(MessagingContext.Provider, { value: value, children: children });
311
+ }
312
+ export function useMessaging() {
313
+ const context = useContext(MessagingContext);
314
+ if (!context)
315
+ throw new Error('useMessaging must be used within a MessagingProvider');
316
+ return context;
317
+ }
@@ -0,0 +1,28 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Alert, Box, CircularProgress, FormControl, FormControlLabel, InputLabel, MenuItem, Select, Stack, Switch, Typography } from '@mui/material';
3
+ import { useEffect, useState } from 'react';
4
+ import { useTranslation } from 'react-i18next';
5
+ import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
6
+ /** Provider-backed scope configuration surface; hosts choose its placement and capability gate. */
7
+ export function MessagingScopeConfig({ conversationId }) {
8
+ const { t } = useTranslation();
9
+ const { loadConversationConfig, saveConversationConfig } = useMessaging();
10
+ const [config, setConfig] = useState(null);
11
+ const [error, setError] = useState(null);
12
+ const [saving, setSaving] = useState(false);
13
+ useEffect(() => { let live = true; loadConversationConfig(conversationId).then((value) => live && setConfig(value)).catch((loadError) => live && setError(t('MessagingConfig.LOAD_ERROR', { message: extractApiErrorMessage(loadError) }))); return () => { live = false; }; }, [conversationId, loadConversationConfig, t]);
14
+ const update = async (patch) => { setSaving(true); setError(null); try {
15
+ setConfig(await saveConversationConfig(conversationId, patch));
16
+ }
17
+ catch (saveError) {
18
+ setError(t('MessagingConfig.SAVE_ERROR', { message: extractApiErrorMessage(saveError) }));
19
+ }
20
+ finally {
21
+ setSaving(false);
22
+ } };
23
+ if (!config)
24
+ return error
25
+ ? _jsx(Alert, { severity: "error", role: "alert", children: error })
26
+ : _jsx(Box, { "aria-label": t('MessagingConfig.LOADING'), sx: { display: 'flex', justifyContent: 'center', py: 4 }, children: _jsx(CircularProgress, { size: 24 }) });
27
+ return _jsxs(Stack, { spacing: 1, "aria-label": t('MessagingConfig.LABEL'), children: [_jsx(Typography, { variant: "subtitle1", children: t('MessagingConfig.TITLE') }), error && _jsx(Alert, { severity: "error", role: "alert", children: error }), _jsxs(FormControl, { fullWidth: true, size: "small", disabled: saving, children: [_jsx(InputLabel, { children: t('MessagingConfig.DM_POLICY') }), _jsxs(Select, { label: t('MessagingConfig.DM_POLICY'), value: config.dm_policy || 'all', onChange: (event) => update({ dm_policy: event.target.value }), children: [_jsx(MenuItem, { value: "all", children: t('MessagingConfig.DM_ALL') }), _jsx(MenuItem, { value: "within_shared_groups", children: t('MessagingConfig.DM_SHARED') }), _jsx(MenuItem, { value: "team_only", children: t('MessagingConfig.DM_TEAM') })] })] }), _jsx(FormControlLabel, { control: _jsx(Switch, { checked: Boolean(config.group_chat_enabled), disabled: saving, onChange: (event) => update({ group_chat_enabled: event.target.checked }) }), label: t('MessagingConfig.GROUP_ENABLED') }), _jsx(FormControlLabel, { control: _jsx(Switch, { checked: Boolean(config.everyone_can_post), disabled: saving || !config.group_chat_enabled, onChange: (event) => update({ everyone_can_post: event.target.checked }) }), label: t('MessagingConfig.EVERYONE_POSTS') })] });
28
+ }
@@ -0,0 +1,53 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Alert, Button, Checkbox, FormControlLabel, Paper, Stack, Typography } from '@mui/material';
3
+ import { useEffect, useState } from 'react';
4
+ import { useTranslation } from 'react-i18next';
5
+ import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
6
+ /** Independently mountable live poll view. Server authorization remains authoritative. */
7
+ export function PollCard({ message, canClose: canCloseProp }) {
8
+ var _a, _b, _c, _d, _e, _f;
9
+ const { t } = useTranslation();
10
+ const { castPollVote, closeConversationPoll, currentUser } = useMessaging();
11
+ const poll = message === null || message === void 0 ? void 0 : message.poll;
12
+ const [selected, setSelected] = useState([]);
13
+ const [error, setError] = useState(null);
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]);
16
+ if (!poll)
17
+ return null;
18
+ 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));
21
+ const changeSelection = (id) => setSelected((current) => poll.allow_multiple ? (current.includes(id) ? current.filter((value) => value !== id) : [...current, id]) : (current.includes(id) ? [] : [id]));
22
+ const vote = async () => {
23
+ if (!selected.length) {
24
+ setError(t('MessagingPoll.SELECT_OPTION'));
25
+ return;
26
+ }
27
+ setBusy(true);
28
+ setError(null);
29
+ try {
30
+ await castPollVote(message.id, poll, selected);
31
+ }
32
+ catch (voteError) {
33
+ setError(t('MessagingPoll.VOTE_ERROR', { message: extractApiErrorMessage(voteError) }));
34
+ }
35
+ finally {
36
+ setBusy(false);
37
+ }
38
+ };
39
+ const close = async () => {
40
+ setBusy(true);
41
+ setError(null);
42
+ try {
43
+ await closeConversationPoll(message.id, poll);
44
+ }
45
+ catch (closeError) {
46
+ setError(t('MessagingPoll.CLOSE_ERROR', { message: extractApiErrorMessage(closeError) }));
47
+ }
48
+ finally {
49
+ setBusy(false);
50
+ }
51
+ };
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') })] })] }) });
53
+ }
@@ -0,0 +1,44 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import AddReactionOutlinedIcon from '@mui/icons-material/AddReactionOutlined';
3
+ import { Alert, Chip, IconButton, Stack } from '@mui/material';
4
+ import { useState } from 'react';
5
+ import { useTranslation } from 'react-i18next';
6
+ import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
7
+ const QUICK_EMOJIS = ['👍', '❤️', '😂', '🎉', '👀'];
8
+ /** Independently mountable aggregate reaction controls for one message. */
9
+ export function ReactionBar({ message }) {
10
+ const { t } = useTranslation();
11
+ const { toggleReaction } = useMessaging();
12
+ const [expanded, setExpanded] = useState(false);
13
+ const [error, setError] = useState(null);
14
+ // Tracks in-flight emoji toggles for this message so a rapid double-click
15
+ // can't fire two concurrent requests for the same emoji — toggleReaction's
16
+ // rollback-on-failure reverts to a pre-optimistic snapshot, which would
17
+ // silently discard a second, already-succeeded change if both were in
18
+ // flight at once. Disabling the chip while its own request is pending
19
+ // removes the race instead of trying to make concurrent toggles safe.
20
+ const [pending, setPending] = useState(() => new Set());
21
+ const reactions = (message === null || message === void 0 ? void 0 : message.reactions) || [];
22
+ const react = async (emoji, active = false) => {
23
+ if (pending.has(emoji))
24
+ return;
25
+ setError(null);
26
+ setPending((current) => new Set(current).add(emoji));
27
+ try {
28
+ await toggleReaction(message.id, emoji, active);
29
+ }
30
+ catch (reactionError) {
31
+ setError(t('MessagingReactions.ERROR', { message: extractApiErrorMessage(reactionError) }));
32
+ }
33
+ finally {
34
+ setPending((current) => { const next = new Set(current); next.delete(emoji); return next; });
35
+ }
36
+ };
37
+ return _jsxs(Stack, { spacing: 0.5, "aria-label": t('MessagingReactions.LABEL'), children: [_jsxs(Stack, { direction: "row", spacing: 0.5, flexWrap: "wrap", children: [reactions.map((reaction) => _jsx(Chip, { size: "small", disabled: pending.has(reaction.emoji), color: reaction.reacted ? 'primary' : 'default', label: `${reaction.emoji} ${reaction.count || 0}`, onClick: () => react(reaction.emoji, Boolean(reaction.reacted)), "aria-label": t('MessagingReactions.TOGGLE', { emoji: reaction.emoji, count: reaction.count || 0 }) }, reaction.emoji)), _jsx(IconButton, { size: "small", "aria-label": t('MessagingReactions.ADD'), onClick: () => setExpanded((value) => !value), children: _jsx(AddReactionOutlinedIcon, { fontSize: "small" }) })] }), expanded && _jsx(Stack, { direction: "row", spacing: 0.25, flexWrap: "wrap", children: QUICK_EMOJIS.map((emoji) => {
38
+ // A quick-emoji already reflected in the aggregate row must toggle it
39
+ // off, not re-add it — otherwise clicking an already-reacted emoji here
40
+ // optimistically double-increments the count before the API call.
41
+ const existing = reactions.find((reaction) => reaction.emoji === emoji);
42
+ return _jsx(Chip, { size: "small", disabled: pending.has(emoji), color: (existing === null || existing === void 0 ? void 0 : existing.reacted) ? 'primary' : 'default', label: emoji, onClick: () => { react(emoji, Boolean(existing === null || existing === void 0 ? void 0 : existing.reacted)); setExpanded(false); } }, emoji);
43
+ }) }), error && _jsx(Alert, { severity: "error", role: "alert", children: error })] });
44
+ }
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Tooltip, Typography } from '@mui/material';
3
+ import { useEffect, useState } from 'react';
4
+ import { useTranslation } from 'react-i18next';
5
+ import { useMessaging } from './MessagingProvider';
6
+ /** Renders only the aggregate receipt unless the server explicitly supplies permitted detail. */
7
+ export function ReadTicks({ messageId, conversation }) {
8
+ const { t } = useTranslation();
9
+ const { getMessageReadStatus } = useMessaging();
10
+ const [status, setStatus] = useState(null);
11
+ useEffect(() => {
12
+ let mounted = true;
13
+ getMessageReadStatus(messageId).then((next) => { if (mounted)
14
+ setStatus(next); }).catch(() => { if (mounted)
15
+ setStatus(null); });
16
+ return () => { mounted = false; };
17
+ }, [getMessageReadStatus, messageId]);
18
+ if (!status)
19
+ return null;
20
+ const label = status.all_read ? t('MessagingReadTicks.ALL_READ') : t('MessagingReadTicks.DELIVERED', { count: status.delivered_count || 0 });
21
+ // The server never returns DM detail; enforce the same carve-out defensively.
22
+ 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 }) });
25
+ }
@@ -0,0 +1,73 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Alert, Box, Button, CircularProgress, Divider, Stack, Typography } from '@mui/material';
3
+ import { useCallback, useContext, useMemo, useRef, useState } from 'react';
4
+ import { useTranslation } from 'react-i18next';
5
+ import { AuthContext } from '../auth/AuthContext';
6
+ import { MessageBubble } from './MessageBubble';
7
+ import { useMessaging } from './MessagingProvider';
8
+ import { ReadTicks } from './ReadTicks';
9
+ function chronological(messages) { return [...messages].sort((left, right) => new Date(left.created_at || 0) - new Date(right.created_at || 0)); }
10
+ // A still-pending optimistic row (chunk 4) has a fake `local-<requestId>` id
11
+ // and no durable server row yet — mounting ReadTicks against it would fire a
12
+ // getMessageReadStatus REST call for an id the server has never heard of.
13
+ // Wait for reconciliation (status !== 'pending') before showing read state.
14
+ function canShowReadTicks(message, user) {
15
+ var _a;
16
+ return Boolean(user === null || user === void 0 ? void 0 : user.id) && ((_a = message.sender) === null || _a === void 0 ? void 0 : _a.id) === user.id && message.status !== 'pending';
17
+ }
18
+ /**
19
+ * A standalone timeline. Compose/reaction/poll/attachment mutations remain
20
+ * separate chunk collaborators.
21
+ *
22
+ * ReadTicks is only mounted for the current user's own messages (matches
23
+ * jg's `shouldShowReadTick(msg, isOwn)`): a read receipt tells the sender
24
+ * whether their own message was seen, not the recipient anything about a
25
+ * message they're currently reading. Gating here also bounds the number of
26
+ * `getMessageReadStatus` REST calls a rendered timeline fires — one per own
27
+ * message shown, not one per message in the conversation.
28
+ */
29
+ export function Thread({ conversationId, onReplyTargetChange }) {
30
+ var _a;
31
+ const { t } = useTranslation();
32
+ const { user } = useContext(AuthContext) || {};
33
+ const { cache, loadMoreMessages, loadThreadReplies } = useMessaging();
34
+ const scrollRef = useRef(null);
35
+ const [loadingOlder, setLoadingOlder] = useState(false);
36
+ const [error, setError] = useState(null);
37
+ const [replyingTo, setReplyingTo] = useState(null);
38
+ const [openThreads, setOpenThreads] = useState({});
39
+ 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]);
41
+ const loadOlder = useCallback(async () => {
42
+ setLoadingOlder(true);
43
+ setError(null);
44
+ try {
45
+ await loadMoreMessages(conversationId);
46
+ }
47
+ catch (_a) {
48
+ setError(t('MessagingThread.LOAD_ERROR'));
49
+ }
50
+ finally {
51
+ setLoadingOlder(false);
52
+ }
53
+ }, [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(); };
56
+ const reply = (message) => { setReplyingTo(message); onReplyTargetChange === null || onReplyTargetChange === void 0 ? void 0 : onReplyTargetChange(message); };
57
+ const toggleReplies = async (root) => {
58
+ if (!openThreads[root.id]) {
59
+ try {
60
+ await loadThreadReplies(root.id);
61
+ }
62
+ catch (_a) {
63
+ setError(t('MessagingThread.REPLIES_ERROR'));
64
+ return;
65
+ }
66
+ }
67
+ setOpenThreads((current) => (Object.assign(Object.assign({}, current), { [root.id]: !current[root.id] })));
68
+ };
69
+ 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);
72
+ }) })] })] }));
73
+ }
@@ -0,0 +1,51 @@
1
+ var __rest = (this && this.__rest) || function (s, e) {
2
+ var t = {};
3
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
4
+ t[p] = s[p];
5
+ if (s != null && typeof Object.getOwnPropertySymbols === "function")
6
+ for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
7
+ if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
8
+ t[p[i]] = s[p[i]];
9
+ }
10
+ return t;
11
+ };
12
+ import apiClient from '../auth/apiClient';
13
+ const BASE_URL = '/api/messaging/';
14
+ const conversationsUrl = `${BASE_URL}conversations/`;
15
+ function data(response) {
16
+ return response.data;
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 } : {}));
20
+ }
21
+ export function listConversations(_a = {}) {
22
+ var { cursor } = _a, filters = __rest(_a, ["cursor"]);
23
+ return apiClient.get(conversationsUrl, requestConfig({ cursor, params: filters })).then(data);
24
+ }
25
+ export function createDirectConversation(payload) { return apiClient.post(`${conversationsUrl}direct/`, payload).then(data); }
26
+ export function createGroupConversation(payload) { return apiClient.post(`${conversationsUrl}group/`, payload).then(data); }
27
+ export function createBroadcastConversation(payload) { return apiClient.post(`${conversationsUrl}broadcast/`, payload).then(data); }
28
+ export function createManagedConversation(payload) { return apiClient.post(`${conversationsUrl}managed/`, payload).then(data); }
29
+ export function createObjectThreadConversation(payload) { return apiClient.post(`${conversationsUrl}object-thread/`, payload).then(data); }
30
+ export function getConversationConfig(conversationId) { return apiClient.get(`${conversationsUrl}${conversationId}/config/`).then(data); }
31
+ export function patchConversationConfig(conversationId, patch) { return apiClient.patch(`${conversationsUrl}${conversationId}/config/`, patch).then(data); }
32
+ export function archiveConversation(conversationId, archived) { return apiClient.post(`${conversationsUrl}${conversationId}/archive/`, { archived }).then(data); }
33
+ export function patchConversationPreferences(conversationId, patch) { return apiClient.post(`${conversationsUrl}${conversationId}/preferences/`, patch).then(data); }
34
+ export function listMessages(conversationId, { cursor } = {}) { return apiClient.get(`${conversationsUrl}${conversationId}/messages/`, requestConfig({ cursor })).then(data); }
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); }
37
+ export function createPoll(conversationId, payload, { idempotencyKey } = {}) { return apiClient.post(`${conversationsUrl}${conversationId}/polls/`, payload, requestConfig({ headers: idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : undefined })).then(data); }
38
+ export function getMessage(messageId) { return apiClient.get(`${BASE_URL}messages/${messageId}/`).then(data); }
39
+ export function patchMessage(messageId, patch) { return apiClient.patch(`${BASE_URL}messages/${messageId}/`, patch).then(data); }
40
+ export function deleteMessage(messageId) { return apiClient.delete(`${BASE_URL}messages/${messageId}/`).then(data); }
41
+ export function addReaction(messageId, emoji) { return apiClient.post(`${BASE_URL}messages/${messageId}/reactions/`, { emoji }).then(data); }
42
+ export function removeReaction(messageId, emoji) { return apiClient.delete(`${BASE_URL}messages/${messageId}/reactions/${encodeURIComponent(emoji)}/`).then(data); }
43
+ export function listThread(rootId, { cursor } = {}) { return apiClient.get(`${BASE_URL}messages/${rootId}/thread/`, requestConfig({ cursor })).then(data); }
44
+ export function markThreadRead(rootId, readAt) { return apiClient.post(`${BASE_URL}messages/${rootId}/thread/read/`, readAt ? { read_at: readAt } : {}).then(data); }
45
+ export function markConversationRead(conversationId, readAt) { return apiClient.post(`${conversationsUrl}${conversationId}/read/`, readAt ? { read_at: readAt } : {}).then(data); }
46
+ export function getReadStatus(messageId) { return apiClient.get(`${BASE_URL}messages/${messageId}/read-status/`).then(data); }
47
+ export function votePoll(pollId, optionIds) { return apiClient.post(`${BASE_URL}polls/${pollId}/vote/`, { option_ids: optionIds }).then(data); }
48
+ export function closePoll(pollId) { return apiClient.post(`${BASE_URL}polls/${pollId}/close/`).then(data); }
49
+ export function getAttachment(attachmentId) { return apiClient.get(`${BASE_URL}attachments/${attachmentId}/`, { responseType: 'blob' }).then(data); }
50
+ export function getAttachmentThumbnail(attachmentId) { return apiClient.get(`${BASE_URL}attachments/${attachmentId}/thumbnail/`, { responseType: 'blob' }).then(data); }
51
+ export function getUnreadCount() { return apiClient.get(`${BASE_URL}unread-count/`).then(data); }
@@ -66,7 +66,7 @@ export function NotificationsProvider({ children, wsUrlBase }) {
66
66
  // `replaceNotifications(...)` is unconditional and would silently drop a message that
67
67
  // arrived and patched state before the initial REST response landed.
68
68
  const [seeded, setSeeded] = useState(false);
69
- const { subscribe } = useRealtimeCore({
69
+ const { subscribe, onReconnect } = useRealtimeCore({
70
70
  active: authenticated && seeded,
71
71
  wsUrl: getWebSocketUrl(wsUrlBase),
72
72
  });
@@ -159,7 +159,7 @@ export function NotificationsProvider({ children, wsUrlBase }) {
159
159
  const markSeen = useCallback((ids) => mark('seen', ids), [mark]);
160
160
  const markDismissed = useCallback((ids) => mark('dismissed', ids), [mark]);
161
161
  const markDone = useCallback((ids) => mark('done', ids), [mark]);
162
- return (_jsx(RealtimeContext.Provider, { value: { subscribe }, children: _jsx(NotificationsContext.Provider, { value: {
162
+ return (_jsx(RealtimeContext.Provider, { value: { subscribe, onReconnect }, children: _jsx(NotificationsContext.Provider, { value: {
163
163
  notifications,
164
164
  unreadCount,
165
165
  markSeen,