@micha.bigler/ui-core-micha 2.15.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 (52) hide show
  1. package/README.md +7 -0
  2. package/WORK_ORDERS.md +5 -1
  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/i18n/messagingTranslations.js +81 -0
  8. package/dist/index.js +14 -0
  9. package/dist/messaging/AttachmentList.js +43 -0
  10. package/dist/messaging/Composer.js +120 -0
  11. package/dist/messaging/ConversationLaunchers.js +39 -0
  12. package/dist/messaging/ConversationList.js +75 -0
  13. package/dist/messaging/MessageBubble.js +17 -0
  14. package/dist/messaging/MessagingProvider.js +317 -0
  15. package/dist/messaging/MessagingScopeConfig.js +28 -0
  16. package/dist/messaging/PollCard.js +53 -0
  17. package/dist/messaging/ReactionBar.js +44 -0
  18. package/dist/messaging/ReadTicks.js +25 -0
  19. package/dist/messaging/Thread.js +73 -0
  20. package/dist/messaging/api.js +51 -0
  21. package/dist/notifications/NotificationsProvider.js +2 -2
  22. package/dist/notifications/realtime.js +21 -1
  23. package/docs/messaging-deviations.md +36 -0
  24. package/index.html +12 -0
  25. package/package.json +4 -1
  26. package/src/i18n/messagingTranslations.ts +81 -0
  27. package/src/index.js +15 -0
  28. package/src/messaging/AttachmentList.jsx +38 -0
  29. package/src/messaging/Composer.jsx +110 -0
  30. package/src/messaging/ConversationLaunchers.jsx +51 -0
  31. package/src/messaging/ConversationList.jsx +78 -0
  32. package/src/messaging/MessageBubble.jsx +37 -0
  33. package/src/messaging/MessagingProvider.jsx +323 -0
  34. package/src/messaging/MessagingScopeConfig.jsx +24 -0
  35. package/src/messaging/PollCard.jsx +36 -0
  36. package/src/messaging/ReactionBar.jsx +46 -0
  37. package/src/messaging/ReadTicks.jsx +23 -0
  38. package/src/messaging/Thread.jsx +71 -0
  39. package/src/messaging/api.js +47 -0
  40. package/src/notifications/NotificationsProvider.jsx +2 -2
  41. package/src/notifications/realtime.jsx +21 -1
  42. package/tests/ConversationList.test.jsx +66 -0
  43. package/tests/MessagingProvider.test.jsx +50 -0
  44. package/tests/Thread.test.jsx +100 -0
  45. package/tests/messagingApi.test.js +32 -0
  46. package/tests/messagingComposer.test.jsx +111 -0
  47. package/tests/messagingExports.test.js +20 -0
  48. package/tests/messagingInteractions.test.jsx +112 -0
  49. package/tests/realtime.test.jsx +27 -0
  50. package/vite.config.mjs +6 -0
  51. package/work-orders/DX-1.md +166 -0
  52. package/work-orders/MSG-3.md +375 -0
@@ -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
+ 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,23 @@
1
+ import { Tooltip, 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
+ useEffect(() => {
13
+ let mounted = true;
14
+ getMessageReadStatus(messageId).then((next) => { if (mounted) setStatus(next); }).catch(() => { if (mounted) setStatus(null); });
15
+ return () => { mounted = false; };
16
+ }, [getMessageReadStatus, messageId]);
17
+ if (!status) return null;
18
+ const label = status.all_read ? t('MessagingReadTicks.ALL_READ') : t('MessagingReadTicks.DELIVERED', { count: status.delivered_count || 0 });
19
+ // The server never returns DM detail; enforce the same carve-out defensively.
20
+ const detail = conversation?.kind === 'direct' ? null : status.recipient_detail;
21
+ const detailText = detail?.map((recipient) => recipient.display_name || recipient.username || recipient.user_id).filter(Boolean).join(', ');
22
+ return <Tooltip title={detailText || label}><Typography component="span" variant="caption" color="text.secondary" aria-label={label}>{label}</Typography></Tooltip>;
23
+ }
@@ -0,0 +1,71 @@
1
+ import { Alert, Box, Button, CircularProgress, Divider, Stack, Typography } from '@mui/material';
2
+ import { useCallback, useContext, 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
+ // A still-pending optimistic row (chunk 4) has a fake `local-<requestId>` id
12
+ // and no durable server row yet — mounting ReadTicks against it would fire a
13
+ // getMessageReadStatus REST call for an id the server has never heard of.
14
+ // Wait for reconciliation (status !== 'pending') before showing read state.
15
+ function canShowReadTicks(message, user) {
16
+ return Boolean(user?.id) && message.sender?.id === user.id && message.status !== 'pending';
17
+ }
18
+
19
+ /**
20
+ * A standalone timeline. Compose/reaction/poll/attachment mutations remain
21
+ * separate chunk collaborators.
22
+ *
23
+ * ReadTicks is only mounted for the current user's own messages (matches
24
+ * jg's `shouldShowReadTick(msg, isOwn)`): a read receipt tells the sender
25
+ * whether their own message was seen, not the recipient anything about a
26
+ * message they're currently reading. Gating here also bounds the number of
27
+ * `getMessageReadStatus` REST calls a rendered timeline fires — one per own
28
+ * message shown, not one per message in the conversation.
29
+ */
30
+ export function Thread({ conversationId, onReplyTargetChange }) {
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
+
42
+ const loadOlder = useCallback(async () => {
43
+ setLoadingOlder(true); setError(null);
44
+ try { await loadMoreMessages(conversationId); } catch { setError(t('MessagingThread.LOAD_ERROR')); } finally { setLoadingOlder(false); }
45
+ }, [conversationId, loadMoreMessages, t]);
46
+ const onScroll = () => { if (scrollRef.current?.scrollTop === 0 && cache.cursors.messages[conversationId] && !loadingOlder) loadOlder(); };
47
+ const reply = (message) => { setReplyingTo(message); onReplyTargetChange?.(message); };
48
+ const toggleReplies = async (root) => {
49
+ if (!openThreads[root.id]) {
50
+ try { await loadThreadReplies(root.id); } catch { setError(t('MessagingThread.REPLIES_ERROR')); return; }
51
+ }
52
+ setOpenThreads((current) => ({ ...current, [root.id]: !current[root.id] }));
53
+ };
54
+ return (
55
+ <Stack spacing={1} sx={{ minWidth: 0 }}>
56
+ {error && <Alert severity="error" role="alert">{error}</Alert>}
57
+ {replyingTo && <Alert severity="info" onClose={() => { setReplyingTo(null); onReplyTargetChange?.(null); }}>{t('MessagingThread.REPLYING_TO', { sender: replyingTo.sender?.display_name || t('MessagingThread.UNKNOWN_SENDER') })}</Alert>}
58
+ <Box ref={scrollRef} onScroll={onScroll} sx={{ maxHeight: 560, overflowY: 'auto', px: 0.5 }} aria-label={t('MessagingThread.TIMELINE')}>
59
+ {cache.cursors.messages[conversationId] && <Box textAlign="center" py={1}><Button onClick={loadOlder} disabled={loadingOlder}>{loadingOlder ? <CircularProgress size={18} /> : t('MessagingThread.LOAD_OLDER')}</Button></Box>}
60
+ {!roots.length && <Typography color="text.secondary" textAlign="center" py={4}>{t('MessagingThread.EMPTY')}</Typography>}
61
+ <Stack spacing={1}>{roots.map((message) => {
62
+ const replies = chronological(Object.values(cache.messages).filter((item) => item.reply_to === message.id));
63
+ return <Stack key={message.id} spacing={0.75}><MessageBubble message={message} onReply={reply}>{canShowReadTicks(message, user) && <ReadTicks messageId={message.id} conversation={conversation} />}</MessageBubble>
64
+ {(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>}
65
+ {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} onReply={reply}>{canShowReadTicks(item, user) && <ReadTicks messageId={item.id} conversation={conversation} />}</MessageBubble>)}</Stack>}
66
+ <Divider /></Stack>;
67
+ })}</Stack>
68
+ </Box>
69
+ </Stack>
70
+ );
71
+ }
@@ -0,0 +1,47 @@
1
+ import apiClient from '../auth/apiClient';
2
+
3
+ const BASE_URL = '/api/messaging/';
4
+ const conversationsUrl = `${BASE_URL}conversations/`;
5
+
6
+ function data(response) {
7
+ return response.data;
8
+ }
9
+
10
+ function requestConfig({ cursor, params, headers } = {}) {
11
+ return {
12
+ ...(params ? { params } : {}),
13
+ ...(cursor ? { params: { ...(params || {}), cursor } } : {}),
14
+ ...(headers ? { headers } : {}),
15
+ };
16
+ }
17
+
18
+ export function listConversations({ cursor, ...filters } = {}) {
19
+ return apiClient.get(conversationsUrl, requestConfig({ cursor, params: filters })).then(data);
20
+ }
21
+ export function createDirectConversation(payload) { return apiClient.post(`${conversationsUrl}direct/`, payload).then(data); }
22
+ export function createGroupConversation(payload) { return apiClient.post(`${conversationsUrl}group/`, payload).then(data); }
23
+ export function createBroadcastConversation(payload) { return apiClient.post(`${conversationsUrl}broadcast/`, payload).then(data); }
24
+ export function createManagedConversation(payload) { return apiClient.post(`${conversationsUrl}managed/`, payload).then(data); }
25
+ export function createObjectThreadConversation(payload) { return apiClient.post(`${conversationsUrl}object-thread/`, payload).then(data); }
26
+ export function getConversationConfig(conversationId) { return apiClient.get(`${conversationsUrl}${conversationId}/config/`).then(data); }
27
+ export function patchConversationConfig(conversationId, patch) { return apiClient.patch(`${conversationsUrl}${conversationId}/config/`, patch).then(data); }
28
+ export function archiveConversation(conversationId, archived) { return apiClient.post(`${conversationsUrl}${conversationId}/archive/`, { archived }).then(data); }
29
+ export function patchConversationPreferences(conversationId, patch) { return apiClient.post(`${conversationsUrl}${conversationId}/preferences/`, patch).then(data); }
30
+ export function listMessages(conversationId, { cursor } = {}) { return apiClient.get(`${conversationsUrl}${conversationId}/messages/`, requestConfig({ cursor })).then(data); }
31
+ export function createMessage(conversationId, payload, { idempotencyKey } = {}) { return apiClient.post(`${conversationsUrl}${conversationId}/messages/`, payload, requestConfig({ headers: idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : undefined })).then(data); }
32
+ export function uploadAttachments(conversationId, formData, { idempotencyKey } = {}) { return apiClient.post(`${conversationsUrl}${conversationId}/attachments/`, formData, requestConfig({ headers: idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : undefined })).then(data); }
33
+ export function createPoll(conversationId, payload, { idempotencyKey } = {}) { return apiClient.post(`${conversationsUrl}${conversationId}/polls/`, payload, requestConfig({ headers: idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : undefined })).then(data); }
34
+ export function getMessage(messageId) { return apiClient.get(`${BASE_URL}messages/${messageId}/`).then(data); }
35
+ export function patchMessage(messageId, patch) { return apiClient.patch(`${BASE_URL}messages/${messageId}/`, patch).then(data); }
36
+ export function deleteMessage(messageId) { return apiClient.delete(`${BASE_URL}messages/${messageId}/`).then(data); }
37
+ export function addReaction(messageId, emoji) { return apiClient.post(`${BASE_URL}messages/${messageId}/reactions/`, { emoji }).then(data); }
38
+ export function removeReaction(messageId, emoji) { return apiClient.delete(`${BASE_URL}messages/${messageId}/reactions/${encodeURIComponent(emoji)}/`).then(data); }
39
+ export function listThread(rootId, { cursor } = {}) { return apiClient.get(`${BASE_URL}messages/${rootId}/thread/`, requestConfig({ cursor })).then(data); }
40
+ export function markThreadRead(rootId, readAt) { return apiClient.post(`${BASE_URL}messages/${rootId}/thread/read/`, readAt ? { read_at: readAt } : {}).then(data); }
41
+ export function markConversationRead(conversationId, readAt) { return apiClient.post(`${conversationsUrl}${conversationId}/read/`, readAt ? { read_at: readAt } : {}).then(data); }
42
+ export function getReadStatus(messageId) { return apiClient.get(`${BASE_URL}messages/${messageId}/read-status/`).then(data); }
43
+ export function votePoll(pollId, optionIds) { return apiClient.post(`${BASE_URL}polls/${pollId}/vote/`, { option_ids: optionIds }).then(data); }
44
+ export function closePoll(pollId) { return apiClient.post(`${BASE_URL}polls/${pollId}/close/`).then(data); }
45
+ export function getAttachment(attachmentId) { return apiClient.get(`${BASE_URL}attachments/${attachmentId}/`, { responseType: 'blob' }).then(data); }
46
+ export function getAttachmentThumbnail(attachmentId) { return apiClient.get(`${BASE_URL}attachments/${attachmentId}/thumbnail/`, { responseType: 'blob' }).then(data); }
47
+ export function getUnreadCount() { return apiClient.get(`${BASE_URL}unread-count/`).then(data); }
@@ -89,7 +89,7 @@ export function NotificationsProvider({ children, wsUrlBase }) {
89
89
  // arrived and patched state before the initial REST response landed.
90
90
  const [seeded, setSeeded] = useState(false);
91
91
 
92
- const { subscribe } = useRealtimeCore({
92
+ const { subscribe, onReconnect } = useRealtimeCore({
93
93
  active: authenticated && seeded,
94
94
  wsUrl: getWebSocketUrl(wsUrlBase),
95
95
  });
@@ -200,7 +200,7 @@ export function NotificationsProvider({ children, wsUrlBase }) {
200
200
  const markDone = useCallback((ids) => mark('done', ids), [mark]);
201
201
 
202
202
  return (
203
- <RealtimeContext.Provider value={{ subscribe }}>
203
+ <RealtimeContext.Provider value={{ subscribe, onReconnect }}>
204
204
  <NotificationsContext.Provider value={{
205
205
  notifications,
206
206
  unreadCount,
@@ -33,6 +33,7 @@ export { RealtimeContext };
33
33
  export function useRealtimeCore({ active, wsUrl }) {
34
34
  const socketRef = useRef(null);
35
35
  const subscribersRef = useRef(new Map());
36
+ const reconnectSubscribersRef = useRef(new Set());
36
37
 
37
38
  const subscribe = useCallback((envelope, handler) => {
38
39
  const subscribers = subscribersRef.current;
@@ -50,12 +51,21 @@ export function useRealtimeCore({ active, wsUrl }) {
50
51
  };
51
52
  }, []);
52
53
 
54
+ // Additive lifecycle signal for domains whose REST projections must be
55
+ // refreshed after the one shared transport reconnects. It deliberately does
56
+ // not alter the socket/backoff ownership or the envelope subscriber API.
57
+ const onReconnect = useCallback((handler) => {
58
+ reconnectSubscribersRef.current.add(handler);
59
+ return () => reconnectSubscribersRef.current.delete(handler);
60
+ }, []);
61
+
53
62
  useEffect(() => {
54
63
  if (!active) return undefined;
55
64
 
56
65
  let isActive = true;
57
66
  let reconnectTimer = null;
58
67
  let backoffMs = INITIAL_BACKOFF_MS;
68
+ let hasConnected = false;
59
69
 
60
70
  const handleMessage = (event) => {
61
71
  let data;
@@ -85,6 +95,16 @@ export function useRealtimeCore({ active, wsUrl }) {
85
95
 
86
96
  socket.onopen = () => {
87
97
  backoffMs = INITIAL_BACKOFF_MS;
98
+ if (hasConnected) {
99
+ reconnectSubscribersRef.current.forEach((handler) => {
100
+ try {
101
+ handler();
102
+ } catch (error) {
103
+ console.error('Realtime reconnect subscriber error', error);
104
+ }
105
+ });
106
+ }
107
+ hasConnected = true;
88
108
  };
89
109
  socket.onmessage = handleMessage;
90
110
  socket.onerror = () => {
@@ -114,5 +134,5 @@ export function useRealtimeCore({ active, wsUrl }) {
114
134
  };
115
135
  }, [active, wsUrl]);
116
136
 
117
- return { subscribe };
137
+ return { subscribe, onReconnect };
118
138
  }
@@ -0,0 +1,66 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
5
+
6
+ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key) => key }) }));
7
+ vi.mock('../src/notifications/realtime', () => ({ useRealtime: () => ({ subscribe: () => () => {}, onReconnect: () => () => {} }) }));
8
+
9
+ import { ConversationList } from '../src/messaging/ConversationList';
10
+ import { MessagingProvider } from '../src/messaging/MessagingProvider';
11
+
12
+ function renderList(api, props = {}) {
13
+ return render(<MessagingProvider api={api}><ConversationList onOpen={props.onOpen} {...props} /></MessagingProvider>);
14
+ }
15
+
16
+ describe('ConversationList', () => {
17
+ let api;
18
+ beforeEach(() => {
19
+ api = {
20
+ listConversations: vi.fn().mockResolvedValue({ results: [{ id: 1, title: 'First', unread_count: 2, last_message: { body: 'Hello' } }], next_cursor: 'opaque-cursor' }),
21
+ getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 2, by_conversation: { 1: 2 } }),
22
+ listMessages: vi.fn().mockResolvedValue({ results: [] }),
23
+ archiveConversation: vi.fn().mockResolvedValue({ id: 1, archived: true }),
24
+ patchConversationPreferences: vi.fn().mockResolvedValue({ id: 1, muted: true }),
25
+ createGroupConversation: vi.fn().mockResolvedValue({ id: 4, title: 'Volunteers', kind: 'group' }),
26
+ createBroadcastConversation: vi.fn().mockResolvedValue({ id: 5, title: 'Announcements', kind: 'broadcast' }),
27
+ };
28
+ });
29
+ afterEach(cleanup);
30
+
31
+ it('loads the next opaque cursor page without offset assumptions', async () => {
32
+ api.listConversations.mockResolvedValueOnce({ results: [{ id: 1, title: 'First' }], next_cursor: 'opaque-cursor' }).mockResolvedValueOnce({ results: [{ id: 2, title: 'Second' }], next_cursor: null });
33
+ renderList(api);
34
+ await screen.findByText('First');
35
+ fireEvent.click(screen.getByRole('button', { name: 'MessagingList.LOAD_MORE' }));
36
+ await screen.findByText('Second');
37
+ // Append, not replace: the first page's item must still be present.
38
+ expect(screen.getByText('First')).toBeTruthy();
39
+ expect(api.listConversations).toHaveBeenLastCalledWith({ cursor: 'opaque-cursor' });
40
+ });
41
+
42
+ it('opens host-provided group and broadcast launchers into normalized list state', async () => {
43
+ const onOpen = vi.fn();
44
+ renderList(api, { onOpen, groupLaunchers: [{ id: 'volunteers', label: 'Volunteers', payload: { title: 'Volunteers' } }], broadcastLauncher: { label: 'Announcements', payload: {} } });
45
+ await screen.findByText('First');
46
+ fireEvent.click(screen.getByRole('button', { name: 'Volunteers' }));
47
+ await waitFor(() => expect(api.createGroupConversation).toHaveBeenCalledWith({ title: 'Volunteers' }));
48
+ expect(onOpen).toHaveBeenCalledWith(expect.objectContaining({ id: 4 }));
49
+ fireEvent.click(screen.getByRole('button', { name: 'Announcements' }));
50
+ await waitFor(() => expect(api.createBroadcastConversation).toHaveBeenCalled());
51
+ // Once opened it becomes a normal (unread/preview-bearing) list row; the
52
+ // static launcher button must not keep showing it a second time.
53
+ await waitFor(() => expect(screen.queryByRole('button', { name: 'Announcements' })).toBeNull());
54
+ });
55
+
56
+ it('wires mute and archive through participant APIs', async () => {
57
+ renderList(api);
58
+ await screen.findByText('First');
59
+ fireEvent.click(screen.getByRole('button', { name: 'MessagingList.ACTIONS' }));
60
+ fireEvent.click(screen.getByText('MessagingList.MUTE'));
61
+ await waitFor(() => expect(api.patchConversationPreferences).toHaveBeenCalledWith(1, { muted: true }));
62
+ fireEvent.click(screen.getByRole('button', { name: 'MessagingList.ACTIONS' }));
63
+ fireEvent.click(screen.getByText('MessagingList.ARCHIVE'));
64
+ await waitFor(() => expect(api.archiveConversation).toHaveBeenCalledWith(1, true));
65
+ });
66
+ });
@@ -0,0 +1,50 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { act, render, screen, waitFor } from '@testing-library/react';
5
+
6
+ import { RealtimeContext } from '../src/notifications/realtime';
7
+ import { MessagingProvider, useMessaging } from '../src/messaging/MessagingProvider';
8
+
9
+ function Harness() { const { cache } = useMessaging(); return <output data-testid="cache">{JSON.stringify(cache)}</output>; }
10
+ function makeTransport() {
11
+ const handlers = new Set(); const reconnectHandlers = new Set();
12
+ return { subscribe: vi.fn((_envelope, handler) => { handlers.add(handler); return () => handlers.delete(handler); }), onReconnect: vi.fn((handler) => { reconnectHandlers.add(handler); return () => reconnectHandlers.delete(handler); }), frame: (frame) => handlers.forEach((handler) => handler(frame)), reconnect: () => reconnectHandlers.forEach((handler) => handler()) };
13
+ }
14
+ function renderProvider({ transport = makeTransport(), api, activeConversationId = 1, active } = {}) {
15
+ const props = { api, activeConversationId, ...(active === undefined ? {} : { active }) };
16
+ render(<RealtimeContext.Provider value={transport}><MessagingProvider {...props}><Harness /></MessagingProvider></RealtimeContext.Provider>);
17
+ return transport;
18
+ }
19
+ const cache = () => JSON.parse(screen.getByTestId('cache').textContent);
20
+
21
+ describe('MessagingProvider', () => {
22
+ it('deduplicates repeated event IDs and only changes the affected message slice', async () => {
23
+ const api = { listConversations: vi.fn().mockResolvedValue({ results: [{ id: 1 }, { id: 2 }] }), listMessages: vi.fn().mockResolvedValue({ results: [{ id: 10, conversation_id: 2, body: 'unchanged' }] }), getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }) };
24
+ const transport = renderProvider({ api }); await waitFor(() => expect(api.listConversations).toHaveBeenCalled());
25
+ const frame = { envelope: 'messaging', type: 'message', event_id: 'once', conversation_id: 1, message: { id: 11, body: 'new' } };
26
+ act(() => { transport.frame(frame); transport.frame(frame); });
27
+ expect(Object.keys(cache().messages)).toEqual(['10', '11']);
28
+ expect(cache().messages['10'].body).toBe('unchanged');
29
+ });
30
+
31
+ it('refetches list, active thread and unread state when Layer 1 reconnects', async () => {
32
+ const api = { listConversations: vi.fn().mockResolvedValue({ results: [] }), listMessages: vi.fn().mockResolvedValue({ results: [] }), getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }) };
33
+ const transport = renderProvider({ api }); await waitFor(() => expect(api.listMessages).toHaveBeenCalledTimes(1));
34
+ await act(async () => transport.reconnect());
35
+ await waitFor(() => { expect(api.listConversations).toHaveBeenCalledTimes(2); expect(api.listMessages).toHaveBeenLastCalledWith(1); expect(api.getUnreadCount).toHaveBeenCalledTimes(2); });
36
+ });
37
+
38
+ it('does not call the authenticated-only REST surface while active=false, and starts fetching once it flips true', async () => {
39
+ const api = { listConversations: vi.fn().mockResolvedValue({ results: [] }), listMessages: vi.fn().mockResolvedValue({ results: [] }), getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }) };
40
+ const transport = makeTransport();
41
+ const { rerender } = render(<RealtimeContext.Provider value={transport}><MessagingProvider api={api} activeConversationId={1} active={false}><Harness /></MessagingProvider></RealtimeContext.Provider>);
42
+ await new Promise((resolve) => setTimeout(resolve, 0));
43
+ expect(api.listConversations).not.toHaveBeenCalled();
44
+ expect(api.listMessages).not.toHaveBeenCalled();
45
+ expect(api.getUnreadCount).not.toHaveBeenCalled();
46
+
47
+ rerender(<RealtimeContext.Provider value={transport}><MessagingProvider api={api} activeConversationId={1} active><Harness /></MessagingProvider></RealtimeContext.Provider>);
48
+ await waitFor(() => expect(api.listConversations).toHaveBeenCalledTimes(1));
49
+ });
50
+ });
@@ -0,0 +1,100 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
5
+
6
+ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options?.count != null ? `${key}:${options.count}` : key }) }));
7
+ vi.mock('../src/notifications/realtime', () => ({ useRealtime: () => ({ subscribe: () => () => {}, onReconnect: () => () => {} }) }));
8
+
9
+ import { AuthContext } from '../src/auth/AuthContext';
10
+ import { MessageBubble } from '../src/messaging/MessageBubble';
11
+ import { MessagingProvider } from '../src/messaging/MessagingProvider';
12
+ import { ReadTicks } from '../src/messaging/ReadTicks';
13
+ import { Thread } from '../src/messaging/Thread';
14
+
15
+ function makeApi() {
16
+ return {
17
+ listConversations: vi.fn().mockResolvedValue({ results: [{ id: 1, kind: 'group' }] }),
18
+ getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
19
+ listMessages: vi.fn().mockResolvedValue({ results: [{ id: 2, conversation_id: 1, body: 'Visible message', created_at: '2026-07-31T10:00:00Z' }], next_cursor: 'signed-opaque-cursor' }),
20
+ listThread: vi.fn().mockResolvedValue({ results: [] }),
21
+ getReadStatus: vi.fn().mockResolvedValue({ all_read: false, delivered_count: 2 }),
22
+ };
23
+ }
24
+ function renderThread(api, { userId = 9 } = {}) {
25
+ return render(
26
+ <AuthContext.Provider value={{ user: { id: userId } }}>
27
+ <MessagingProvider api={api} activeConversationId={1}><Thread conversationId={1} /></MessagingProvider>
28
+ </AuthContext.Provider>,
29
+ );
30
+ }
31
+
32
+ describe('messaging Thread surfaces', () => {
33
+ let api;
34
+ beforeEach(() => { api = makeApi(); });
35
+ afterEach(cleanup);
36
+
37
+ it('uses the opaque message cursor and shows a rejected older page as an error', async () => {
38
+ api.listMessages.mockResolvedValueOnce({ results: [{ id: 2, conversation_id: 1, body: 'Visible message' }], next_cursor: 'signed-opaque-cursor' }).mockRejectedValueOnce(new Error('bad cursor'));
39
+ renderThread(api);
40
+ await screen.findByText('Visible message');
41
+ fireEvent.click(screen.getByRole('button', { name: 'MessagingThread.LOAD_OLDER' }));
42
+ await screen.findByRole('alert');
43
+ expect(api.listMessages).toHaveBeenLastCalledWith(1, { cursor: 'signed-opaque-cursor' });
44
+ expect(screen.getByText('Visible message')).toBeTruthy();
45
+ });
46
+
47
+ it('renders deleted content as a redacted placeholder', () => {
48
+ render(<MessageBubble message={{ id: 4, body: 'Stale secret', deleted_at: '2026-07-31T10:00:00Z' }} />);
49
+ expect(screen.getByText('MessagingThread.DELETED')).toBeTruthy();
50
+ expect(screen.queryByText('Stale secret')).toBeNull();
51
+ });
52
+
53
+ it('renders aggregate read state and never exposes supplied recipient details for a direct conversation', async () => {
54
+ api.getReadStatus.mockResolvedValue({ all_read: false, delivered_count: 2, recipient_detail: [{ display_name: 'Private recipient' }] });
55
+ render(<MessagingProvider api={api} active={false}><ReadTicks messageId={2} conversation={{ id: 1, kind: 'direct' }} /></MessagingProvider>);
56
+ await screen.findByLabelText('MessagingReadTicks.DELIVERED:2');
57
+ expect(screen.queryByText('Private recipient')).toBeNull();
58
+ });
59
+
60
+ it('renders supplied recipient detail for a non-direct conversation (the permitted case)', async () => {
61
+ api.getReadStatus.mockResolvedValue({ all_read: false, delivered_count: 2, recipient_detail: [{ display_name: 'Visible recipient' }] });
62
+ render(<MessagingProvider api={api} active={false}><ReadTicks messageId={2} conversation={{ id: 1, kind: 'group' }} /></MessagingProvider>);
63
+ const tick = await screen.findByLabelText('MessagingReadTicks.DELIVERED:2');
64
+ fireEvent.mouseOver(tick);
65
+ await screen.findByText('Visible recipient');
66
+ });
67
+
68
+ it('only mounts ReadTicks (and its REST call) for the current user\'s own messages, not every message in the timeline', async () => {
69
+ api.listMessages.mockResolvedValue({
70
+ results: [
71
+ { id: 2, conversation_id: 1, body: 'Mine', created_at: '2026-07-31T10:00:00Z', sender: { id: 9 } },
72
+ { id: 3, conversation_id: 1, body: 'Theirs', created_at: '2026-07-31T10:01:00Z', sender: { id: 42 } },
73
+ ],
74
+ next_cursor: null,
75
+ });
76
+ renderThread(api, { userId: 9 });
77
+ await screen.findByText('Mine');
78
+ await screen.findByText('Theirs');
79
+ await waitFor(() => expect(api.getReadStatus).toHaveBeenCalledTimes(1));
80
+ expect(api.getReadStatus).toHaveBeenCalledWith(2);
81
+ });
82
+
83
+ it('does not mount ReadTicks (or fire a REST call) for a still-pending optimistic own message', async () => {
84
+ api.listMessages.mockResolvedValue({
85
+ results: [{ id: 'local-r9', conversation_id: 1, body: 'Sending...', created_at: '2026-07-31T10:00:00Z', sender: { id: 9 }, status: 'pending', client_request_id: 'r9' }],
86
+ next_cursor: null,
87
+ });
88
+ renderThread(api, { userId: 9 });
89
+ await screen.findByText('Sending...');
90
+ expect(api.getReadStatus).not.toHaveBeenCalled();
91
+ });
92
+
93
+ it('mounts Thread and ReadTicks independently', async () => {
94
+ renderThread(api);
95
+ await screen.findByLabelText('MessagingThread.TIMELINE');
96
+ cleanup();
97
+ render(<MessagingProvider api={api} active={false}><ReadTicks messageId={2} conversation={{ id: 1, kind: 'group' }} /></MessagingProvider>);
98
+ await waitFor(() => expect(screen.getByLabelText('MessagingReadTicks.DELIVERED:2')).toBeTruthy());
99
+ });
100
+ });
@@ -0,0 +1,32 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const client = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() }));
4
+ vi.mock('../src/auth/apiClient', () => ({ default: client }));
5
+
6
+ import * as api from '../src/messaging/api';
7
+
8
+ const response = { data: { ok: true } };
9
+ beforeEach(() => { vi.clearAllMocks(); Object.values(client).forEach((method) => method.mockResolvedValue(response)); });
10
+
11
+ describe('messaging API contract', () => {
12
+ it('uses the published conversations and message routes', async () => {
13
+ await api.listConversations({ scope_kind: 'container', cursor: 'opaque' });
14
+ await api.createDirectConversation({ target_user_id: 8 });
15
+ await api.createGroupConversation({ title: 'Group' }); await api.createBroadcastConversation({}); await api.createManagedConversation({}); await api.createObjectThreadConversation({});
16
+ await api.getConversationConfig(1); await api.patchConversationConfig(1, {}); await api.archiveConversation(1, true); await api.patchConversationPreferences(1, {});
17
+ await api.listMessages(1, { cursor: 'opaque' }); await api.createMessage(1, { body: 'x' }, { idempotencyKey: 'key' }); await api.uploadAttachments(1, new FormData()); await api.createPoll(1, {});
18
+ expect(client.get).toHaveBeenCalledWith('/api/messaging/conversations/', { params: { scope_kind: 'container', cursor: 'opaque' } });
19
+ expect(client.post).toHaveBeenCalledWith('/api/messaging/conversations/direct/', { target_user_id: 8 });
20
+ expect(client.post).toHaveBeenCalledWith('/api/messaging/conversations/1/messages/', { body: 'x' }, { headers: { 'Idempotency-Key': 'key' } });
21
+ expect(client.get).toHaveBeenCalledWith('/api/messaging/conversations/1/messages/', { params: { cursor: 'opaque' } });
22
+ });
23
+
24
+ it('uses the published detail, receipt, poll and attachment routes', async () => {
25
+ await api.getMessage(2); await api.patchMessage(2, {}); await api.deleteMessage(2); await api.addReaction(2, '👍'); await api.removeReaction(2, '👍');
26
+ await api.listThread(2, { cursor: 'opaque' }); await api.markThreadRead(2); await api.markConversationRead(3); await api.getReadStatus(2); await api.votePoll(4, [5]); await api.closePoll(4); await api.getAttachment(6); await api.getAttachmentThumbnail(6); await api.getUnreadCount();
27
+ expect(client.delete).toHaveBeenCalledWith('/api/messaging/messages/2/reactions/%F0%9F%91%8D/');
28
+ expect(client.get).toHaveBeenCalledWith('/api/messaging/messages/2/thread/', { params: { cursor: 'opaque' } });
29
+ expect(client.post).toHaveBeenCalledWith('/api/messaging/conversations/3/read/', {});
30
+ expect(client.get).toHaveBeenCalledWith('/api/messaging/unread-count/');
31
+ });
32
+ });