@micha.bigler/ui-core-micha 2.15.0 → 2.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/WORK_ORDERS.md +6 -1
- package/dev/entries.jsx +151 -0
- package/dev/fixtures.js +26 -0
- package/dev/main.jsx +56 -0
- package/dev/mockTransport.jsx +128 -0
- package/dist/i18n/messagingTranslations.js +107 -0
- package/dist/index.js +15 -0
- package/dist/messaging/AttachmentList.js +43 -0
- package/dist/messaging/Composer.js +156 -0
- package/dist/messaging/ConversationLaunchers.js +39 -0
- package/dist/messaging/ConversationList.js +82 -0
- package/dist/messaging/DirectMessageLauncher.js +48 -0
- package/dist/messaging/MessageBubble.js +97 -0
- package/dist/messaging/MessagingProvider.js +363 -0
- package/dist/messaging/MessagingScopeConfig.js +28 -0
- package/dist/messaging/PollCard.js +53 -0
- package/dist/messaging/ReactionBar.js +44 -0
- package/dist/messaging/ReadTicks.js +28 -0
- package/dist/messaging/Thread.js +110 -0
- package/dist/messaging/api.js +51 -0
- package/dist/messaging/composerImageCompression.js +36 -0
- package/dist/notifications/NotificationsProvider.js +2 -2
- package/dist/notifications/realtime.js +21 -1
- package/docs/messaging-deviations.md +101 -0
- package/index.html +12 -0
- package/package.json +4 -1
- package/src/i18n/messagingTranslations.ts +107 -0
- package/src/index.js +16 -0
- package/src/messaging/AttachmentList.jsx +38 -0
- package/src/messaging/Composer.jsx +156 -0
- package/src/messaging/ConversationLaunchers.jsx +51 -0
- package/src/messaging/ConversationList.jsx +84 -0
- package/src/messaging/DirectMessageLauncher.jsx +96 -0
- package/src/messaging/MessageBubble.jsx +104 -0
- package/src/messaging/MessagingProvider.jsx +395 -0
- package/src/messaging/MessagingScopeConfig.jsx +24 -0
- package/src/messaging/PollCard.jsx +36 -0
- package/src/messaging/ReactionBar.jsx +46 -0
- package/src/messaging/ReadTicks.jsx +25 -0
- package/src/messaging/Thread.jsx +102 -0
- package/src/messaging/api.js +48 -0
- package/src/messaging/composerImageCompression.js +35 -0
- package/src/notifications/NotificationsProvider.jsx +2 -2
- package/src/notifications/realtime.jsx +21 -1
- package/tests/ConversationList.test.jsx +66 -0
- package/tests/MessagingProvider.test.jsx +50 -0
- package/tests/Thread.test.jsx +100 -0
- package/tests/messagingApi.test.js +32 -0
- package/tests/messagingChunk6.test.jsx +109 -0
- package/tests/messagingComposer.test.jsx +111 -0
- package/tests/messagingComposerChunk5.test.jsx +110 -0
- package/tests/messagingContractConformance.test.js +42 -0
- package/tests/messagingDirectMessageLauncher.test.jsx +78 -0
- package/tests/messagingExports.test.js +21 -0
- package/tests/messagingInteractions.test.jsx +112 -0
- package/tests/messagingMessageActions.test.jsx +93 -0
- package/tests/messagingThreadingQuoting.test.jsx +86 -0
- package/tests/messagingUnreadLifecycle.test.jsx +63 -0
- package/tests/realtime.test.jsx +27 -0
- package/vite.config.mjs +6 -0
- package/work-orders/DX-1.md +166 -0
- package/work-orders/MSG-3.md +375 -0
- package/work-orders/MSG-3b.md +1002 -0
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useReducer, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
import { AuthContext } from '../auth/AuthContext';
|
|
4
|
+
import {
|
|
5
|
+
archiveConversation,
|
|
6
|
+
createBroadcastConversation,
|
|
7
|
+
createDirectConversation,
|
|
8
|
+
createGroupConversation,
|
|
9
|
+
getReadStatus,
|
|
10
|
+
getUnreadCount,
|
|
11
|
+
listConversations,
|
|
12
|
+
listMessages,
|
|
13
|
+
listThread,
|
|
14
|
+
patchConversationPreferences,
|
|
15
|
+
markConversationRead,
|
|
16
|
+
markThreadRead,
|
|
17
|
+
createMessage,
|
|
18
|
+
uploadAttachments,
|
|
19
|
+
getAttachment,
|
|
20
|
+
getAttachmentThumbnail,
|
|
21
|
+
addReaction,
|
|
22
|
+
removeReaction,
|
|
23
|
+
createPoll,
|
|
24
|
+
votePoll,
|
|
25
|
+
closePoll,
|
|
26
|
+
getConversationConfig,
|
|
27
|
+
patchConversationConfig,
|
|
28
|
+
patchMessage as patchMessageRequest,
|
|
29
|
+
deleteMessage as deleteMessageRequest,
|
|
30
|
+
} from './api';
|
|
31
|
+
import { useRealtime } from '../notifications/realtime';
|
|
32
|
+
|
|
33
|
+
const MessagingContext = createContext(null);
|
|
34
|
+
export const MESSAGING_ENVELOPE = 'messaging';
|
|
35
|
+
const EMPTY_CACHE = { conversations: {}, messages: {}, threads: {}, polls: {}, reactions: {}, receipts: {}, unread: { unread_count: 0, by_conversation: {} }, cursors: { conversations: null, messages: {}, threads: {} } };
|
|
36
|
+
const DEFAULT_API = {
|
|
37
|
+
listConversations, listMessages, listThread, getReadStatus, getUnreadCount, archiveConversation,
|
|
38
|
+
patchConversationPreferences, createGroupConversation, createBroadcastConversation, createDirectConversation,
|
|
39
|
+
markConversationRead, markThreadRead,
|
|
40
|
+
createMessage, uploadAttachments, getAttachment, getAttachmentThumbnail,
|
|
41
|
+
addReaction, removeReaction, createPoll, votePoll, closePoll, getConversationConfig, patchConversationConfig,
|
|
42
|
+
patchMessage: patchMessageRequest, deleteMessage: deleteMessageRequest,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function idOf(item) { return item?.id ?? item?.conversation_id ?? item?.message_id ?? item?.poll_id; }
|
|
46
|
+
function mergeById(slice, item) { const id = idOf(item); return id == null ? slice : { ...slice, [id]: { ...slice[id], ...item } }; }
|
|
47
|
+
function resultsOf(response) { return response?.results || []; }
|
|
48
|
+
function clientRequestIdOf(message) { return message?.client_request_id; }
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* dcm's DRF validation errors aren't always {detail}: ConversationAttachmentView
|
|
52
|
+
* translates a rejected upload into a field-keyed {"files": [...]} body (the
|
|
53
|
+
* MSG-2 chunk-4 fix — see django-core-micha messaging/views.py), while other
|
|
54
|
+
* endpoints use {detail}. Check every shape a caller might actually receive
|
|
55
|
+
* before falling back to the generic axios error message.
|
|
56
|
+
*/
|
|
57
|
+
export function extractApiErrorMessage(error) {
|
|
58
|
+
const data = error?.response?.data;
|
|
59
|
+
if (typeof data?.detail === 'string') return data.detail;
|
|
60
|
+
if (typeof data?.message === 'string') return data.message;
|
|
61
|
+
if (Array.isArray(data?.files)) return data.files.join(' ');
|
|
62
|
+
if (data && typeof data === 'object') {
|
|
63
|
+
const firstArray = Object.values(data).find((value) => Array.isArray(value) && value.length);
|
|
64
|
+
if (firstArray) return firstArray.join(' ');
|
|
65
|
+
}
|
|
66
|
+
return error?.message;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function reconcileMessage(messages, message) {
|
|
70
|
+
const requestId = clientRequestIdOf(message);
|
|
71
|
+
const matchingEntry = requestId && Object.entries(messages).find(([, item]) => clientRequestIdOf(item) === requestId);
|
|
72
|
+
if (!matchingEntry) return mergeById(messages, message);
|
|
73
|
+
const [previousKey, previous] = matchingEntry;
|
|
74
|
+
const next = { ...messages };
|
|
75
|
+
delete next[previousKey];
|
|
76
|
+
const id = idOf(message) ?? previousKey;
|
|
77
|
+
next[id] = { ...previous, ...message, id: idOf(message) ?? previous.id, status: message.status || 'sent', error: null };
|
|
78
|
+
return next;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function messagingReducer(state, action) {
|
|
82
|
+
switch (action.type) {
|
|
83
|
+
case 'conversationsLoaded': {
|
|
84
|
+
const conversations = action.results.reduce((next, item) => mergeById(next, item), state.conversations);
|
|
85
|
+
return { ...state, conversations, cursors: { ...state.cursors, conversations: action.nextCursor ?? null } };
|
|
86
|
+
}
|
|
87
|
+
case 'unreadLoaded': return { ...state, unread: action.unread || EMPTY_CACHE.unread };
|
|
88
|
+
case 'messagesLoaded': {
|
|
89
|
+
const messages = action.results.reduce((next, item) => mergeById(next, item), state.messages);
|
|
90
|
+
return { ...state, messages, cursors: { ...state.cursors, messages: { ...state.cursors.messages, [action.conversationId]: action.nextCursor ?? null } } };
|
|
91
|
+
}
|
|
92
|
+
case 'threadLoaded': {
|
|
93
|
+
const messages = action.results.reduce((next, item) => mergeById(next, item), state.messages);
|
|
94
|
+
return { ...state, messages, cursors: { ...state.cursors, threads: { ...state.cursors.threads, [action.rootId]: action.nextCursor ?? null } } };
|
|
95
|
+
}
|
|
96
|
+
case 'conversationUpsert': return { ...state, conversations: mergeById(state.conversations, action.conversation) };
|
|
97
|
+
case 'messageOptimistic': return { ...state, messages: mergeById(state.messages, action.message) };
|
|
98
|
+
case 'messageReconciled': return { ...state, messages: reconcileMessage(state.messages, action.message) };
|
|
99
|
+
case 'messageFailed': {
|
|
100
|
+
const matching = Object.entries(state.messages).find(([, item]) => clientRequestIdOf(item) === action.clientRequestId);
|
|
101
|
+
if (!matching) return state;
|
|
102
|
+
const [key, message] = matching;
|
|
103
|
+
return { ...state, messages: { ...state.messages, [key]: { ...message, status: 'error', error: action.error } } };
|
|
104
|
+
}
|
|
105
|
+
case 'messagePatched': return { ...state, messages: mergeById(state.messages, action.message) };
|
|
106
|
+
case 'conversationRead': {
|
|
107
|
+
const previousCount = state.unread?.by_conversation?.[action.conversationId] || 0;
|
|
108
|
+
if (!previousCount) return state;
|
|
109
|
+
return {
|
|
110
|
+
...state,
|
|
111
|
+
unread: {
|
|
112
|
+
...state.unread,
|
|
113
|
+
unread_count: Math.max(0, (state.unread?.unread_count || 0) - previousCount),
|
|
114
|
+
by_conversation: { ...state.unread.by_conversation, [action.conversationId]: 0 },
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
case 'frame': return applyFrame(state, action.frame, action.activeConversationId);
|
|
119
|
+
default: return state;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function applyFrame(state, frame, activeConversationId = null) {
|
|
124
|
+
const payload = frame.message || frame.conversation || frame.participant || frame;
|
|
125
|
+
const conversationId = frame.conversation_id ?? payload.conversation_id;
|
|
126
|
+
if (frame.type === 'message') {
|
|
127
|
+
const message = { ...payload, conversation_id: conversationId };
|
|
128
|
+
const requestId = clientRequestIdOf(message);
|
|
129
|
+
const reconcilesOptimisticMessage = Boolean(requestId && Object.values(state.messages).some((item) => clientRequestIdOf(item) === requestId));
|
|
130
|
+
const messages = reconcileMessage(state.messages, message);
|
|
131
|
+
if (conversationId == null || String(conversationId) === String(activeConversationId) || reconcilesOptimisticMessage) return { ...state, messages };
|
|
132
|
+
const unread = state.unread || EMPTY_CACHE.unread;
|
|
133
|
+
const previousCount = unread.by_conversation?.[conversationId] || 0;
|
|
134
|
+
return {
|
|
135
|
+
...state,
|
|
136
|
+
messages,
|
|
137
|
+
unread: {
|
|
138
|
+
...unread,
|
|
139
|
+
unread_count: (unread.unread_count || 0) + 1,
|
|
140
|
+
by_conversation: { ...unread.by_conversation, [conversationId]: previousCount + 1 },
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
if (frame.type === 'message_edited') return { ...state, messages: mergeById(state.messages, payload) };
|
|
145
|
+
if (frame.type === 'reaction') {
|
|
146
|
+
const messageId = frame.message_id ?? payload.message_id;
|
|
147
|
+
const previous = state.messages[messageId] || { id: messageId };
|
|
148
|
+
return { ...state, messages: mergeById(state.messages, { ...previous, reactions: payload.reactions || frame.reactions || previous.reactions || [] }) };
|
|
149
|
+
}
|
|
150
|
+
if (frame.type === 'poll_updated') {
|
|
151
|
+
const messageId = frame.message_id ?? payload.message_id;
|
|
152
|
+
const previous = state.messages[messageId] || { id: messageId };
|
|
153
|
+
const poll = payload.poll || frame.poll || payload;
|
|
154
|
+
return { ...state, messages: mergeById(state.messages, { ...previous, poll }), polls: mergeById(state.polls, poll) };
|
|
155
|
+
}
|
|
156
|
+
if (frame.type === 'message_deleted') {
|
|
157
|
+
const previous = state.messages[frame.message_id] || {};
|
|
158
|
+
return { ...state, messages: { ...state.messages, [frame.message_id]: { ...previous, id: frame.message_id, deleted_at: frame.deleted_at, deleted_by: frame.deleted_by, body: null, title: null, link_target: null } } };
|
|
159
|
+
}
|
|
160
|
+
if (frame.type === 'conversation_upsert') return { ...state, conversations: mergeById(state.conversations, payload) };
|
|
161
|
+
// conversation_archived/participant_changed: the design doc names these frame
|
|
162
|
+
// types but doesn't pin their exact payload shape. Treating them as merging
|
|
163
|
+
// onto the conversation record (participant-local fields folded flat, an
|
|
164
|
+
// `archived` flag set) is this chunk's best-effort reading, not a confirmed
|
|
165
|
+
// contract — re-verify against dcm's actual WS payload once available, per
|
|
166
|
+
// the WO's "shape fixtures from the design doc, not from imagination" caution.
|
|
167
|
+
if (frame.type === 'conversation_archived') return { ...state, conversations: mergeById(state.conversations, { ...payload, id: conversationId, archived: true }) };
|
|
168
|
+
if (frame.type === 'participant_changed') return { ...state, conversations: mergeById(state.conversations, { ...payload, id: conversationId }) };
|
|
169
|
+
return state;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function MessagingProvider({ children, filters = {}, activeConversationId = null, api = DEFAULT_API, active = true }) {
|
|
173
|
+
const { subscribe, onReconnect } = useRealtime();
|
|
174
|
+
const { user } = useContext(AuthContext) || {};
|
|
175
|
+
const [cache, dispatch] = useReducer(messagingReducer, EMPTY_CACHE);
|
|
176
|
+
const activeIdRef = useRef(activeConversationId);
|
|
177
|
+
const seenEventsRef = useRef(new Set());
|
|
178
|
+
const filtersRef = useRef(filters);
|
|
179
|
+
activeIdRef.current = activeConversationId;
|
|
180
|
+
filtersRef.current = filters;
|
|
181
|
+
|
|
182
|
+
const refreshConversations = useCallback(async () => {
|
|
183
|
+
const response = await api.listConversations(filtersRef.current);
|
|
184
|
+
dispatch({ type: 'conversationsLoaded', results: resultsOf(response), nextCursor: response?.next_cursor });
|
|
185
|
+
return response;
|
|
186
|
+
}, [api]);
|
|
187
|
+
const refreshUnread = useCallback(async () => {
|
|
188
|
+
const unread = await api.getUnreadCount();
|
|
189
|
+
dispatch({ type: 'unreadLoaded', unread });
|
|
190
|
+
return unread;
|
|
191
|
+
}, [api]);
|
|
192
|
+
const refreshThread = useCallback(async (conversationId = activeIdRef.current, { cursor } = {}) => {
|
|
193
|
+
if (conversationId == null) return null;
|
|
194
|
+
const response = await api.listMessages(conversationId, ...(cursor ? [{ cursor }] : []));
|
|
195
|
+
dispatch({ type: 'messagesLoaded', conversationId, results: resultsOf(response), nextCursor: response?.next_cursor });
|
|
196
|
+
return response;
|
|
197
|
+
}, [api]);
|
|
198
|
+
const loadMoreMessages = useCallback((conversationId = activeIdRef.current) => {
|
|
199
|
+
const cursor = cache.cursors.messages[conversationId];
|
|
200
|
+
return cursor ? refreshThread(conversationId, { cursor }) : Promise.resolve(null);
|
|
201
|
+
}, [cache.cursors.messages, refreshThread]);
|
|
202
|
+
const loadThreadReplies = useCallback(async (rootId, { cursor } = {}) => {
|
|
203
|
+
const response = await api.listThread(rootId, { cursor });
|
|
204
|
+
dispatch({ type: 'threadLoaded', rootId, results: resultsOf(response), nextCursor: response?.next_cursor });
|
|
205
|
+
return response;
|
|
206
|
+
}, [api]);
|
|
207
|
+
const getMessageReadStatus = useCallback((messageId) => api.getReadStatus(messageId), [api]);
|
|
208
|
+
const loadMoreConversations = useCallback(async () => {
|
|
209
|
+
const cursor = cache.cursors.conversations;
|
|
210
|
+
if (!cursor) return null;
|
|
211
|
+
const response = await api.listConversations({ ...filtersRef.current, cursor });
|
|
212
|
+
dispatch({ type: 'conversationsLoaded', results: resultsOf(response), nextCursor: response?.next_cursor });
|
|
213
|
+
return response;
|
|
214
|
+
}, [api, cache.cursors.conversations]);
|
|
215
|
+
const updateConversation = useCallback((conversation) => {
|
|
216
|
+
dispatch({ type: 'conversationUpsert', conversation });
|
|
217
|
+
return conversation;
|
|
218
|
+
}, []);
|
|
219
|
+
const setConversationArchived = useCallback(async (conversationId, archived) => {
|
|
220
|
+
const conversation = await api.archiveConversation(conversationId, archived);
|
|
221
|
+
return updateConversation({ ...conversation, id: conversation?.id ?? conversationId, archived: Boolean(archived) });
|
|
222
|
+
}, [api, updateConversation]);
|
|
223
|
+
const setConversationPreferences = useCallback(async (conversationId, patch) => {
|
|
224
|
+
const conversation = await api.patchConversationPreferences(conversationId, patch);
|
|
225
|
+
return updateConversation({ ...conversation, id: conversation?.id ?? conversationId, ...patch });
|
|
226
|
+
}, [api, updateConversation]);
|
|
227
|
+
const markConversationAsRead = useCallback(async (conversationId, readAt) => {
|
|
228
|
+
const result = await api.markConversationRead(conversationId, readAt);
|
|
229
|
+
dispatch({ type: 'conversationRead', conversationId });
|
|
230
|
+
return result;
|
|
231
|
+
}, [api]);
|
|
232
|
+
const markReplyThreadRead = useCallback((rootId, readAt) => api.markThreadRead(rootId, readAt), [api]);
|
|
233
|
+
const openGroupConversation = useCallback(async (payload) => {
|
|
234
|
+
const conversation = await api.createGroupConversation(payload);
|
|
235
|
+
return updateConversation(conversation);
|
|
236
|
+
}, [api, updateConversation]);
|
|
237
|
+
const openBroadcastConversation = useCallback(async (payload) => {
|
|
238
|
+
const conversation = await api.createBroadcastConversation(payload);
|
|
239
|
+
return updateConversation(conversation);
|
|
240
|
+
}, [api, updateConversation]);
|
|
241
|
+
const openDirectConversation = useCallback(async (payload) => {
|
|
242
|
+
const conversation = await api.createDirectConversation(payload);
|
|
243
|
+
return updateConversation(conversation);
|
|
244
|
+
}, [api, updateConversation]);
|
|
245
|
+
const sendMessage = useCallback(async (conversationId, payload, { clientRequestId, retry = false } = {}) => {
|
|
246
|
+
const requestId = clientRequestId || payload.client_request_id;
|
|
247
|
+
const optimistic = {
|
|
248
|
+
id: `local-${requestId}`,
|
|
249
|
+
conversation_id: conversationId,
|
|
250
|
+
kind: payload.kind || 'chat',
|
|
251
|
+
body: payload.body || '',
|
|
252
|
+
title: payload.title || null,
|
|
253
|
+
link_target: payload.link_target || null,
|
|
254
|
+
reply_to: payload.reply_to || null,
|
|
255
|
+
client_request_id: requestId,
|
|
256
|
+
created_at: new Date().toISOString(),
|
|
257
|
+
status: 'pending',
|
|
258
|
+
sender: user ? { id: user.id, display_name: user.display_name || user.username } : undefined,
|
|
259
|
+
...(retry ? {} : {}),
|
|
260
|
+
};
|
|
261
|
+
if (!retry) dispatch({ type: 'messageOptimistic', message: optimistic });
|
|
262
|
+
else dispatch({ type: 'messageReconciled', message: { ...optimistic, status: 'pending', error: null } });
|
|
263
|
+
try {
|
|
264
|
+
const message = await api.createMessage(conversationId, { ...payload, client_request_id: requestId }, { idempotencyKey: requestId });
|
|
265
|
+
dispatch({ type: 'messageReconciled', message: { ...message, conversation_id: message?.conversation_id ?? conversationId, client_request_id: message?.client_request_id ?? requestId } });
|
|
266
|
+
return message;
|
|
267
|
+
} catch (error) {
|
|
268
|
+
dispatch({ type: 'messageFailed', clientRequestId: requestId, error: extractApiErrorMessage(error) });
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
271
|
+
}, [api, user]);
|
|
272
|
+
const sendAttachments = useCallback(async (conversationId, formData, { clientRequestId, optimisticMessage, retry = false, onUploadProgress } = {}) => {
|
|
273
|
+
const optimistic = {
|
|
274
|
+
id: `local-${clientRequestId}`, conversation_id: conversationId, kind: 'chat', body: optimisticMessage?.body || '',
|
|
275
|
+
reply_to: optimisticMessage?.reply_to || null, client_request_id: clientRequestId, created_at: new Date().toISOString(), status: 'pending',
|
|
276
|
+
sender: user ? { id: user.id, display_name: user.display_name || user.username } : undefined,
|
|
277
|
+
attachments: optimisticMessage?.attachments || [],
|
|
278
|
+
};
|
|
279
|
+
if (!retry) dispatch({ type: 'messageOptimistic', message: optimistic });
|
|
280
|
+
else dispatch({ type: 'messageReconciled', message: { ...optimistic, status: 'pending', error: null } });
|
|
281
|
+
try {
|
|
282
|
+
const message = await api.uploadAttachments(conversationId, formData, { idempotencyKey: clientRequestId, onUploadProgress });
|
|
283
|
+
dispatch({ type: 'messageReconciled', message: { ...message, conversation_id: message?.conversation_id ?? conversationId, client_request_id: message?.client_request_id ?? clientRequestId } });
|
|
284
|
+
return message;
|
|
285
|
+
} catch (error) {
|
|
286
|
+
dispatch({ type: 'messageFailed', clientRequestId, error: extractApiErrorMessage(error) });
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
}, [api, user]);
|
|
290
|
+
const patchMessage = useCallback((message) => dispatch({ type: 'messagePatched', message }), []);
|
|
291
|
+
// Keep this API-backed action deliberately distinct from the cache-merge
|
|
292
|
+
// helper above. The server remains the authority for author/moderator rights.
|
|
293
|
+
const editMessage = useCallback(async (messageId, patch) => {
|
|
294
|
+
const result = await api.patchMessage(messageId, patch);
|
|
295
|
+
const message = result?.message || result;
|
|
296
|
+
patchMessage({ ...(cache.messages[messageId] || { id: messageId }), ...(message || {}), id: message?.id ?? messageId });
|
|
297
|
+
return result;
|
|
298
|
+
}, [api, cache.messages, patchMessage]);
|
|
299
|
+
const removeMessage = useCallback(async (messageId) => {
|
|
300
|
+
const result = await api.deleteMessage(messageId);
|
|
301
|
+
const message = result?.message || result;
|
|
302
|
+
const previous = cache.messages[messageId] || { id: messageId };
|
|
303
|
+
patchMessage({
|
|
304
|
+
...previous,
|
|
305
|
+
...(message || {}),
|
|
306
|
+
id: message?.id ?? messageId,
|
|
307
|
+
deleted_at: message?.deleted_at ?? previous.deleted_at ?? new Date().toISOString(),
|
|
308
|
+
deleted_by: message?.deleted_by ?? previous.deleted_by ?? user?.id,
|
|
309
|
+
body: null,
|
|
310
|
+
title: null,
|
|
311
|
+
link_target: null,
|
|
312
|
+
});
|
|
313
|
+
return result;
|
|
314
|
+
}, [api, cache.messages, patchMessage, user]);
|
|
315
|
+
const toggleReaction = useCallback(async (messageId, emoji, active) => {
|
|
316
|
+
const message = cache.messages[messageId] || { id: messageId };
|
|
317
|
+
const reactions = message.reactions || [];
|
|
318
|
+
const current = reactions.find((reaction) => reaction.emoji === emoji);
|
|
319
|
+
const nextReactions = active
|
|
320
|
+
? reactions.map((reaction) => reaction.emoji === emoji ? { ...reaction, reacted: false, count: Math.max(0, (reaction.count || 1) - 1) } : reaction).filter((reaction) => reaction.count !== 0)
|
|
321
|
+
: current
|
|
322
|
+
? reactions.map((reaction) => reaction.emoji === emoji ? { ...reaction, reacted: true, count: (reaction.count || 0) + 1 } : reaction)
|
|
323
|
+
: [...reactions, { emoji, count: 1, reacted: true }];
|
|
324
|
+
patchMessage({ ...message, reactions: nextReactions });
|
|
325
|
+
try {
|
|
326
|
+
const result = active ? await api.removeReaction(messageId, emoji) : await api.addReaction(messageId, emoji);
|
|
327
|
+
patchMessage({ ...message, ...(result?.message || result), reactions: result?.reactions || result?.message?.reactions || nextReactions });
|
|
328
|
+
return result;
|
|
329
|
+
} catch (error) { patchMessage(message); throw error; }
|
|
330
|
+
}, [api, cache.messages, patchMessage]);
|
|
331
|
+
const createConversationPoll = useCallback(async (conversationId, payload, { clientRequestId } = {}) => {
|
|
332
|
+
const poll = await api.createPoll(conversationId, { ...payload, client_request_id: clientRequestId }, { idempotencyKey: clientRequestId });
|
|
333
|
+
const message = poll?.message || poll;
|
|
334
|
+
if (message?.id != null) patchMessage({ ...message, conversation_id: message.conversation_id ?? conversationId, poll: message.poll || poll.poll || poll });
|
|
335
|
+
return poll;
|
|
336
|
+
}, [api, patchMessage]);
|
|
337
|
+
const castPollVote = useCallback(async (messageId, poll, optionIds) => {
|
|
338
|
+
const result = await api.votePoll(poll.id, optionIds);
|
|
339
|
+
const nextPoll = { ...poll, ...(result?.poll || result) };
|
|
340
|
+
patchMessage({ ...(cache.messages[messageId] || { id: messageId }), poll: nextPoll });
|
|
341
|
+
return result;
|
|
342
|
+
}, [api, cache.messages, patchMessage]);
|
|
343
|
+
const closeConversationPoll = useCallback(async (messageId, poll) => {
|
|
344
|
+
const result = await api.closePoll(poll.id);
|
|
345
|
+
const nextPoll = { ...poll, ...(result?.poll || result), closed_at: result?.poll?.closed_at || result?.closed_at || new Date().toISOString() };
|
|
346
|
+
patchMessage({ ...(cache.messages[messageId] || { id: messageId }), poll: nextPoll });
|
|
347
|
+
return result;
|
|
348
|
+
}, [api, cache.messages, patchMessage]);
|
|
349
|
+
const loadConversationConfig = useCallback((conversationId) => api.getConversationConfig(conversationId), [api]);
|
|
350
|
+
const saveConversationConfig = useCallback((conversationId, patch) => api.patchConversationConfig(conversationId, patch), [api]);
|
|
351
|
+
const refresh = useCallback(async () => Promise.all([refreshConversations(), refreshUnread(), refreshThread()]), [refreshConversations, refreshThread, refreshUnread]);
|
|
352
|
+
|
|
353
|
+
// `/api/messaging/` is authenticated-only (design §REST). Host apps mount
|
|
354
|
+
// MessagingProvider inside NotificationsProvider, which itself renders its
|
|
355
|
+
// children regardless of auth state — so without this gate a provider
|
|
356
|
+
// mounted before login completes fires three REST calls that spuriously
|
|
357
|
+
// 401. Defaults to true (matches chunk 1's existing tests/harness usage,
|
|
358
|
+
// which don't pass it); a host app gates it with `active={authenticated}`.
|
|
359
|
+
useEffect(() => { if (active) refresh().catch(() => {}); }, [active, refresh]);
|
|
360
|
+
useEffect(() => subscribe(MESSAGING_ENVELOPE, (frame) => {
|
|
361
|
+
if (frame.event_id && seenEventsRef.current.has(frame.event_id)) return;
|
|
362
|
+
if (frame.event_id) {
|
|
363
|
+
seenEventsRef.current.add(frame.event_id);
|
|
364
|
+
if (seenEventsRef.current.size > 500) seenEventsRef.current.delete(seenEventsRef.current.values().next().value);
|
|
365
|
+
}
|
|
366
|
+
dispatch({ type: 'frame', frame, activeConversationId: activeIdRef.current });
|
|
367
|
+
}), [subscribe]);
|
|
368
|
+
useEffect(() => {
|
|
369
|
+
if (!active || !onReconnect) return undefined;
|
|
370
|
+
return onReconnect(() => { refresh().catch(() => {}); });
|
|
371
|
+
}, [active, onReconnect, refresh]);
|
|
372
|
+
|
|
373
|
+
const value = useMemo(() => ({
|
|
374
|
+
cache, refresh, refreshConversations, refreshUnread, refreshThread, loadMoreMessages, loadThreadReplies, getMessageReadStatus, loadMoreConversations,
|
|
375
|
+
setConversationArchived, setConversationPreferences, openGroupConversation,
|
|
376
|
+
markConversationRead: markConversationAsRead, markThreadRead: markReplyThreadRead,
|
|
377
|
+
openBroadcastConversation, openDirectConversation, sendMessage, sendAttachments, editMessage, removeMessage, toggleReaction, createConversationPoll, castPollVote, closeConversationPoll,
|
|
378
|
+
loadConversationConfig, saveConversationConfig, currentUser: user, getAttachment: api.getAttachment,
|
|
379
|
+
getAttachmentThumbnail: api.getAttachmentThumbnail, activeConversationId,
|
|
380
|
+
}), [cache, refresh, refreshConversations, refreshUnread, refreshThread, loadMoreMessages, loadThreadReplies, getMessageReadStatus, loadMoreConversations,
|
|
381
|
+
setConversationArchived, setConversationPreferences, openGroupConversation, markConversationAsRead, markReplyThreadRead,
|
|
382
|
+
openBroadcastConversation, openDirectConversation, sendMessage, sendAttachments, editMessage, removeMessage, toggleReaction, createConversationPoll, castPollVote, closeConversationPoll,
|
|
383
|
+
loadConversationConfig, saveConversationConfig, user, api.getAttachment, api.getAttachmentThumbnail, activeConversationId]);
|
|
384
|
+
return <MessagingContext.Provider value={value}>{children}</MessagingContext.Provider>;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function useMessaging() {
|
|
388
|
+
const context = useOptionalMessaging();
|
|
389
|
+
if (!context) throw new Error('useMessaging must be used within a MessagingProvider');
|
|
390
|
+
return context;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function useOptionalMessaging() {
|
|
394
|
+
return useContext(MessagingContext);
|
|
395
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Alert, Box, CircularProgress, FormControl, FormControlLabel, InputLabel, MenuItem, Select, Stack, Switch, Typography } from '@mui/material';
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
|
|
5
|
+
import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
|
|
6
|
+
|
|
7
|
+
/** Provider-backed scope configuration surface; hosts choose its placement and capability gate. */
|
|
8
|
+
export function MessagingScopeConfig({ conversationId }) {
|
|
9
|
+
const { t } = useTranslation();
|
|
10
|
+
const { loadConversationConfig, saveConversationConfig } = useMessaging();
|
|
11
|
+
const [config, setConfig] = useState(null);
|
|
12
|
+
const [error, setError] = useState(null);
|
|
13
|
+
const [saving, setSaving] = useState(false);
|
|
14
|
+
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]);
|
|
15
|
+
const update = async (patch) => { setSaving(true); setError(null); try { setConfig(await saveConversationConfig(conversationId, patch)); } catch (saveError) { setError(t('MessagingConfig.SAVE_ERROR', { message: extractApiErrorMessage(saveError) })); } finally { setSaving(false); } };
|
|
16
|
+
if (!config) return error
|
|
17
|
+
? <Alert severity="error" role="alert">{error}</Alert>
|
|
18
|
+
: <Box aria-label={t('MessagingConfig.LOADING')} sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress size={24} /></Box>;
|
|
19
|
+
return <Stack spacing={1} aria-label={t('MessagingConfig.LABEL')}><Typography variant="subtitle1">{t('MessagingConfig.TITLE')}</Typography>{error && <Alert severity="error" role="alert">{error}</Alert>}
|
|
20
|
+
<FormControl fullWidth size="small" disabled={saving}><InputLabel>{t('MessagingConfig.DM_POLICY')}</InputLabel><Select label={t('MessagingConfig.DM_POLICY')} value={config.dm_policy || 'all'} onChange={(event) => update({ dm_policy: event.target.value })}><MenuItem value="all">{t('MessagingConfig.DM_ALL')}</MenuItem><MenuItem value="within_shared_groups">{t('MessagingConfig.DM_SHARED')}</MenuItem><MenuItem value="team_only">{t('MessagingConfig.DM_TEAM')}</MenuItem></Select></FormControl>
|
|
21
|
+
<FormControlLabel control={<Switch checked={Boolean(config.group_chat_enabled)} disabled={saving} onChange={(event) => update({ group_chat_enabled: event.target.checked })} />} label={t('MessagingConfig.GROUP_ENABLED')} />
|
|
22
|
+
<FormControlLabel control={<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')} />
|
|
23
|
+
</Stack>;
|
|
24
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Alert, Button, Checkbox, FormControlLabel, Paper, Stack, Typography } from '@mui/material';
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
|
|
5
|
+
import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
|
|
6
|
+
|
|
7
|
+
/** Independently mountable live poll view. Server authorization remains authoritative. */
|
|
8
|
+
export function PollCard({ message, canClose: canCloseProp }) {
|
|
9
|
+
const { t } = useTranslation();
|
|
10
|
+
const { castPollVote, closeConversationPoll, currentUser } = useMessaging();
|
|
11
|
+
const poll = message?.poll;
|
|
12
|
+
const [selected, setSelected] = useState([]);
|
|
13
|
+
const [error, setError] = useState(null);
|
|
14
|
+
const [busy, setBusy] = useState(false);
|
|
15
|
+
useEffect(() => setSelected((poll?.options || []).filter((option) => option.selected || option.voted).map((option) => option.id)), [poll]);
|
|
16
|
+
if (!poll) return null;
|
|
17
|
+
const closed = Boolean(poll.closed_at);
|
|
18
|
+
const creatorId = poll.created_by?.id ?? poll.created_by;
|
|
19
|
+
const canClose = canCloseProp ?? poll.can_close ?? message?.can_close ?? message?.permissions?.can_close ?? (creatorId != null && creatorId === currentUser?.id);
|
|
20
|
+
const changeSelection = (id) => setSelected((current) => poll.allow_multiple ? (current.includes(id) ? current.filter((value) => value !== id) : [...current, id]) : (current.includes(id) ? [] : [id]));
|
|
21
|
+
const vote = async () => {
|
|
22
|
+
if (!selected.length) { setError(t('MessagingPoll.SELECT_OPTION')); return; }
|
|
23
|
+
setBusy(true); setError(null);
|
|
24
|
+
try { await castPollVote(message.id, poll, selected); } catch (voteError) { setError(t('MessagingPoll.VOTE_ERROR', { message: extractApiErrorMessage(voteError) })); } finally { setBusy(false); }
|
|
25
|
+
};
|
|
26
|
+
const close = async () => {
|
|
27
|
+
setBusy(true); setError(null);
|
|
28
|
+
try { await closeConversationPoll(message.id, poll); } catch (closeError) { setError(t('MessagingPoll.CLOSE_ERROR', { message: extractApiErrorMessage(closeError) })); } finally { setBusy(false); }
|
|
29
|
+
};
|
|
30
|
+
return <Paper variant="outlined" sx={{ p: 1 }} aria-label={t('MessagingPoll.LABEL')}><Stack spacing={0.5}>
|
|
31
|
+
<Typography variant="subtitle2">{poll.question}</Typography>
|
|
32
|
+
{(poll.options || []).map((option) => <FormControlLabel key={option.id} disabled={closed || busy} control={<Checkbox checked={selected.includes(option.id)} onChange={() => changeSelection(option.id)} />} label={`${option.text || option.label} (${option.vote_count ?? option.votes_count ?? 0})`} />)}
|
|
33
|
+
{error && <Alert severity="error" role="alert">{error}</Alert>}
|
|
34
|
+
<Stack direction="row" spacing={1}><Button size="small" onClick={vote} disabled={closed || busy}>{t('MessagingPoll.VOTE')}</Button>{canClose && !closed && <Button size="small" color="warning" onClick={close} disabled={busy}>{t('MessagingPoll.CLOSE')}</Button>}{closed && <Typography variant="caption">{t('MessagingPoll.CLOSED')}</Typography>}</Stack>
|
|
35
|
+
</Stack></Paper>;
|
|
36
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import AddReactionOutlinedIcon from '@mui/icons-material/AddReactionOutlined';
|
|
2
|
+
import { Alert, Chip, IconButton, Stack } from '@mui/material';
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { useTranslation } from 'react-i18next';
|
|
5
|
+
|
|
6
|
+
import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
|
|
7
|
+
|
|
8
|
+
export const QUICK_EMOJIS = ['👍', '❤️', '😂', '🎉', '👀'];
|
|
9
|
+
|
|
10
|
+
/** Independently mountable aggregate reaction controls for one message. */
|
|
11
|
+
export function ReactionBar({ message }) {
|
|
12
|
+
const { t } = useTranslation();
|
|
13
|
+
const { toggleReaction } = useMessaging();
|
|
14
|
+
const [expanded, setExpanded] = useState(false);
|
|
15
|
+
const [error, setError] = useState(null);
|
|
16
|
+
// Tracks in-flight emoji toggles for this message so a rapid double-click
|
|
17
|
+
// can't fire two concurrent requests for the same emoji — toggleReaction's
|
|
18
|
+
// rollback-on-failure reverts to a pre-optimistic snapshot, which would
|
|
19
|
+
// silently discard a second, already-succeeded change if both were in
|
|
20
|
+
// flight at once. Disabling the chip while its own request is pending
|
|
21
|
+
// removes the race instead of trying to make concurrent toggles safe.
|
|
22
|
+
const [pending, setPending] = useState(() => new Set());
|
|
23
|
+
const reactions = message?.reactions || [];
|
|
24
|
+
const react = async (emoji, active = false) => {
|
|
25
|
+
if (pending.has(emoji)) return;
|
|
26
|
+
setError(null);
|
|
27
|
+
setPending((current) => new Set(current).add(emoji));
|
|
28
|
+
try { await toggleReaction(message.id, emoji, active); }
|
|
29
|
+
catch (reactionError) { setError(t('MessagingReactions.ERROR', { message: extractApiErrorMessage(reactionError) })); }
|
|
30
|
+
finally { setPending((current) => { const next = new Set(current); next.delete(emoji); return next; }); }
|
|
31
|
+
};
|
|
32
|
+
return <Stack spacing={0.5} aria-label={t('MessagingReactions.LABEL')}>
|
|
33
|
+
<Stack direction="row" spacing={0.5} flexWrap="wrap">
|
|
34
|
+
{reactions.map((reaction) => <Chip key={reaction.emoji} 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 })} />)}
|
|
35
|
+
<IconButton size="small" aria-label={t('MessagingReactions.ADD')} onClick={() => setExpanded((value) => !value)}><AddReactionOutlinedIcon fontSize="small" /></IconButton>
|
|
36
|
+
</Stack>
|
|
37
|
+
{expanded && <Stack direction="row" spacing={0.25} flexWrap="wrap">{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 <Chip key={emoji} size="small" disabled={pending.has(emoji)} color={existing?.reacted ? 'primary' : 'default'} label={emoji} onClick={() => { react(emoji, Boolean(existing?.reacted)); setExpanded(false); }} />;
|
|
43
|
+
})}</Stack>}
|
|
44
|
+
{error && <Alert severity="error" role="alert">{error}</Alert>}
|
|
45
|
+
</Stack>;
|
|
46
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { ButtonBase, List, ListItem, Popover, Typography } from '@mui/material';
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
|
|
5
|
+
import { useMessaging } from './MessagingProvider';
|
|
6
|
+
|
|
7
|
+
/** Renders only the aggregate receipt unless the server explicitly supplies permitted detail. */
|
|
8
|
+
export function ReadTicks({ messageId, conversation }) {
|
|
9
|
+
const { t } = useTranslation();
|
|
10
|
+
const { getMessageReadStatus } = useMessaging();
|
|
11
|
+
const [status, setStatus] = useState(null);
|
|
12
|
+
const [anchorEl, setAnchorEl] = useState(null);
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
let mounted = true;
|
|
15
|
+
getMessageReadStatus(messageId).then((next) => { if (mounted) setStatus(next); }).catch(() => { if (mounted) setStatus(null); });
|
|
16
|
+
return () => { mounted = false; };
|
|
17
|
+
}, [getMessageReadStatus, messageId]);
|
|
18
|
+
if (!status) return null;
|
|
19
|
+
const label = status.all_read ? t('MessagingReadTicks.ALL_READ') : t('MessagingReadTicks.DELIVERED', { count: status.delivered_count || 0 });
|
|
20
|
+
// The server never returns DM detail; enforce the same carve-out defensively.
|
|
21
|
+
const detail = conversation?.kind === 'direct' ? null : status.recipient_detail;
|
|
22
|
+
const recipients = detail?.map((recipient) => recipient.display_name || recipient.username || recipient.user_id).filter(Boolean) || [];
|
|
23
|
+
if (!recipients.length) return <Typography component="span" variant="caption" color="text.secondary" aria-label={label}>{label}</Typography>;
|
|
24
|
+
return <><ButtonBase aria-label={label} aria-haspopup="dialog" aria-expanded={Boolean(anchorEl)} onClick={(event) => setAnchorEl(event.currentTarget)} sx={{ typography: 'caption', color: 'text.secondary', textDecoration: 'underline' }}>{label}</ButtonBase><Popover open={Boolean(anchorEl)} anchorEl={anchorEl} onClose={() => setAnchorEl(null)}><List aria-label={t('MessagingReadTicks.RECIPIENTS')}>{recipients.map((recipient) => <ListItem key={recipient}>{recipient}</ListItem>)}</List></Popover></>;
|
|
25
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { Alert, Box, Button, CircularProgress, Divider, Stack, Typography } from '@mui/material';
|
|
2
|
+
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
|
|
5
|
+
import { AuthContext } from '../auth/AuthContext';
|
|
6
|
+
import { MessageBubble } from './MessageBubble';
|
|
7
|
+
import { useMessaging } from './MessagingProvider';
|
|
8
|
+
import { ReadTicks } from './ReadTicks';
|
|
9
|
+
|
|
10
|
+
function chronological(messages) { return [...messages].sort((left, right) => new Date(left.created_at || 0) - new Date(right.created_at || 0)); }
|
|
11
|
+
function replyToId(message) { return message.reply_to_id ?? message.reply_to; }
|
|
12
|
+
// A still-pending optimistic row (chunk 4) has a fake `local-<requestId>` id
|
|
13
|
+
// and no durable server row yet — mounting ReadTicks against it would fire a
|
|
14
|
+
// getMessageReadStatus REST call for an id the server has never heard of.
|
|
15
|
+
// Wait for reconciliation (status !== 'pending') before showing read state.
|
|
16
|
+
function canShowReadTicks(message, user) {
|
|
17
|
+
return Boolean(user?.id) && message.sender?.id === user.id && message.status !== 'pending';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A standalone timeline. Compose/reaction/poll/attachment mutations remain
|
|
22
|
+
* separate chunk collaborators.
|
|
23
|
+
*
|
|
24
|
+
* ReadTicks is only mounted for the current user's own messages (matches
|
|
25
|
+
* jg's `shouldShowReadTick(msg, isOwn)`): a read receipt tells the sender
|
|
26
|
+
* whether their own message was seen, not the recipient anything about a
|
|
27
|
+
* message they're currently reading. Gating here also bounds the number of
|
|
28
|
+
* `getMessageReadStatus` REST calls a rendered timeline fires — one per own
|
|
29
|
+
* message shown, not one per message in the conversation.
|
|
30
|
+
*/
|
|
31
|
+
export function Thread({ conversationId, onReplyTargetChange, canModerateMessages = false, onAnnouncementLink }) {
|
|
32
|
+
const { t } = useTranslation();
|
|
33
|
+
const { user } = useContext(AuthContext) || {};
|
|
34
|
+
const { cache, loadMoreMessages, loadThreadReplies, markConversationRead, markThreadRead } = useMessaging();
|
|
35
|
+
const scrollRef = useRef(null);
|
|
36
|
+
const wasNearBottomRef = useRef(true);
|
|
37
|
+
const messageCountRef = useRef(0);
|
|
38
|
+
const [loadingOlder, setLoadingOlder] = useState(false);
|
|
39
|
+
const [error, setError] = useState(null);
|
|
40
|
+
const [replyingTo, setReplyingTo] = useState(null);
|
|
41
|
+
const [openThreads, setOpenThreads] = useState({});
|
|
42
|
+
const conversation = cache.conversations[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
|
+
|
|
46
|
+
useEffect(() => { if (conversationId != null) markConversationRead(conversationId).catch(() => {}); }, [conversationId, markConversationRead]);
|
|
47
|
+
useEffect(() => { wasNearBottomRef.current = true; messageCountRef.current = 0; }, [conversationId]);
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (replyingTo && cache.messages[replyingTo.id]?.deleted_at) {
|
|
50
|
+
setReplyingTo(null);
|
|
51
|
+
onReplyTargetChange?.(null);
|
|
52
|
+
}
|
|
53
|
+
}, [cache.messages, onReplyTargetChange, replyingTo]);
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
const isNewMessage = conversationMessages.length > messageCountRef.current;
|
|
56
|
+
messageCountRef.current = conversationMessages.length;
|
|
57
|
+
if (!isNewMessage || !wasNearBottomRef.current || !scrollRef.current) return;
|
|
58
|
+
const timeline = scrollRef.current;
|
|
59
|
+
if (typeof timeline.scrollTo === 'function') timeline.scrollTo({ top: timeline.scrollHeight, behavior: 'smooth' });
|
|
60
|
+
else timeline.scrollTop = timeline.scrollHeight;
|
|
61
|
+
}, [conversationMessages]);
|
|
62
|
+
|
|
63
|
+
const loadOlder = useCallback(async () => {
|
|
64
|
+
setLoadingOlder(true); setError(null);
|
|
65
|
+
try { await loadMoreMessages(conversationId); } catch { setError(t('MessagingThread.LOAD_ERROR')); } finally { setLoadingOlder(false); }
|
|
66
|
+
}, [conversationId, loadMoreMessages, t]);
|
|
67
|
+
const onScroll = () => {
|
|
68
|
+
const timeline = scrollRef.current;
|
|
69
|
+
if (timeline) wasNearBottomRef.current = timeline.scrollHeight - timeline.scrollTop - timeline.clientHeight < 80;
|
|
70
|
+
if (timeline?.scrollTop === 0 && cache.cursors.messages[conversationId] && !loadingOlder) loadOlder();
|
|
71
|
+
};
|
|
72
|
+
const reply = (message) => { setReplyingTo(message); onReplyTargetChange?.(message); };
|
|
73
|
+
const jumpToMessage = (messageId) => {
|
|
74
|
+
const target = [...(scrollRef.current?.querySelectorAll('[data-message-id]') || [])]
|
|
75
|
+
.find((element) => String(element.dataset.messageId) === String(messageId));
|
|
76
|
+
target?.scrollIntoView?.({ block: 'center', behavior: 'smooth' });
|
|
77
|
+
};
|
|
78
|
+
const toggleReplies = async (root) => {
|
|
79
|
+
if (!openThreads[root.id]) {
|
|
80
|
+
try { await loadThreadReplies(root.id); } catch { setError(t('MessagingThread.REPLIES_ERROR')); return; }
|
|
81
|
+
markThreadRead(root.id).catch(() => {});
|
|
82
|
+
}
|
|
83
|
+
setOpenThreads((current) => ({ ...current, [root.id]: !current[root.id] }));
|
|
84
|
+
};
|
|
85
|
+
return (
|
|
86
|
+
<Stack spacing={1} sx={{ minWidth: 0 }}>
|
|
87
|
+
{error && <Alert severity="error" role="alert">{error}</Alert>}
|
|
88
|
+
{replyingTo && <Alert severity="info" onClose={() => { setReplyingTo(null); onReplyTargetChange?.(null); }}>{t('MessagingThread.REPLYING_TO', { sender: replyingTo.sender?.display_name || t('MessagingThread.UNKNOWN_SENDER') })}</Alert>}
|
|
89
|
+
<Box ref={scrollRef} onScroll={onScroll} sx={{ maxHeight: 560, overflowY: 'auto', px: 0.5 }} aria-label={t('MessagingThread.TIMELINE')}>
|
|
90
|
+
{cache.cursors.messages[conversationId] && <Box textAlign="center" py={1}><Button onClick={loadOlder} disabled={loadingOlder}>{loadingOlder ? <CircularProgress size={18} /> : t('MessagingThread.LOAD_OLDER')}</Button></Box>}
|
|
91
|
+
{!roots.length && <Typography color="text.secondary" textAlign="center" py={4}>{t('MessagingThread.EMPTY')}</Typography>}
|
|
92
|
+
<Stack spacing={1}>{roots.map((message) => {
|
|
93
|
+
const replies = chronological(Object.values(cache.messages).filter((item) => String(replyToId(item)) === String(message.id)));
|
|
94
|
+
return <Stack key={message.id} spacing={0.75}><MessageBubble message={message} conversation={conversation} onReply={reply} onJumpToMessage={jumpToMessage} onAnnouncementLink={onAnnouncementLink} canModerateMessages={canModerateMessages}>{canShowReadTicks(message, user) && <ReadTicks messageId={message.id} conversation={conversation} />}</MessageBubble>
|
|
95
|
+
{(message.reply_count || replies.length) > 0 && <Button size="small" onClick={() => toggleReplies(message)}>{openThreads[message.id] ? t('MessagingThread.HIDE_REPLIES') : t('MessagingThread.SHOW_REPLIES', { count: message.reply_count || replies.length })}</Button>}
|
|
96
|
+
{openThreads[message.id] && <Stack spacing={0.75} sx={{ pl: 3, borderLeft: 2, borderColor: 'divider' }}>{replies.map((item) => <MessageBubble key={item.id} message={item} replyTo={message} conversation={conversation} onReply={reply} onJumpToMessage={jumpToMessage} onAnnouncementLink={onAnnouncementLink} canModerateMessages={canModerateMessages}>{canShowReadTicks(item, user) && <ReadTicks messageId={item.id} conversation={conversation} />}</MessageBubble>)}</Stack>}
|
|
97
|
+
<Divider /></Stack>;
|
|
98
|
+
})}</Stack>
|
|
99
|
+
</Box>
|
|
100
|
+
</Stack>
|
|
101
|
+
);
|
|
102
|
+
}
|