@micha.bigler/ui-core-micha 2.14.0 → 2.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/WORK_ORDERS.md +8 -2
- 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/components/charts/BarChart.js +32 -0
- package/dist/components/charts/ChartFrame.js +50 -0
- package/dist/components/charts/LineChart.js +32 -0
- package/dist/components/charts/exportChart.js +67 -0
- package/dist/components/charts/formatters.js +23 -0
- package/dist/components/charts/palette.js +21 -0
- package/dist/i18n/chartsTranslations.js +38 -0
- package/dist/i18n/messagingTranslations.js +81 -0
- package/dist/index.js +25 -4
- 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 +6 -1
- package/src/components/charts/BarChart.jsx +44 -0
- package/src/components/charts/ChartFrame.jsx +112 -0
- package/src/components/charts/LineChart.jsx +44 -0
- package/src/components/charts/exportChart.js +71 -0
- package/src/components/charts/formatters.js +39 -0
- package/src/components/charts/palette.js +24 -0
- package/src/i18n/chartsTranslations.ts +38 -0
- package/src/i18n/messagingTranslations.ts +81 -0
- package/src/index.js +33 -4
- 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/BarChart.test.jsx +55 -0
- package/tests/ChartFrame.test.jsx +122 -0
- package/tests/ConversationList.test.jsx +66 -0
- package/tests/LineChart.test.jsx +33 -0
- package/tests/MessagingProvider.test.jsx +50 -0
- package/tests/Thread.test.jsx +100 -0
- package/tests/chartsPalette.test.js +39 -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/CHART-1.md +285 -0
- package/work-orders/DX-1.md +166 -0
- package/work-orders/MSG-3.md +375 -0
package/src/index.js
CHANGED
|
@@ -39,10 +39,23 @@ export { AuthFactorRequirementCard } from './components/AuthFactorRequirementCar
|
|
|
39
39
|
export { AccessCodeSingleUseToggle } from './components/AccessCodeSingleUseToggle';
|
|
40
40
|
export { QrSignupManager } from './components/QrSignupManager';
|
|
41
41
|
|
|
42
|
-
// --- 6.
|
|
42
|
+
// --- 6. Charts ---
|
|
43
|
+
export { ChartFrame } from './components/charts/ChartFrame';
|
|
44
|
+
export { BarChart } from './components/charts/BarChart';
|
|
45
|
+
export { LineChart } from './components/charts/LineChart';
|
|
46
|
+
export { getNeutralChartPalette, useNeutralChartPalette } from './components/charts/palette';
|
|
47
|
+
export {
|
|
48
|
+
formatPercentage,
|
|
49
|
+
formatCompact,
|
|
50
|
+
formatRatio,
|
|
51
|
+
createChartFormatters,
|
|
52
|
+
useChartFormatters,
|
|
53
|
+
} from './components/charts/formatters';
|
|
54
|
+
|
|
55
|
+
// --- 7. Translations ---
|
|
43
56
|
export { authTranslations } from './i18n/authTranslations';
|
|
44
57
|
|
|
45
|
-
// ---
|
|
58
|
+
// --- 8. Notifications ---
|
|
46
59
|
export { NotificationSettings } from './notifications/NotificationSettings';
|
|
47
60
|
export * from './notifications/api';
|
|
48
61
|
export { NotificationsProvider, useNotifications } from './notifications/NotificationsProvider';
|
|
@@ -51,7 +64,21 @@ export { NotificationBell } from './notifications/NotificationBell';
|
|
|
51
64
|
export { PopupSurface } from './notifications/PopupSurface';
|
|
52
65
|
export { getNotificationFeed, getUnreadCount, markNotifications } from './notifications/feedApi';
|
|
53
66
|
|
|
54
|
-
// ---
|
|
67
|
+
// --- 9. Messaging ---
|
|
68
|
+
export * from './messaging/api';
|
|
69
|
+
export { MessagingProvider, useMessaging } from './messaging/MessagingProvider';
|
|
70
|
+
export { ConversationList } from './messaging/ConversationList';
|
|
71
|
+
export { ConversationLaunchers } from './messaging/ConversationLaunchers';
|
|
72
|
+
export { Thread } from './messaging/Thread';
|
|
73
|
+
export { MessageBubble } from './messaging/MessageBubble';
|
|
74
|
+
export { ReadTicks } from './messaging/ReadTicks';
|
|
75
|
+
export { Composer } from './messaging/Composer';
|
|
76
|
+
export { AttachmentList } from './messaging/AttachmentList';
|
|
77
|
+
export { ReactionBar } from './messaging/ReactionBar';
|
|
78
|
+
export { PollCard } from './messaging/PollCard';
|
|
79
|
+
export { MessagingScopeConfig } from './messaging/MessagingScopeConfig';
|
|
80
|
+
|
|
81
|
+
// --- 9. Onboarding ---
|
|
55
82
|
export * from './onboarding/api';
|
|
56
83
|
export { selectActiveSteps } from './onboarding/stepSelection';
|
|
57
84
|
export {
|
|
@@ -66,6 +93,8 @@ export { CompleteNameStep } from './onboarding/steps/CompleteNameStep';
|
|
|
66
93
|
export { BrowserPushStep } from './onboarding/steps/BrowserPushStep';
|
|
67
94
|
export { PwaInstallStep } from './onboarding/steps/PwaInstallStep';
|
|
68
95
|
|
|
69
|
-
// ---
|
|
96
|
+
// --- 10. Translations ---
|
|
70
97
|
export { notificationsTranslations } from './i18n/notificationsTranslations';
|
|
71
98
|
export { onboardingTranslations } from './i18n/onboardingTranslations';
|
|
99
|
+
export { chartsTranslations } from './i18n/chartsTranslations';
|
|
100
|
+
export { messagingTranslations } from './i18n/messagingTranslations';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Box, Button, Stack } from '@mui/material';
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
|
|
5
|
+
import { useMessaging } from './MessagingProvider';
|
|
6
|
+
|
|
7
|
+
function isImage(attachment) { return attachment.content_type?.startsWith('image/'); }
|
|
8
|
+
function nameOf(attachment) { return attachment.filename || attachment.name || attachment.id; }
|
|
9
|
+
|
|
10
|
+
/** Download-only attachment renderer; all bytes remain behind the authenticated REST endpoints. */
|
|
11
|
+
export function AttachmentList({ attachments = [] }) {
|
|
12
|
+
const { t } = useTranslation();
|
|
13
|
+
const { getAttachment, getAttachmentThumbnail } = useMessaging();
|
|
14
|
+
const [previews, setPreviews] = useState({});
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
let live = true; const urls = [];
|
|
17
|
+
attachments.filter(isImage).forEach(async (attachment) => {
|
|
18
|
+
try {
|
|
19
|
+
const blob = await getAttachmentThumbnail(attachment.id);
|
|
20
|
+
const url = blob instanceof Blob ? URL.createObjectURL(blob) : blob?.url;
|
|
21
|
+
if (url && live) { urls.push(url); setPreviews((current) => ({ ...current, [attachment.id]: url })); }
|
|
22
|
+
} catch { /* The image remains available through the download action. */ }
|
|
23
|
+
});
|
|
24
|
+
return () => { live = false; urls.forEach((url) => URL.revokeObjectURL(url)); };
|
|
25
|
+
}, [attachments, getAttachmentThumbnail]);
|
|
26
|
+
if (!attachments.length) return null;
|
|
27
|
+
const download = async (attachment) => {
|
|
28
|
+
const blob = await getAttachment(attachment.id);
|
|
29
|
+
if (!(blob instanceof Blob)) return;
|
|
30
|
+
const url = URL.createObjectURL(blob); const link = document.createElement('a');
|
|
31
|
+
link.href = url; link.download = nameOf(attachment); link.click(); URL.revokeObjectURL(url);
|
|
32
|
+
};
|
|
33
|
+
return <Stack direction="row" spacing={1} flexWrap="wrap" aria-label={t('MessagingAttachments.LABEL')}>
|
|
34
|
+
{attachments.map((attachment) => <Button key={attachment.id} type="button" size="small" onClick={() => download(attachment)}>
|
|
35
|
+
{previews[attachment.id] && <Box component="img" src={previews[attachment.id]} alt="" width={36} height={36} sx={{ objectFit: 'cover', mr: 0.75 }} />}{t('MessagingAttachments.DOWNLOAD', { name: nameOf(attachment) })}
|
|
36
|
+
</Button>)}
|
|
37
|
+
</Stack>;
|
|
38
|
+
}
|
|
@@ -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
|
+
}
|