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