@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,110 @@
1
+ import { Alert, Button, Checkbox, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, IconButton, Stack, TextField, Typography } from '@mui/material';
2
+ import AttachFileIcon from '@mui/icons-material/AttachFile';
3
+ import CampaignOutlinedIcon from '@mui/icons-material/CampaignOutlined';
4
+ import PollOutlinedIcon from '@mui/icons-material/PollOutlined';
5
+ import { useRef, useState } from 'react';
6
+ import { useTranslation } from 'react-i18next';
7
+
8
+ import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
9
+
10
+ function newRequestId() { return globalThis.crypto?.randomUUID?.() || `message-${Date.now()}-${Math.random().toString(36).slice(2)}`; }
11
+
12
+ /**
13
+ * A provider-backed, independently mountable REST composer.
14
+ *
15
+ * `allowAnnouncement` and `linkTarget`: whether announcement composing (jg's
16
+ * `AnnouncementDialog` — title + body + a deep-link target) is offered here at
17
+ * all is a host decision (the server enforces who may actually post one; the
18
+ * client only offers the affordance). `linkTarget`'s VALUE is inherently
19
+ * app-specific navigation context (jg pre-fills it from the currently open
20
+ * event-info section) that ucm cannot compute itself, so the host supplies it.
21
+ */
22
+ export function Composer({ conversationId, replyTarget = null, onReplyTargetChange, allowAnnouncement = false, linkTarget }) {
23
+ const { t } = useTranslation();
24
+ const { sendMessage, sendAttachments, createConversationPoll } = useMessaging();
25
+ const [body, setBody] = useState('');
26
+ const [files, setFiles] = useState([]);
27
+ const [error, setError] = useState(null);
28
+ const [failed, setFailed] = useState(null);
29
+ const [sending, setSending] = useState(false);
30
+ const [pollOpen, setPollOpen] = useState(false);
31
+ const [pollQuestion, setPollQuestion] = useState('');
32
+ const [pollOptions, setPollOptions] = useState(['', '']);
33
+ const [allowMultiple, setAllowMultiple] = useState(false);
34
+ const [announcementOpen, setAnnouncementOpen] = useState(false);
35
+ const [announcementTitle, setAnnouncementTitle] = useState('');
36
+ const [announcementBody, setAnnouncementBody] = useState('');
37
+ const inputRef = useRef(null);
38
+ const submit = async (requestId = newRequestId(), retry = false) => {
39
+ if (!body.trim() && !files.length) return;
40
+ setSending(true); setError(null);
41
+ const payload = { kind: 'chat', body: body.trim() || undefined, reply_to: replyTarget?.id || undefined, client_request_id: requestId };
42
+ try {
43
+ if (files.length) {
44
+ const formData = new FormData();
45
+ files.forEach((file) => formData.append('files[]', file));
46
+ if (payload.body) formData.append('body', payload.body);
47
+ if (payload.reply_to) formData.append('reply_to', String(payload.reply_to));
48
+ formData.append('client_request_id', requestId);
49
+ await sendAttachments(conversationId, formData, { clientRequestId: requestId, retry, optimisticMessage: { body: payload.body, reply_to: payload.reply_to, attachments: files.map((file, index) => ({ id: `local-${requestId}-${index}`, filename: file.name, content_type: file.type })) } });
50
+ } else await sendMessage(conversationId, payload, { clientRequestId: requestId, retry });
51
+ setBody(''); setFiles([]); setFailed(null); onReplyTargetChange?.(null);
52
+ } catch (sendError) {
53
+ const message = extractApiErrorMessage(sendError);
54
+ setError(files.length ? t('MessagingComposer.UPLOAD_ERROR', { message }) : t('MessagingComposer.SEND_ERROR', { message }));
55
+ setFailed({ requestId, retry: true });
56
+ } finally { setSending(false); }
57
+ };
58
+ const submitPoll = async () => {
59
+ const options = pollOptions.map((option) => option.trim()).filter(Boolean);
60
+ if (!pollQuestion.trim() || options.length < 2) { setError(t('MessagingPoll.CREATE_VALIDATION')); return; }
61
+ setSending(true); setError(null);
62
+ try {
63
+ await createConversationPoll(conversationId, { question: pollQuestion.trim(), options, allow_multiple: allowMultiple }, { clientRequestId: newRequestId() });
64
+ setPollQuestion(''); setPollOptions(['', '']); setAllowMultiple(false); setPollOpen(false);
65
+ } catch (pollError) { setError(t('MessagingPoll.CREATE_ERROR', { message: extractApiErrorMessage(pollError) })); } finally { setSending(false); }
66
+ };
67
+ const submitAnnouncement = async () => {
68
+ if (!announcementTitle.trim()) { setError(t('MessagingAnnouncement.VALIDATION')); return; }
69
+ setSending(true); setError(null);
70
+ const requestId = newRequestId();
71
+ try {
72
+ await sendMessage(conversationId, {
73
+ kind: 'announcement',
74
+ title: announcementTitle.trim(),
75
+ body: announcementBody.trim() || undefined,
76
+ link_target: linkTarget || undefined,
77
+ client_request_id: requestId,
78
+ }, { clientRequestId: requestId });
79
+ setAnnouncementTitle(''); setAnnouncementBody(''); setAnnouncementOpen(false);
80
+ } catch (announcementError) { setError(t('MessagingAnnouncement.ERROR', { message: extractApiErrorMessage(announcementError) })); } finally { setSending(false); }
81
+ };
82
+ return <Stack component="form" spacing={1} onSubmit={(event) => { event.preventDefault(); submit(); }} aria-label={t('MessagingComposer.LABEL')}>
83
+ {replyTarget && <Alert severity="info" onClose={() => onReplyTargetChange?.(null)}>{t('MessagingComposer.REPLYING_TO', { sender: replyTarget.sender?.display_name || t('MessagingThread.UNKNOWN_SENDER') })}</Alert>}
84
+ {error && <Alert severity="error" role="alert">{error}</Alert>}
85
+ {files.length > 0 && <Typography variant="caption">{t('MessagingComposer.FILES_SELECTED', { count: files.length })}</Typography>}
86
+ <Stack direction="row" spacing={1} alignItems="flex-end">
87
+ <input ref={inputRef} hidden type="file" multiple onChange={(event) => setFiles(Array.from(event.target.files || []))} />
88
+ <IconButton type="button" aria-label={t('MessagingComposer.ADD_ATTACHMENT')} onClick={() => inputRef.current?.click()}><AttachFileIcon /></IconButton>
89
+ <IconButton type="button" aria-label={t('MessagingPoll.CREATE')} onClick={() => setPollOpen(true)}><PollOutlinedIcon /></IconButton>
90
+ {allowAnnouncement && <IconButton type="button" aria-label={t('MessagingAnnouncement.CREATE')} onClick={() => setAnnouncementOpen(true)}><CampaignOutlinedIcon /></IconButton>}
91
+ <TextField fullWidth multiline minRows={2} value={body} onChange={(event) => setBody(event.target.value)} label={t('MessagingComposer.MESSAGE')} disabled={sending} />
92
+ <Button type="submit" variant="contained" disabled={sending || (!body.trim() && !files.length)}>{t('MessagingComposer.SEND')}</Button>
93
+ </Stack>
94
+ {failed && <Button type="button" onClick={() => submit(failed.requestId, true)} disabled={sending}>{t('MessagingComposer.RETRY')}</Button>}
95
+ <Dialog open={pollOpen} onClose={() => !sending && setPollOpen(false)} fullWidth maxWidth="sm">
96
+ <DialogTitle>{t('MessagingPoll.CREATE')}</DialogTitle><DialogContent><Stack spacing={1} sx={{ pt: 1 }}>
97
+ <TextField label={t('MessagingPoll.QUESTION')} value={pollQuestion} onChange={(event) => setPollQuestion(event.target.value)} autoFocus />
98
+ {pollOptions.map((option, index) => <TextField key={index} label={t('MessagingPoll.OPTION', { count: index + 1 })} value={option} onChange={(event) => setPollOptions((current) => current.map((value, optionIndex) => optionIndex === index ? event.target.value : value))} />)}
99
+ <Button type="button" onClick={() => setPollOptions((current) => [...current, ''])}>{t('MessagingPoll.ADD_OPTION')}</Button>
100
+ <FormControlLabel control={<Checkbox checked={allowMultiple} onChange={(event) => setAllowMultiple(event.target.checked)} />} label={t('MessagingPoll.ALLOW_MULTIPLE')} />
101
+ </Stack></DialogContent><DialogActions><Button onClick={() => setPollOpen(false)}>{t('MessagingPoll.CANCEL')}</Button><Button variant="contained" onClick={submitPoll} disabled={sending}>{t('MessagingPoll.CREATE')}</Button></DialogActions>
102
+ </Dialog>
103
+ {allowAnnouncement && <Dialog open={announcementOpen} onClose={() => !sending && setAnnouncementOpen(false)} fullWidth maxWidth="sm">
104
+ <DialogTitle>{t('MessagingAnnouncement.CREATE')}</DialogTitle><DialogContent><Stack spacing={1} sx={{ pt: 1 }}>
105
+ <TextField label={t('MessagingAnnouncement.TITLE')} value={announcementTitle} onChange={(event) => setAnnouncementTitle(event.target.value)} autoFocus />
106
+ <TextField label={t('MessagingAnnouncement.BODY')} value={announcementBody} onChange={(event) => setAnnouncementBody(event.target.value)} multiline minRows={2} />
107
+ </Stack></DialogContent><DialogActions><Button onClick={() => setAnnouncementOpen(false)}>{t('MessagingAnnouncement.CANCEL')}</Button><Button variant="contained" onClick={submitAnnouncement} disabled={sending}>{t('MessagingAnnouncement.SEND')}</Button></DialogActions>
108
+ </Dialog>}
109
+ </Stack>;
110
+ }
@@ -0,0 +1,51 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { Alert, Button, Stack } from '@mui/material';
3
+ import { useTranslation } from 'react-i18next';
4
+
5
+ import { useMessaging } from './MessagingProvider';
6
+
7
+ /**
8
+ * Host apps provide launchable scopes. This component owns the REST transition
9
+ * that turns one into a normalized conversation, but deliberately does not
10
+ * discover groups itself: that is app-specific scope-picker data.
11
+ */
12
+ export function ConversationLaunchers({ groupLaunchers = [], broadcastLauncher, autoOpenBroadcast = false, onOpen }) {
13
+ const { t } = useTranslation();
14
+ const { openGroupConversation, openBroadcastConversation } = useMessaging();
15
+ const [error, setError] = useState(null);
16
+ const autoOpened = useRef(false);
17
+
18
+ const open = async (kind, payload) => {
19
+ setError(null);
20
+ try {
21
+ const conversation = kind === 'broadcast'
22
+ ? await openBroadcastConversation(payload)
23
+ : await openGroupConversation(payload);
24
+ onOpen?.(conversation);
25
+ } catch {
26
+ setError(t('MessagingLaunchers.OPEN_ERROR'));
27
+ }
28
+ };
29
+
30
+ useEffect(() => {
31
+ if (!autoOpenBroadcast || !broadcastLauncher || autoOpened.current) return;
32
+ autoOpened.current = true;
33
+ open('broadcast', broadcastLauncher.payload ?? broadcastLauncher);
34
+ }, [autoOpenBroadcast, broadcastLauncher]); // one intentional auto-open per mounted launcher
35
+
36
+ return (
37
+ <Stack spacing={1}>
38
+ {broadcastLauncher && !autoOpenBroadcast && (
39
+ <Button variant="outlined" onClick={() => open('broadcast', broadcastLauncher.payload ?? broadcastLauncher)}>
40
+ {broadcastLauncher.label || t('MessagingLaunchers.OPEN_BROADCAST')}
41
+ </Button>
42
+ )}
43
+ {groupLaunchers.map((launcher) => (
44
+ <Button key={launcher.id ?? launcher.label} variant="outlined" onClick={() => open('group', launcher.payload ?? launcher)}>
45
+ {launcher.label || t('MessagingLaunchers.OPEN_GROUP')}
46
+ </Button>
47
+ ))}
48
+ {error && <Alert severity="error">{error}</Alert>}
49
+ </Stack>
50
+ );
51
+ }
@@ -0,0 +1,78 @@
1
+ import { useMemo, useState } from 'react';
2
+ import { Archive, Campaign, Groups, MoreVert, NotificationsOff, Person } from '@mui/icons-material';
3
+ import { Badge, Box, Button, CircularProgress, IconButton, List, ListItemButton, ListItemText, Menu, MenuItem, Stack, Typography } from '@mui/material';
4
+ import { useTranslation } from 'react-i18next';
5
+
6
+ import { useMessaging } from './MessagingProvider';
7
+ import { ConversationLaunchers } from './ConversationLaunchers';
8
+
9
+ function titleOf(conversation, t) { return conversation.title || conversation.other_user?.display_name || conversation.other_user?.username || t('MessagingList.UNTITLED'); }
10
+ function ordered(conversations) {
11
+ return [...conversations].sort((left, right) => new Date(right.last_message_at || 0) - new Date(left.last_message_at || 0));
12
+ }
13
+ // jg's ConversationList renders a distinct icon per conversation kind so a
14
+ // user can identify type/person at a glance (getConversationAvatar); that
15
+ // glanceable identification is a feature, not layout, so it's reproduced
16
+ // here rather than left as a generic archive-status glyph.
17
+ function KindIcon({ conversation }) {
18
+ if (conversation.kind === 'broadcast') return <Campaign fontSize="small" />;
19
+ if (conversation.kind === 'group' || conversation.kind === 'managed' || conversation.kind === 'object_thread') return <Groups fontSize="small" />;
20
+ return <Person fontSize="small" />;
21
+ }
22
+
23
+ /** A standalone, provider-backed list; scope picker metadata is supplied as launcher props. */
24
+ export function ConversationList({ onOpen, groupLaunchers, broadcastLauncher, autoOpenBroadcast = false, includeArchived = false }) {
25
+ const { t } = useTranslation();
26
+ const { cache, activeConversationId, loadMoreConversations, setConversationArchived, setConversationPreferences } = useMessaging();
27
+ const [menuConversation, setMenuConversation] = useState(null);
28
+ const [menuAnchor, setMenuAnchor] = useState(null);
29
+ const [loadingMore, setLoadingMore] = useState(false);
30
+ const [error, setError] = useState(null);
31
+ const conversations = useMemo(() => ordered(Object.values(cache.conversations).filter((item) => includeArchived || !item.archived_at && !item.archived)), [cache.conversations, includeArchived]);
32
+ const unreadFor = (conversation) => conversation.unread_count ?? cache.unread.by_conversation?.[conversation.id] ?? 0;
33
+ // Once opened, the broadcast conversation becomes a normal, recency-sorted
34
+ // row with real unread/preview state — hide the static launcher so it isn't
35
+ // shown twice (button + list row) with the button's stale, unread-less copy.
36
+ const broadcastAlreadyOpened = useMemo(() => Object.values(cache.conversations).some((item) => item.kind === 'broadcast'), [cache.conversations]);
37
+
38
+ const loadMore = async () => {
39
+ setLoadingMore(true); setError(null);
40
+ try { await loadMoreConversations(); } catch { setError(t('MessagingList.LOAD_ERROR')); } finally { setLoadingMore(false); }
41
+ };
42
+ const closeMenu = () => { setMenuConversation(null); setMenuAnchor(null); };
43
+ const archive = async () => {
44
+ try { await setConversationArchived(menuConversation.id, true); } catch { setError(t('MessagingList.ARCHIVE_ERROR')); }
45
+ closeMenu();
46
+ };
47
+ const toggleMute = async () => {
48
+ try { await setConversationPreferences(menuConversation.id, { muted: !menuConversation.muted }); } catch { setError(t('MessagingList.PREFERENCES_ERROR')); }
49
+ closeMenu();
50
+ };
51
+
52
+ return (
53
+ <Stack spacing={1}>
54
+ <ConversationLaunchers groupLaunchers={groupLaunchers} broadcastLauncher={broadcastAlreadyOpened ? null : broadcastLauncher} autoOpenBroadcast={autoOpenBroadcast && !broadcastAlreadyOpened} onOpen={onOpen} />
55
+ {error && <Typography color="error" role="alert">{error}</Typography>}
56
+ {!conversations.length ? <Box sx={{ py: 3, textAlign: 'center' }}><Typography color="text.secondary">{t('MessagingList.EMPTY')}</Typography></Box> : (
57
+ <List disablePadding aria-label={t('MessagingList.LABEL')}>
58
+ {conversations.map((conversation) => {
59
+ const unread = unreadFor(conversation);
60
+ const archived = Boolean(conversation.archived || conversation.archived_at);
61
+ return <ListItemButton key={conversation.id} selected={conversation.id === activeConversationId} onClick={() => onOpen?.(conversation)} sx={{ minHeight: 64, opacity: archived ? 0.6 : 1 }}>
62
+ <Badge color="error" badgeContent={unread || null} max={99} sx={{ mr: 2 }}>
63
+ {archived ? <Archive fontSize="small" color="disabled" /> : <KindIcon conversation={conversation} />}
64
+ </Badge>
65
+ <ListItemText primary={titleOf(conversation, t)} secondary={conversation.last_message?.body || t('MessagingList.NO_MESSAGES')} primaryTypographyProps={{ fontWeight: unread ? 700 : 400, noWrap: true }} secondaryTypographyProps={{ noWrap: true }} />
66
+ <IconButton aria-label={t('MessagingList.ACTIONS')} onClick={(event) => { event.stopPropagation(); setMenuAnchor(event.currentTarget); setMenuConversation(conversation); }}><MoreVert /></IconButton>
67
+ </ListItemButton>;
68
+ })}
69
+ </List>
70
+ )}
71
+ {cache.cursors.conversations && <Button onClick={loadMore} disabled={loadingMore}>{loadingMore ? <CircularProgress size={18} /> : t('MessagingList.LOAD_MORE')}</Button>}
72
+ <Menu anchorEl={menuAnchor} open={Boolean(menuConversation)} onClose={closeMenu}>
73
+ <MenuItem onClick={toggleMute}><NotificationsOff fontSize="small" sx={{ mr: 1 }} />{menuConversation?.muted ? t('MessagingList.UNMUTE') : t('MessagingList.MUTE')}</MenuItem>
74
+ <MenuItem onClick={archive}><Archive fontSize="small" sx={{ mr: 1 }} />{t('MessagingList.ARCHIVE')}</MenuItem>
75
+ </Menu>
76
+ </Stack>
77
+ );
78
+ }
@@ -0,0 +1,37 @@
1
+ import { Button, Paper, Stack, Typography } from '@mui/material';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { AttachmentList } from './AttachmentList';
4
+ import { ReactionBar } from './ReactionBar';
5
+ import { PollCard } from './PollCard';
6
+
7
+ function senderName(message) {
8
+ return message.sender?.display_name || message.sender?.username || message.sender_name || message.sender?.name;
9
+ }
10
+
11
+ /** Provider-backed timeline collaborator; it deliberately has no mutation controls. */
12
+ export function MessageBubble({ message, replyTo, onReply, children }) {
13
+ const { t } = useTranslation();
14
+ const deleted = Boolean(message.deleted_at);
15
+ const attachments = message.attachments || [];
16
+ return (
17
+ <Paper component="article" variant="outlined" sx={{ p: 1.25, maxWidth: 'min(100%, 680px)' }} aria-label={t('MessagingThread.MESSAGE')}>
18
+ <Stack spacing={0.75}>
19
+ {replyTo && <Typography variant="caption" color="text.secondary">{t('MessagingThread.REPLY_TO', { sender: senderName(replyTo) || t('MessagingThread.UNKNOWN_SENDER') })}</Typography>}
20
+ <Stack direction="row" spacing={1} alignItems="baseline">
21
+ <Typography variant="subtitle2">{senderName(message) || t('MessagingThread.UNKNOWN_SENDER')}</Typography>
22
+ {message.created_at && <Typography variant="caption" color="text.secondary">{new Date(message.created_at).toLocaleString()}</Typography>}
23
+ {message.edited_at && !deleted && <Typography variant="caption" color="text.secondary">{t('MessagingThread.EDITED')}</Typography>}
24
+ </Stack>
25
+ {!deleted && message.kind === 'announcement' && message.title && <Typography variant="subtitle1" fontWeight={700}>{message.title}</Typography>}
26
+ <Typography sx={{ whiteSpace: 'pre-wrap' }}>{deleted ? t('MessagingThread.DELETED') : (message.kind === 'announcement' ? message.body : (message.body || message.title)) || ''}</Typography>
27
+ {!deleted && <Stack spacing={0.75}>
28
+ {attachments.length > 0 && <AttachmentList attachments={attachments} />}
29
+ <ReactionBar message={message} />
30
+ {message.poll && <PollCard message={message} />}
31
+ </Stack>}
32
+ {!deleted && onReply && <Button type="button" size="small" onClick={() => onReply(message)}>{t('MessagingThread.REPLY')}</Button>}
33
+ {children}
34
+ </Stack>
35
+ </Paper>
36
+ );
37
+ }
@@ -0,0 +1,323 @@
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
+ createGroupConversation,
8
+ getReadStatus,
9
+ getUnreadCount,
10
+ listConversations,
11
+ listMessages,
12
+ listThread,
13
+ patchConversationPreferences,
14
+ createMessage,
15
+ uploadAttachments,
16
+ getAttachment,
17
+ getAttachmentThumbnail,
18
+ addReaction,
19
+ removeReaction,
20
+ createPoll,
21
+ votePoll,
22
+ closePoll,
23
+ getConversationConfig,
24
+ patchConversationConfig,
25
+ } from './api';
26
+ import { useRealtime } from '../notifications/realtime';
27
+
28
+ const MessagingContext = createContext(null);
29
+ export const MESSAGING_ENVELOPE = 'messaging';
30
+ const EMPTY_CACHE = { conversations: {}, messages: {}, threads: {}, polls: {}, reactions: {}, receipts: {}, unread: { unread_count: 0, by_conversation: {} }, cursors: { conversations: null, messages: {}, threads: {} } };
31
+ const DEFAULT_API = {
32
+ listConversations, listMessages, listThread, getReadStatus, getUnreadCount, archiveConversation,
33
+ patchConversationPreferences, createGroupConversation, createBroadcastConversation,
34
+ createMessage, uploadAttachments, getAttachment, getAttachmentThumbnail,
35
+ addReaction, removeReaction, createPoll, votePoll, closePoll, getConversationConfig, patchConversationConfig,
36
+ };
37
+
38
+ function idOf(item) { return item?.id ?? item?.conversation_id ?? item?.message_id ?? item?.poll_id; }
39
+ function mergeById(slice, item) { const id = idOf(item); return id == null ? slice : { ...slice, [id]: { ...slice[id], ...item } }; }
40
+ function resultsOf(response) { return response?.results || []; }
41
+ function clientRequestIdOf(message) { return message?.client_request_id; }
42
+
43
+ /**
44
+ * dcm's DRF validation errors aren't always {detail}: ConversationAttachmentView
45
+ * translates a rejected upload into a field-keyed {"files": [...]} body (the
46
+ * MSG-2 chunk-4 fix — see django-core-micha messaging/views.py), while other
47
+ * endpoints use {detail}. Check every shape a caller might actually receive
48
+ * before falling back to the generic axios error message.
49
+ */
50
+ export function extractApiErrorMessage(error) {
51
+ const data = error?.response?.data;
52
+ if (typeof data?.detail === 'string') return data.detail;
53
+ if (typeof data?.message === 'string') return data.message;
54
+ if (Array.isArray(data?.files)) return data.files.join(' ');
55
+ if (data && typeof data === 'object') {
56
+ const firstArray = Object.values(data).find((value) => Array.isArray(value) && value.length);
57
+ if (firstArray) return firstArray.join(' ');
58
+ }
59
+ return error?.message;
60
+ }
61
+
62
+ function reconcileMessage(messages, message) {
63
+ const requestId = clientRequestIdOf(message);
64
+ const matchingEntry = requestId && Object.entries(messages).find(([, item]) => clientRequestIdOf(item) === requestId);
65
+ if (!matchingEntry) return mergeById(messages, message);
66
+ const [previousKey, previous] = matchingEntry;
67
+ const next = { ...messages };
68
+ delete next[previousKey];
69
+ const id = idOf(message) ?? previousKey;
70
+ next[id] = { ...previous, ...message, id: idOf(message) ?? previous.id, status: message.status || 'sent', error: null };
71
+ return next;
72
+ }
73
+
74
+ export function messagingReducer(state, action) {
75
+ switch (action.type) {
76
+ case 'conversationsLoaded': {
77
+ const conversations = action.results.reduce((next, item) => mergeById(next, item), state.conversations);
78
+ return { ...state, conversations, cursors: { ...state.cursors, conversations: action.nextCursor ?? null } };
79
+ }
80
+ case 'unreadLoaded': return { ...state, unread: action.unread || EMPTY_CACHE.unread };
81
+ case 'messagesLoaded': {
82
+ const messages = action.results.reduce((next, item) => mergeById(next, item), state.messages);
83
+ return { ...state, messages, cursors: { ...state.cursors, messages: { ...state.cursors.messages, [action.conversationId]: action.nextCursor ?? null } } };
84
+ }
85
+ case 'threadLoaded': {
86
+ const messages = action.results.reduce((next, item) => mergeById(next, item), state.messages);
87
+ return { ...state, messages, cursors: { ...state.cursors, threads: { ...state.cursors.threads, [action.rootId]: action.nextCursor ?? null } } };
88
+ }
89
+ case 'conversationUpsert': return { ...state, conversations: mergeById(state.conversations, action.conversation) };
90
+ case 'messageOptimistic': return { ...state, messages: mergeById(state.messages, action.message) };
91
+ case 'messageReconciled': return { ...state, messages: reconcileMessage(state.messages, action.message) };
92
+ case 'messageFailed': {
93
+ const matching = Object.entries(state.messages).find(([, item]) => clientRequestIdOf(item) === action.clientRequestId);
94
+ if (!matching) return state;
95
+ const [key, message] = matching;
96
+ return { ...state, messages: { ...state.messages, [key]: { ...message, status: 'error', error: action.error } } };
97
+ }
98
+ case 'messagePatched': return { ...state, messages: mergeById(state.messages, action.message) };
99
+ case 'frame': return applyFrame(state, action.frame);
100
+ default: return state;
101
+ }
102
+ }
103
+
104
+ export function applyFrame(state, frame) {
105
+ const payload = frame.message || frame.conversation || frame.participant || frame;
106
+ const conversationId = frame.conversation_id ?? payload.conversation_id;
107
+ if (frame.type === 'message') {
108
+ const message = { ...payload, conversation_id: conversationId };
109
+ return { ...state, messages: reconcileMessage(state.messages, message) };
110
+ }
111
+ if (frame.type === 'message_edited') return { ...state, messages: mergeById(state.messages, payload) };
112
+ if (frame.type === 'reaction') {
113
+ const messageId = frame.message_id ?? payload.message_id;
114
+ const previous = state.messages[messageId] || { id: messageId };
115
+ return { ...state, messages: mergeById(state.messages, { ...previous, reactions: payload.reactions || frame.reactions || previous.reactions || [] }) };
116
+ }
117
+ if (frame.type === 'poll_updated') {
118
+ const messageId = frame.message_id ?? payload.message_id;
119
+ const previous = state.messages[messageId] || { id: messageId };
120
+ const poll = payload.poll || frame.poll || payload;
121
+ return { ...state, messages: mergeById(state.messages, { ...previous, poll }), polls: mergeById(state.polls, poll) };
122
+ }
123
+ if (frame.type === 'message_deleted') {
124
+ const previous = state.messages[frame.message_id] || {};
125
+ 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 } } };
126
+ }
127
+ if (frame.type === 'conversation_upsert') return { ...state, conversations: mergeById(state.conversations, payload) };
128
+ // conversation_archived/participant_changed: the design doc names these frame
129
+ // types but doesn't pin their exact payload shape. Treating them as merging
130
+ // onto the conversation record (participant-local fields folded flat, an
131
+ // `archived` flag set) is this chunk's best-effort reading, not a confirmed
132
+ // contract — re-verify against dcm's actual WS payload once available, per
133
+ // the WO's "shape fixtures from the design doc, not from imagination" caution.
134
+ if (frame.type === 'conversation_archived') return { ...state, conversations: mergeById(state.conversations, { ...payload, id: conversationId, archived: true }) };
135
+ if (frame.type === 'participant_changed') return { ...state, conversations: mergeById(state.conversations, { ...payload, id: conversationId }) };
136
+ return state;
137
+ }
138
+
139
+ export function MessagingProvider({ children, filters = {}, activeConversationId = null, api = DEFAULT_API, active = true }) {
140
+ const { subscribe, onReconnect } = useRealtime();
141
+ const { user } = useContext(AuthContext) || {};
142
+ const [cache, dispatch] = useReducer(messagingReducer, EMPTY_CACHE);
143
+ const activeIdRef = useRef(activeConversationId);
144
+ const seenEventsRef = useRef(new Set());
145
+ const filtersRef = useRef(filters);
146
+ activeIdRef.current = activeConversationId;
147
+ filtersRef.current = filters;
148
+
149
+ const refreshConversations = useCallback(async () => {
150
+ const response = await api.listConversations(filtersRef.current);
151
+ dispatch({ type: 'conversationsLoaded', results: resultsOf(response), nextCursor: response?.next_cursor });
152
+ return response;
153
+ }, [api]);
154
+ const refreshUnread = useCallback(async () => {
155
+ const unread = await api.getUnreadCount();
156
+ dispatch({ type: 'unreadLoaded', unread });
157
+ return unread;
158
+ }, [api]);
159
+ const refreshThread = useCallback(async (conversationId = activeIdRef.current, { cursor } = {}) => {
160
+ if (conversationId == null) return null;
161
+ const response = await api.listMessages(conversationId, ...(cursor ? [{ cursor }] : []));
162
+ dispatch({ type: 'messagesLoaded', conversationId, results: resultsOf(response), nextCursor: response?.next_cursor });
163
+ return response;
164
+ }, [api]);
165
+ const loadMoreMessages = useCallback((conversationId = activeIdRef.current) => {
166
+ const cursor = cache.cursors.messages[conversationId];
167
+ return cursor ? refreshThread(conversationId, { cursor }) : Promise.resolve(null);
168
+ }, [cache.cursors.messages, refreshThread]);
169
+ const loadThreadReplies = useCallback(async (rootId, { cursor } = {}) => {
170
+ const response = await api.listThread(rootId, { cursor });
171
+ dispatch({ type: 'threadLoaded', rootId, results: resultsOf(response), nextCursor: response?.next_cursor });
172
+ return response;
173
+ }, [api]);
174
+ const getMessageReadStatus = useCallback((messageId) => api.getReadStatus(messageId), [api]);
175
+ const loadMoreConversations = useCallback(async () => {
176
+ const cursor = cache.cursors.conversations;
177
+ if (!cursor) return null;
178
+ const response = await api.listConversations({ ...filtersRef.current, cursor });
179
+ dispatch({ type: 'conversationsLoaded', results: resultsOf(response), nextCursor: response?.next_cursor });
180
+ return response;
181
+ }, [api, cache.cursors.conversations]);
182
+ const updateConversation = useCallback((conversation) => {
183
+ dispatch({ type: 'conversationUpsert', conversation });
184
+ return conversation;
185
+ }, []);
186
+ const setConversationArchived = useCallback(async (conversationId, archived) => {
187
+ const conversation = await api.archiveConversation(conversationId, archived);
188
+ return updateConversation({ ...conversation, id: conversation?.id ?? conversationId, archived: Boolean(archived) });
189
+ }, [api, updateConversation]);
190
+ const setConversationPreferences = useCallback(async (conversationId, patch) => {
191
+ const conversation = await api.patchConversationPreferences(conversationId, patch);
192
+ return updateConversation({ ...conversation, id: conversation?.id ?? conversationId, ...patch });
193
+ }, [api, updateConversation]);
194
+ const openGroupConversation = useCallback(async (payload) => {
195
+ const conversation = await api.createGroupConversation(payload);
196
+ return updateConversation(conversation);
197
+ }, [api, updateConversation]);
198
+ const openBroadcastConversation = useCallback(async (payload) => {
199
+ const conversation = await api.createBroadcastConversation(payload);
200
+ return updateConversation(conversation);
201
+ }, [api, updateConversation]);
202
+ const sendMessage = useCallback(async (conversationId, payload, { clientRequestId, retry = false } = {}) => {
203
+ const requestId = clientRequestId || payload.client_request_id;
204
+ const optimistic = {
205
+ id: `local-${requestId}`,
206
+ conversation_id: conversationId,
207
+ kind: payload.kind || 'chat',
208
+ body: payload.body || '',
209
+ title: payload.title || null,
210
+ link_target: payload.link_target || null,
211
+ reply_to: payload.reply_to || null,
212
+ client_request_id: requestId,
213
+ created_at: new Date().toISOString(),
214
+ status: 'pending',
215
+ sender: user ? { id: user.id, display_name: user.display_name || user.username } : undefined,
216
+ ...(retry ? {} : {}),
217
+ };
218
+ if (!retry) dispatch({ type: 'messageOptimistic', message: optimistic });
219
+ else dispatch({ type: 'messageReconciled', message: { ...optimistic, status: 'pending', error: null } });
220
+ try {
221
+ const message = await api.createMessage(conversationId, { ...payload, client_request_id: requestId }, { idempotencyKey: requestId });
222
+ dispatch({ type: 'messageReconciled', message: { ...message, conversation_id: message?.conversation_id ?? conversationId, client_request_id: message?.client_request_id ?? requestId } });
223
+ return message;
224
+ } catch (error) {
225
+ dispatch({ type: 'messageFailed', clientRequestId: requestId, error: extractApiErrorMessage(error) });
226
+ throw error;
227
+ }
228
+ }, [api, user]);
229
+ const sendAttachments = useCallback(async (conversationId, formData, { clientRequestId, optimisticMessage, retry = false } = {}) => {
230
+ const optimistic = {
231
+ id: `local-${clientRequestId}`, conversation_id: conversationId, kind: 'chat', body: optimisticMessage?.body || '',
232
+ reply_to: optimisticMessage?.reply_to || null, client_request_id: clientRequestId, created_at: new Date().toISOString(), status: 'pending',
233
+ sender: user ? { id: user.id, display_name: user.display_name || user.username } : undefined,
234
+ attachments: optimisticMessage?.attachments || [],
235
+ };
236
+ if (!retry) dispatch({ type: 'messageOptimistic', message: optimistic });
237
+ else dispatch({ type: 'messageReconciled', message: { ...optimistic, status: 'pending', error: null } });
238
+ try {
239
+ const message = await api.uploadAttachments(conversationId, formData, { idempotencyKey: clientRequestId });
240
+ dispatch({ type: 'messageReconciled', message: { ...message, conversation_id: message?.conversation_id ?? conversationId, client_request_id: message?.client_request_id ?? clientRequestId } });
241
+ return message;
242
+ } catch (error) {
243
+ dispatch({ type: 'messageFailed', clientRequestId, error: extractApiErrorMessage(error) });
244
+ throw error;
245
+ }
246
+ }, [api, user]);
247
+ const patchMessage = useCallback((message) => dispatch({ type: 'messagePatched', message }), []);
248
+ const toggleReaction = useCallback(async (messageId, emoji, active) => {
249
+ const message = cache.messages[messageId] || { id: messageId };
250
+ const reactions = message.reactions || [];
251
+ const current = reactions.find((reaction) => reaction.emoji === emoji);
252
+ const nextReactions = active
253
+ ? reactions.map((reaction) => reaction.emoji === emoji ? { ...reaction, reacted: false, count: Math.max(0, (reaction.count || 1) - 1) } : reaction).filter((reaction) => reaction.count !== 0)
254
+ : current
255
+ ? reactions.map((reaction) => reaction.emoji === emoji ? { ...reaction, reacted: true, count: (reaction.count || 0) + 1 } : reaction)
256
+ : [...reactions, { emoji, count: 1, reacted: true }];
257
+ patchMessage({ ...message, reactions: nextReactions });
258
+ try {
259
+ const result = active ? await api.removeReaction(messageId, emoji) : await api.addReaction(messageId, emoji);
260
+ patchMessage({ ...message, ...(result?.message || result), reactions: result?.reactions || result?.message?.reactions || nextReactions });
261
+ return result;
262
+ } catch (error) { patchMessage(message); throw error; }
263
+ }, [api, cache.messages, patchMessage]);
264
+ const createConversationPoll = useCallback(async (conversationId, payload, { clientRequestId } = {}) => {
265
+ const poll = await api.createPoll(conversationId, { ...payload, client_request_id: clientRequestId }, { idempotencyKey: clientRequestId });
266
+ const message = poll?.message || poll;
267
+ if (message?.id != null) patchMessage({ ...message, conversation_id: message.conversation_id ?? conversationId, poll: message.poll || poll.poll || poll });
268
+ return poll;
269
+ }, [api, patchMessage]);
270
+ const castPollVote = useCallback(async (messageId, poll, optionIds) => {
271
+ const result = await api.votePoll(poll.id, optionIds);
272
+ const nextPoll = { ...poll, ...(result?.poll || result) };
273
+ patchMessage({ ...(cache.messages[messageId] || { id: messageId }), poll: nextPoll });
274
+ return result;
275
+ }, [api, cache.messages, patchMessage]);
276
+ const closeConversationPoll = useCallback(async (messageId, poll) => {
277
+ const result = await api.closePoll(poll.id);
278
+ const nextPoll = { ...poll, ...(result?.poll || result), closed_at: result?.poll?.closed_at || result?.closed_at || new Date().toISOString() };
279
+ patchMessage({ ...(cache.messages[messageId] || { id: messageId }), poll: nextPoll });
280
+ return result;
281
+ }, [api, cache.messages, patchMessage]);
282
+ const loadConversationConfig = useCallback((conversationId) => api.getConversationConfig(conversationId), [api]);
283
+ const saveConversationConfig = useCallback((conversationId, patch) => api.patchConversationConfig(conversationId, patch), [api]);
284
+ const refresh = useCallback(async () => Promise.all([refreshConversations(), refreshUnread(), refreshThread()]), [refreshConversations, refreshThread, refreshUnread]);
285
+
286
+ // `/api/messaging/` is authenticated-only (design §REST). Host apps mount
287
+ // MessagingProvider inside NotificationsProvider, which itself renders its
288
+ // children regardless of auth state — so without this gate a provider
289
+ // mounted before login completes fires three REST calls that spuriously
290
+ // 401. Defaults to true (matches chunk 1's existing tests/harness usage,
291
+ // which don't pass it); a host app gates it with `active={authenticated}`.
292
+ useEffect(() => { if (active) refresh().catch(() => {}); }, [active, refresh]);
293
+ useEffect(() => subscribe(MESSAGING_ENVELOPE, (frame) => {
294
+ if (frame.event_id && seenEventsRef.current.has(frame.event_id)) return;
295
+ if (frame.event_id) {
296
+ seenEventsRef.current.add(frame.event_id);
297
+ if (seenEventsRef.current.size > 500) seenEventsRef.current.delete(seenEventsRef.current.values().next().value);
298
+ }
299
+ dispatch({ type: 'frame', frame });
300
+ }), [subscribe]);
301
+ useEffect(() => {
302
+ if (!active || !onReconnect) return undefined;
303
+ return onReconnect(() => { refresh().catch(() => {}); });
304
+ }, [active, onReconnect, refresh]);
305
+
306
+ const value = useMemo(() => ({
307
+ cache, refresh, refreshConversations, refreshUnread, refreshThread, loadMoreMessages, loadThreadReplies, getMessageReadStatus, loadMoreConversations,
308
+ setConversationArchived, setConversationPreferences, openGroupConversation,
309
+ openBroadcastConversation, sendMessage, sendAttachments, toggleReaction, createConversationPoll, castPollVote, closeConversationPoll,
310
+ loadConversationConfig, saveConversationConfig, currentUser: user, getAttachment: api.getAttachment,
311
+ getAttachmentThumbnail: api.getAttachmentThumbnail, activeConversationId,
312
+ }), [cache, refresh, refreshConversations, refreshUnread, refreshThread, loadMoreMessages, loadThreadReplies, getMessageReadStatus, loadMoreConversations,
313
+ setConversationArchived, setConversationPreferences, openGroupConversation,
314
+ openBroadcastConversation, sendMessage, sendAttachments, toggleReaction, createConversationPoll, castPollVote, closeConversationPoll,
315
+ loadConversationConfig, saveConversationConfig, user, api.getAttachment, api.getAttachmentThumbnail, activeConversationId]);
316
+ return <MessagingContext.Provider value={value}>{children}</MessagingContext.Provider>;
317
+ }
318
+
319
+ export function useMessaging() {
320
+ const context = useContext(MessagingContext);
321
+ if (!context) throw new Error('useMessaging must be used within a MessagingProvider');
322
+ return context;
323
+ }
@@ -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
+ }