@micha.bigler/ui-core-micha 2.15.0 → 2.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +7 -0
  2. package/WORK_ORDERS.md +6 -1
  3. package/dev/entries.jsx +151 -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 +107 -0
  8. package/dist/index.js +15 -0
  9. package/dist/messaging/AttachmentList.js +43 -0
  10. package/dist/messaging/Composer.js +156 -0
  11. package/dist/messaging/ConversationLaunchers.js +39 -0
  12. package/dist/messaging/ConversationList.js +82 -0
  13. package/dist/messaging/DirectMessageLauncher.js +48 -0
  14. package/dist/messaging/MessageBubble.js +97 -0
  15. package/dist/messaging/MessagingProvider.js +363 -0
  16. package/dist/messaging/MessagingScopeConfig.js +28 -0
  17. package/dist/messaging/PollCard.js +53 -0
  18. package/dist/messaging/ReactionBar.js +44 -0
  19. package/dist/messaging/ReadTicks.js +28 -0
  20. package/dist/messaging/Thread.js +110 -0
  21. package/dist/messaging/api.js +51 -0
  22. package/dist/messaging/composerImageCompression.js +36 -0
  23. package/dist/notifications/NotificationsProvider.js +2 -2
  24. package/dist/notifications/realtime.js +21 -1
  25. package/docs/messaging-deviations.md +101 -0
  26. package/index.html +12 -0
  27. package/package.json +4 -1
  28. package/src/i18n/messagingTranslations.ts +107 -0
  29. package/src/index.js +16 -0
  30. package/src/messaging/AttachmentList.jsx +38 -0
  31. package/src/messaging/Composer.jsx +156 -0
  32. package/src/messaging/ConversationLaunchers.jsx +51 -0
  33. package/src/messaging/ConversationList.jsx +84 -0
  34. package/src/messaging/DirectMessageLauncher.jsx +96 -0
  35. package/src/messaging/MessageBubble.jsx +104 -0
  36. package/src/messaging/MessagingProvider.jsx +395 -0
  37. package/src/messaging/MessagingScopeConfig.jsx +24 -0
  38. package/src/messaging/PollCard.jsx +36 -0
  39. package/src/messaging/ReactionBar.jsx +46 -0
  40. package/src/messaging/ReadTicks.jsx +25 -0
  41. package/src/messaging/Thread.jsx +102 -0
  42. package/src/messaging/api.js +48 -0
  43. package/src/messaging/composerImageCompression.js +35 -0
  44. package/src/notifications/NotificationsProvider.jsx +2 -2
  45. package/src/notifications/realtime.jsx +21 -1
  46. package/tests/ConversationList.test.jsx +66 -0
  47. package/tests/MessagingProvider.test.jsx +50 -0
  48. package/tests/Thread.test.jsx +100 -0
  49. package/tests/messagingApi.test.js +32 -0
  50. package/tests/messagingChunk6.test.jsx +109 -0
  51. package/tests/messagingComposer.test.jsx +111 -0
  52. package/tests/messagingComposerChunk5.test.jsx +110 -0
  53. package/tests/messagingContractConformance.test.js +42 -0
  54. package/tests/messagingDirectMessageLauncher.test.jsx +78 -0
  55. package/tests/messagingExports.test.js +21 -0
  56. package/tests/messagingInteractions.test.jsx +112 -0
  57. package/tests/messagingMessageActions.test.jsx +93 -0
  58. package/tests/messagingThreadingQuoting.test.jsx +86 -0
  59. package/tests/messagingUnreadLifecycle.test.jsx +63 -0
  60. package/tests/realtime.test.jsx +27 -0
  61. package/vite.config.mjs +6 -0
  62. package/work-orders/DX-1.md +166 -0
  63. package/work-orders/MSG-3.md +375 -0
  64. package/work-orders/MSG-3b.md +1002 -0
@@ -0,0 +1,156 @@
1
+ import { Alert, Box, Button, Checkbox, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, IconButton, LinearProgress, Menu, MenuItem, Stack, TextField, Typography } from '@mui/material';
2
+ import AttachFileIcon from '@mui/icons-material/AttachFile';
3
+ import CampaignOutlinedIcon from '@mui/icons-material/CampaignOutlined';
4
+ import CloseIcon from '@mui/icons-material/Close';
5
+ import EmojiEmotionsOutlinedIcon from '@mui/icons-material/EmojiEmotionsOutlined';
6
+ import PollOutlinedIcon from '@mui/icons-material/PollOutlined';
7
+ import { useEffect, useRef, useState } from 'react';
8
+ import { useTranslation } from 'react-i18next';
9
+
10
+ import { compressImageForUpload } from './composerImageCompression';
11
+ import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
12
+ import { QUICK_EMOJIS } from './ReactionBar';
13
+
14
+ function newRequestId() { return globalThis.crypto?.randomUUID?.() || `message-${Date.now()}-${Math.random().toString(36).slice(2)}`; }
15
+ function isImage(file) { return file.type?.startsWith('image/'); }
16
+
17
+ /**
18
+ * A provider-backed, independently mountable REST composer.
19
+ *
20
+ * `allowAnnouncement` and `linkTarget`: whether announcement composing (jg's
21
+ * `AnnouncementDialog` — title + body + a deep-link target) is offered here at
22
+ * all is a host decision (the server enforces who may actually post one; the
23
+ * client only offers the affordance). `linkTarget`'s VALUE is inherently
24
+ * app-specific navigation context (jg pre-fills it from the currently open
25
+ * event-info section) that ucm cannot compute itself, so the host supplies it.
26
+ */
27
+ export function Composer({ conversationId, replyTarget = null, onReplyTargetChange, allowAnnouncement = false, linkTarget }) {
28
+ const { t } = useTranslation();
29
+ const { sendMessage, sendAttachments, createConversationPoll } = useMessaging();
30
+ const [body, setBody] = useState('');
31
+ const [files, setFiles] = useState([]);
32
+ const [error, setError] = useState(null);
33
+ const [failed, setFailed] = useState(null);
34
+ const [sending, setSending] = useState(false);
35
+ const [pollOpen, setPollOpen] = useState(false);
36
+ const [pollQuestion, setPollQuestion] = useState('');
37
+ const [pollOptions, setPollOptions] = useState(['', '']);
38
+ const [allowMultiple, setAllowMultiple] = useState(false);
39
+ const [announcementOpen, setAnnouncementOpen] = useState(false);
40
+ const [announcementTitle, setAnnouncementTitle] = useState('');
41
+ const [announcementBody, setAnnouncementBody] = useState('');
42
+ const [emojiAnchor, setEmojiAnchor] = useState(null);
43
+ const [previews, setPreviews] = useState([]);
44
+ const [uploadProgress, setUploadProgress] = useState(null);
45
+ const inputRef = useRef(null);
46
+ const messageInputRef = useRef(null);
47
+ useEffect(() => {
48
+ if (replyTarget?.deleted_at) onReplyTargetChange?.(null);
49
+ }, [replyTarget, onReplyTargetChange]);
50
+ useEffect(() => { setBody(''); setFiles([]); }, [conversationId]);
51
+ useEffect(() => {
52
+ const urls = files.map((file) => isImage(file) ? URL.createObjectURL(file) : null);
53
+ setPreviews(urls);
54
+ return () => urls.filter(Boolean).forEach((url) => URL.revokeObjectURL(url));
55
+ }, [files]);
56
+ const submit = async (requestId = newRequestId(), retry = false) => {
57
+ if (!body.trim() && !files.length) return;
58
+ setSending(true); setError(null);
59
+ const payload = { kind: 'chat', body: body.trim() || undefined, reply_to: replyTarget?.id || undefined, client_request_id: requestId };
60
+ try {
61
+ if (files.length) {
62
+ const formData = new FormData();
63
+ const uploadFiles = await Promise.all(files.map(compressImageForUpload));
64
+ uploadFiles.forEach((file) => formData.append('files[]', file));
65
+ if (payload.body) formData.append('body', payload.body);
66
+ if (payload.reply_to) formData.append('reply_to', String(payload.reply_to));
67
+ formData.append('client_request_id', requestId);
68
+ setUploadProgress(0);
69
+ const onUploadProgress = (event) => setUploadProgress(event.total ? Math.round((event.loaded / event.total) * 100) : null);
70
+ await sendAttachments(conversationId, formData, { clientRequestId: requestId, retry, onUploadProgress, 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 })) } });
71
+ } else await sendMessage(conversationId, payload, { clientRequestId: requestId, retry });
72
+ setBody(''); setFiles([]); setFailed(null); onReplyTargetChange?.(null);
73
+ } catch (sendError) {
74
+ const message = extractApiErrorMessage(sendError);
75
+ setError(files.length ? t('MessagingComposer.UPLOAD_ERROR', { message }) : t('MessagingComposer.SEND_ERROR', { message }));
76
+ setFailed({ requestId, retry: true });
77
+ } finally { setSending(false); setUploadProgress(null); }
78
+ };
79
+ const removeFile = (index) => setFiles((current) => current.filter((_, fileIndex) => fileIndex !== index));
80
+ const insertEmoji = (emoji) => {
81
+ const input = messageInputRef.current?.querySelector?.('textarea') || messageInputRef.current;
82
+ const start = input?.selectionStart ?? body.length;
83
+ const end = input?.selectionEnd ?? start;
84
+ setBody(`${body.slice(0, start)}${emoji}${body.slice(end)}`); setEmojiAnchor(null);
85
+ requestAnimationFrame(() => { input?.focus(); input?.setSelectionRange(start + emoji.length, start + emoji.length); });
86
+ };
87
+ const submitPoll = async () => {
88
+ const options = pollOptions.map((option) => option.trim()).filter(Boolean);
89
+ if (!pollQuestion.trim() || options.length < 2) { setError(t('MessagingPoll.CREATE_VALIDATION')); return; }
90
+ setSending(true); setError(null);
91
+ try {
92
+ await createConversationPoll(conversationId, { question: pollQuestion.trim(), options, allow_multiple: allowMultiple }, { clientRequestId: newRequestId() });
93
+ setPollQuestion(''); setPollOptions(['', '']); setAllowMultiple(false); setPollOpen(false);
94
+ } catch (pollError) { setError(t('MessagingPoll.CREATE_ERROR', { message: extractApiErrorMessage(pollError) })); } finally { setSending(false); }
95
+ };
96
+ const submitAnnouncement = async () => {
97
+ if (!announcementTitle.trim()) { setError(t('MessagingAnnouncement.VALIDATION')); return; }
98
+ setSending(true); 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(''); setAnnouncementBody(''); setAnnouncementOpen(false);
109
+ } catch (announcementError) { setError(t('MessagingAnnouncement.ERROR', { message: extractApiErrorMessage(announcementError) })); } finally { setSending(false); }
110
+ };
111
+ return <Stack component="form" spacing={1} onSubmit={(event) => { event.preventDefault(); submit(); }} aria-label={t('MessagingComposer.LABEL')}>
112
+ {replyTarget && <Alert severity="info" onClose={() => onReplyTargetChange?.(null)}>{t('MessagingComposer.REPLYING_TO', { sender: replyTarget.sender?.display_name || t('MessagingThread.UNKNOWN_SENDER') })}</Alert>}
113
+ {error && <Alert severity="error" role="alert">{error}</Alert>}
114
+ {files.length > 0 && <Stack direction="row" spacing={0.5} flexWrap="wrap" aria-label={t('MessagingComposer.STAGED_FILES')}>
115
+ {files.map((file, index) => <Stack key={`${file.name}-${index}`} direction="row" alignItems="center" spacing={0.25} sx={{ border: 1, borderColor: 'divider', borderRadius: 1, p: 0.25 }}>
116
+ {previews[index] && <Box component="img" src={previews[index]} alt={file.name} width={36} height={36} sx={{ objectFit: 'cover' }} />}
117
+ <Typography variant="caption">{file.name}</Typography><IconButton type="button" size="small" aria-label={t('MessagingComposer.REMOVE_ATTACHMENT', { name: file.name })} onClick={() => removeFile(index)}><CloseIcon fontSize="inherit" /></IconButton>
118
+ </Stack>)}
119
+ </Stack>}
120
+ {files.length > 0 && <Typography variant="caption">{t('MessagingComposer.FILES_SELECTED', { count: files.length })}</Typography>}
121
+ <Stack direction="row" spacing={1} alignItems="flex-end">
122
+ <input ref={inputRef} hidden type="file" multiple onChange={(event) => setFiles(Array.from(event.target.files || []))} />
123
+ <IconButton type="button" aria-label={t('MessagingComposer.ADD_ATTACHMENT')} onClick={() => inputRef.current?.click()}><AttachFileIcon /></IconButton>
124
+ <IconButton type="button" aria-label={t('MessagingComposer.ADD_EMOJI')} onClick={(event) => setEmojiAnchor(event.currentTarget)}><EmojiEmotionsOutlinedIcon /></IconButton>
125
+ <IconButton type="button" aria-label={t('MessagingPoll.CREATE')} disabled={pollOpen || files.some(isImage)} onClick={() => setPollOpen(true)}><PollOutlinedIcon /></IconButton>
126
+ {allowAnnouncement && <IconButton type="button" aria-label={t('MessagingAnnouncement.CREATE')} onClick={() => setAnnouncementOpen(true)}><CampaignOutlinedIcon /></IconButton>}
127
+ <TextField inputRef={messageInputRef} fullWidth multiline minRows={2} value={body} onChange={(event) => setBody(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); submit(); } }} label={t('MessagingComposer.MESSAGE')} disabled={sending} />
128
+ <Button type="submit" variant="contained" disabled={sending || (!body.trim() && !files.length)}>{t('MessagingComposer.SEND')}</Button>
129
+ </Stack>
130
+ {sending && files.length > 0 && <Stack spacing={0.5} role="status">
131
+ <Stack direction="row" spacing={1} alignItems="center">
132
+ {uploadProgress == null ? <CircularProgress size={16} /> : <CircularProgress size={16} variant="determinate" value={uploadProgress} />}
133
+ <Typography variant="caption">{t('MessagingComposer.UPLOADING')}</Typography>
134
+ </Stack>
135
+ {uploadProgress != null && <LinearProgress variant="determinate" value={uploadProgress} />}
136
+ </Stack>}
137
+ {failed && <Button type="button" onClick={() => submit(failed.requestId, true)} disabled={sending}>{t('MessagingComposer.RETRY')}</Button>}
138
+ <Menu anchorEl={emojiAnchor} open={Boolean(emojiAnchor)} onClose={() => setEmojiAnchor(null)}>
139
+ {QUICK_EMOJIS.map((emoji) => <MenuItem key={emoji} onClick={() => insertEmoji(emoji)}>{emoji}</MenuItem>)}
140
+ </Menu>
141
+ <Dialog open={pollOpen} onClose={() => !sending && setPollOpen(false)} fullWidth maxWidth="sm">
142
+ <DialogTitle>{t('MessagingPoll.CREATE')}</DialogTitle><DialogContent><Stack spacing={1} sx={{ pt: 1 }}>
143
+ <TextField label={t('MessagingPoll.QUESTION')} value={pollQuestion} onChange={(event) => setPollQuestion(event.target.value)} autoFocus />
144
+ {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))} />)}
145
+ <Button type="button" disabled={pollOptions.length >= 10} onClick={() => setPollOptions((current) => [...current, ''])}>{t('MessagingPoll.ADD_OPTION')}</Button>
146
+ <FormControlLabel control={<Checkbox checked={allowMultiple} onChange={(event) => setAllowMultiple(event.target.checked)} />} label={t('MessagingPoll.ALLOW_MULTIPLE')} />
147
+ </Stack></DialogContent><DialogActions><Button onClick={() => setPollOpen(false)}>{t('MessagingPoll.CANCEL')}</Button><Button variant="contained" onClick={submitPoll} disabled={sending}>{t('MessagingPoll.CREATE')}</Button></DialogActions>
148
+ </Dialog>
149
+ {allowAnnouncement && <Dialog open={announcementOpen} onClose={() => !sending && setAnnouncementOpen(false)} fullWidth maxWidth="sm">
150
+ <DialogTitle>{t('MessagingAnnouncement.CREATE')}</DialogTitle><DialogContent><Stack spacing={1} sx={{ pt: 1 }}>
151
+ <TextField label={t('MessagingAnnouncement.TITLE')} value={announcementTitle} onChange={(event) => setAnnouncementTitle(event.target.value)} autoFocus />
152
+ <TextField label={t('MessagingAnnouncement.BODY')} value={announcementBody} onChange={(event) => setAnnouncementBody(event.target.value)} multiline minRows={2} />
153
+ </Stack></DialogContent><DialogActions><Button onClick={() => setAnnouncementOpen(false)}>{t('MessagingAnnouncement.CANCEL')}</Button><Button variant="contained" onClick={submitAnnouncement} disabled={sending}>{t('MessagingAnnouncement.SEND')}</Button></DialogActions>
154
+ </Dialog>}
155
+ </Stack>;
156
+ }
@@ -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,84 @@
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
+ function relativeTime(value, locale) {
14
+ if (!value) return null;
15
+ const seconds = Math.max(0, Math.floor((Date.now() - new Date(value).getTime()) / 1000));
16
+ const [amount, unit] = seconds < 60 ? [0, 'second'] : seconds < 3600 ? [Math.floor(seconds / 60), 'minute'] : seconds < 86400 ? [Math.floor(seconds / 3600), 'hour'] : [Math.floor(seconds / 86400), 'day'];
17
+ return new Intl.RelativeTimeFormat(locale, { numeric: 'auto', style: 'narrow' }).format(-amount, unit);
18
+ }
19
+ // jg's ConversationList renders a distinct icon per conversation kind so a
20
+ // user can identify type/person at a glance (getConversationAvatar); that
21
+ // glanceable identification is a feature, not layout, so it's reproduced
22
+ // here rather than left as a generic archive-status glyph.
23
+ function KindIcon({ conversation }) {
24
+ if (conversation.kind === 'broadcast') return <Campaign fontSize="small" />;
25
+ if (conversation.kind === 'group' || conversation.kind === 'managed' || conversation.kind === 'object_thread') return <Groups fontSize="small" />;
26
+ return <Person fontSize="small" />;
27
+ }
28
+
29
+ /** A standalone, provider-backed list; scope picker metadata is supplied as launcher props. */
30
+ export function ConversationList({ onOpen, groupLaunchers, broadcastLauncher, autoOpenBroadcast = false, includeArchived = false }) {
31
+ const { t, i18n } = useTranslation();
32
+ const { cache, activeConversationId, loadMoreConversations, setConversationArchived, setConversationPreferences, markConversationRead } = useMessaging();
33
+ const [menuConversation, setMenuConversation] = useState(null);
34
+ const [menuAnchor, setMenuAnchor] = useState(null);
35
+ const [loadingMore, setLoadingMore] = useState(false);
36
+ const [error, setError] = useState(null);
37
+ const conversations = useMemo(() => ordered(Object.values(cache.conversations).filter((item) => includeArchived || !item.archived_at && !item.archived)), [cache.conversations, includeArchived]);
38
+ const unreadFor = (conversation) => conversation.unread_count ?? cache.unread.by_conversation?.[conversation.id] ?? 0;
39
+ // Once opened, the broadcast conversation becomes a normal, recency-sorted
40
+ // row with real unread/preview state — hide the static launcher so it isn't
41
+ // shown twice (button + list row) with the button's stale, unread-less copy.
42
+ const broadcastAlreadyOpened = useMemo(() => Object.values(cache.conversations).some((item) => item.kind === 'broadcast'), [cache.conversations]);
43
+
44
+ const loadMore = async () => {
45
+ setLoadingMore(true); setError(null);
46
+ try { await loadMoreConversations(); } catch { setError(t('MessagingList.LOAD_ERROR')); } finally { setLoadingMore(false); }
47
+ };
48
+ const closeMenu = () => { setMenuConversation(null); setMenuAnchor(null); };
49
+ const archive = async () => {
50
+ try { await setConversationArchived(menuConversation.id, true); } catch { setError(t('MessagingList.ARCHIVE_ERROR')); }
51
+ closeMenu();
52
+ };
53
+ const toggleMute = async () => {
54
+ try { await setConversationPreferences(menuConversation.id, { muted: !menuConversation.muted }); } catch { setError(t('MessagingList.PREFERENCES_ERROR')); }
55
+ closeMenu();
56
+ };
57
+
58
+ return (
59
+ <Stack spacing={1}>
60
+ <ConversationLaunchers groupLaunchers={groupLaunchers} broadcastLauncher={broadcastAlreadyOpened ? null : broadcastLauncher} autoOpenBroadcast={autoOpenBroadcast && !broadcastAlreadyOpened} onOpen={onOpen} />
61
+ {error && <Typography color="error" role="alert">{error}</Typography>}
62
+ {!conversations.length ? <Box sx={{ py: 3, textAlign: 'center' }}><Typography color="text.secondary">{t('MessagingList.EMPTY')}</Typography></Box> : (
63
+ <List disablePadding aria-label={t('MessagingList.LABEL')}>
64
+ {conversations.map((conversation) => {
65
+ const unread = unreadFor(conversation);
66
+ const archived = Boolean(conversation.archived || conversation.archived_at);
67
+ return <ListItemButton key={conversation.id} selected={conversation.id === activeConversationId} onClick={() => { markConversationRead(conversation.id).catch(() => {}); onOpen?.(conversation); }} sx={{ minHeight: 64, opacity: archived ? 0.6 : 1 }}>
68
+ <Badge color="error" badgeContent={unread || null} max={99} sx={{ mr: 2 }}>
69
+ {archived ? <Archive fontSize="small" color="disabled" /> : <KindIcon conversation={conversation} />}
70
+ </Badge>
71
+ <ListItemText primary={<Stack direction="row" spacing={1} justifyContent="space-between"><Typography component="span" fontWeight={unread ? 700 : 400} noWrap>{titleOf(conversation, t)}</Typography>{relativeTime(conversation.last_message_at, i18n?.language) && <Typography component="span" variant="caption" color="text.secondary" noWrap>{relativeTime(conversation.last_message_at, i18n?.language)}</Typography>}</Stack>} secondary={conversation.last_message?.body || t('MessagingList.NO_MESSAGES')} secondaryTypographyProps={{ noWrap: true }} />
72
+ <IconButton aria-label={t('MessagingList.ACTIONS')} onClick={(event) => { event.stopPropagation(); setMenuAnchor(event.currentTarget); setMenuConversation(conversation); }}><MoreVert /></IconButton>
73
+ </ListItemButton>;
74
+ })}
75
+ </List>
76
+ )}
77
+ {cache.cursors.conversations && <Button onClick={loadMore} disabled={loadingMore}>{loadingMore ? <CircularProgress size={18} /> : t('MessagingList.LOAD_MORE')}</Button>}
78
+ <Menu anchorEl={menuAnchor} open={Boolean(menuConversation)} onClose={closeMenu}>
79
+ <MenuItem onClick={toggleMute}><NotificationsOff fontSize="small" sx={{ mr: 1 }} />{menuConversation?.muted ? t('MessagingList.UNMUTE') : t('MessagingList.MUTE')}</MenuItem>
80
+ <MenuItem onClick={archive}><Archive fontSize="small" sx={{ mr: 1 }} />{t('MessagingList.ARCHIVE')}</MenuItem>
81
+ </Menu>
82
+ </Stack>
83
+ );
84
+ }
@@ -0,0 +1,96 @@
1
+ import { useId, useState } from 'react';
2
+ import {
3
+ Alert,
4
+ Button,
5
+ CircularProgress,
6
+ Dialog,
7
+ DialogActions,
8
+ DialogContent,
9
+ DialogTitle,
10
+ List,
11
+ ListItemButton,
12
+ ListItemText,
13
+ Typography,
14
+ useMediaQuery,
15
+ } from '@mui/material';
16
+ import { useTheme } from '@mui/material/styles';
17
+ import { useTranslation } from 'react-i18next';
18
+
19
+ import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
20
+
21
+ /**
22
+ * Starts direct conversations from host-provided people. Candidate discovery is
23
+ * intentionally outside messaging because memberships are host-domain data.
24
+ */
25
+ export function DirectMessageLauncher({ candidates = [], scope, onOpen }) {
26
+ const { t } = useTranslation();
27
+ const { openDirectConversation } = useMessaging();
28
+ const theme = useTheme();
29
+ const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
30
+ const [open, setOpen] = useState(false);
31
+ const [selectedId, setSelectedId] = useState(null);
32
+ const [starting, setStarting] = useState(false);
33
+ const [error, setError] = useState(null);
34
+ const titleId = useId();
35
+
36
+ const showPicker = () => {
37
+ setSelectedId(null);
38
+ setError(null);
39
+ setOpen(true);
40
+ };
41
+ const closePicker = () => {
42
+ if (!starting) setOpen(false);
43
+ };
44
+ const start = async () => {
45
+ if (selectedId == null || starting) return;
46
+ setStarting(true);
47
+ setError(null);
48
+ try {
49
+ const conversation = await openDirectConversation({
50
+ target_user_id: selectedId,
51
+ ...(scope == null ? {} : { scope }),
52
+ });
53
+ setOpen(false);
54
+ onOpen?.(conversation);
55
+ } catch (requestError) {
56
+ setError(extractApiErrorMessage(requestError) || t('MessagingDirect.START_ERROR'));
57
+ } finally {
58
+ setStarting(false);
59
+ }
60
+ };
61
+
62
+ return (
63
+ <>
64
+ <Button variant="outlined" onClick={showPicker}>{t('MessagingDirect.LAUNCH')}</Button>
65
+ <Dialog open={open} onClose={closePicker} fullScreen={fullScreen} aria-labelledby={titleId}>
66
+ <DialogTitle id={titleId}>{t('MessagingDirect.TITLE')}</DialogTitle>
67
+ <DialogContent>
68
+ {error && <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert>}
69
+ {starting && <Typography role="status" sx={{ mb: 1 }}><CircularProgress size={16} sx={{ mr: 1 }} />{t('MessagingDirect.STARTING')}</Typography>}
70
+ {candidates.length === 0 ? (
71
+ <Typography>{t('MessagingDirect.EMPTY')}</Typography>
72
+ ) : (
73
+ <List aria-label={t('MessagingDirect.CANDIDATES')}>
74
+ {candidates.map((candidate) => (
75
+ <ListItemButton
76
+ key={candidate.id}
77
+ selected={candidate.id === selectedId}
78
+ disabled={starting}
79
+ onClick={() => setSelectedId(candidate.id)}
80
+ >
81
+ <ListItemText primary={candidate.display_name || candidate.name || candidate.label || candidate.id} />
82
+ </ListItemButton>
83
+ ))}
84
+ </List>
85
+ )}
86
+ </DialogContent>
87
+ <DialogActions>
88
+ <Button onClick={closePicker} disabled={starting}>{t('MessagingDirect.CANCEL')}</Button>
89
+ <Button variant="contained" onClick={start} disabled={selectedId == null || starting}>
90
+ {t('MessagingDirect.START')}
91
+ </Button>
92
+ </DialogActions>
93
+ </Dialog>
94
+ </>
95
+ );
96
+ }
@@ -0,0 +1,104 @@
1
+ import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Menu, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material';
2
+ import MoreVertIcon from '@mui/icons-material/MoreVert';
3
+ import { useEffect, useState } from 'react';
4
+ import { useTranslation } from 'react-i18next';
5
+ import { AttachmentList } from './AttachmentList';
6
+ import { extractApiErrorMessage, useOptionalMessaging } from './MessagingProvider';
7
+ import { ReactionBar } from './ReactionBar';
8
+ import { PollCard } from './PollCard';
9
+
10
+ function senderName(message) {
11
+ return message.sender?.display_name || message.sender?.username || message.sender_name || message.sender?.name;
12
+ }
13
+ function quoteSnippet(message) {
14
+ const content = message.body || message.title;
15
+ if (!content) return null;
16
+ return content.length > 120 ? `${content.slice(0, 117)}...` : content;
17
+ }
18
+ // A quoted source can be textless for two different reasons: it was deleted
19
+ // (server blanks body/title/link_target on soft delete), or it always had no
20
+ // text — an attachment-only chat send is a real, reachable case (Composer
21
+ // only requires body OR files). Collapsing both into "This message was
22
+ // deleted" would mislabel the second case, so they're distinguished here.
23
+ function quoteLabel(message, t) {
24
+ if (message.deleted_at) return t('MessagingThread.DELETED');
25
+ const snippet = quoteSnippet(message);
26
+ if (snippet) return snippet;
27
+ return message.attachments?.length ? t('MessagingThread.QUOTE_ATTACHMENT') : '';
28
+ }
29
+
30
+ /** Provider-backed timeline collaborator, including per-message mutations. */
31
+ export function MessageBubble({ message, replyTo, conversation, onReply, onJumpToMessage, onAnnouncementLink, canModerateMessages = false, children }) {
32
+ const { t } = useTranslation();
33
+ const messaging = useOptionalMessaging();
34
+ const { currentUser, editMessage, removeMessage } = messaging || {};
35
+ const deleted = Boolean(message.deleted_at);
36
+ const attachments = message.attachments || [];
37
+ const canAct = !deleted && message.kind !== 'poll' && ((Boolean(currentUser?.id) && message.sender?.id === currentUser?.id) || canModerateMessages);
38
+ const [menuAnchor, setMenuAnchor] = useState(null);
39
+ const [editing, setEditing] = useState(false);
40
+ const [draft, setDraft] = useState(message.body || '');
41
+ const [confirmingDelete, setConfirmingDelete] = useState(false);
42
+ const [saving, setSaving] = useState(false);
43
+ const [error, setError] = useState(null);
44
+ useEffect(() => {
45
+ if (deleted) { setEditing(false); setConfirmingDelete(false); setMenuAnchor(null); }
46
+ }, [deleted]);
47
+ const closeMenu = () => setMenuAnchor(null);
48
+ const openMenu = (event) => { event.preventDefault(); setMenuAnchor(event.currentTarget); };
49
+ const startEdit = () => { setDraft(message.body || ''); setEditing(true); closeMenu(); };
50
+ const saveEdit = async () => {
51
+ setSaving(true); setError(null);
52
+ try { await editMessage?.(message.id, { body: draft.trim() }); setEditing(false); }
53
+ catch (saveError) { setError(t('MessagingActions.EDIT_ERROR', { message: extractApiErrorMessage(saveError) })); } finally { setSaving(false); }
54
+ };
55
+ const confirmDelete = async () => {
56
+ setSaving(true); setError(null);
57
+ try { await removeMessage?.(message.id); setConfirmingDelete(false); }
58
+ catch (deleteError) { setError(t('MessagingActions.DELETE_ERROR', { message: extractApiErrorMessage(deleteError) })); } finally { setSaving(false); }
59
+ };
60
+ const copyMessage = async () => {
61
+ closeMenu();
62
+ try { await navigator.clipboard.writeText(message.body || ''); }
63
+ catch (copyError) { setError(t('MessagingActions.COPY_ERROR', { message: extractApiErrorMessage(copyError) })); }
64
+ };
65
+ return (
66
+ <Paper component="article" variant="outlined" sx={{ p: 1.25, maxWidth: 'min(100%, 680px)', '& .message-actions': { opacity: 0 }, '&:hover .message-actions': { opacity: 1 } }} aria-label={t('MessagingThread.MESSAGE')} data-message-id={message.id} onContextMenu={canAct ? openMenu : undefined}>
67
+ <Stack spacing={0.75}>
68
+ {replyTo && <Button type="button" variant="text" size="small" onClick={() => onJumpToMessage?.(replyTo.id)} sx={{ justifyContent: 'flex-start', px: 0, textAlign: 'left', textTransform: 'none' }}>
69
+ <Stack spacing={0} alignItems="flex-start">
70
+ <Typography variant="caption" color="text.secondary">{t('MessagingThread.REPLY_TO', { sender: senderName(replyTo) || t('MessagingThread.UNKNOWN_SENDER') })}</Typography>
71
+ <Typography variant="caption" color="text.secondary">{quoteLabel(replyTo, t)}</Typography>
72
+ </Stack>
73
+ </Button>}
74
+ <Stack direction="row" spacing={1} alignItems="baseline">
75
+ {conversation?.kind !== 'direct' && <Typography variant="subtitle2">{senderName(message) || t('MessagingThread.UNKNOWN_SENDER')}</Typography>}
76
+ {message.created_at && <Typography variant="caption" color="text.secondary">{new Date(message.created_at).toLocaleString()}</Typography>}
77
+ {message.edited_at && !deleted && <Typography variant="caption" color="text.secondary">{t('MessagingThread.EDITED')}</Typography>}
78
+ </Stack>
79
+ {!deleted && message.kind === 'announcement' && message.title && <Typography variant="subtitle1" fontWeight={700}>{message.title}</Typography>}
80
+ {!deleted && message.kind === 'announcement' && message.link_target && onAnnouncementLink && <Button type="button" size="small" onClick={() => onAnnouncementLink(message.link_target)}>{t('MessagingAnnouncement.OPEN_LINK')}</Button>}
81
+ {error && <Alert severity="error" role="alert">{error}</Alert>}
82
+ {editing ? <Stack spacing={0.75}><TextField label={t('MessagingActions.EDIT')} value={draft} onChange={(event) => setDraft(event.target.value)} multiline minRows={2} autoFocus disabled={saving} /><Stack direction="row" spacing={1}><Button type="button" onClick={saveEdit} disabled={saving}>{t('MessagingActions.SAVE')}</Button><Button type="button" onClick={() => setEditing(false)} disabled={saving}>{t('MessagingActions.CANCEL')}</Button></Stack></Stack> : <Typography sx={{ whiteSpace: 'pre-wrap' }}>{deleted ? t('MessagingThread.DELETED') : (message.kind === 'announcement' ? message.body : (message.body || message.title)) || ''}</Typography>}
83
+ {!deleted && <Stack spacing={0.75}>
84
+ {attachments.length > 0 && <AttachmentList attachments={attachments} />}
85
+ <ReactionBar message={message} />
86
+ {message.poll && <PollCard message={message} />}
87
+ </Stack>}
88
+ {!deleted && onReply && <Button type="button" size="small" onClick={() => onReply(message)}>{t('MessagingThread.REPLY')}</Button>}
89
+ {canAct && <IconButton className="message-actions" size="small" aria-label={t('MessagingActions.MENU')} onClick={openMenu}><MoreVertIcon /></IconButton>}
90
+ {children}
91
+ </Stack>
92
+ <Menu anchorEl={menuAnchor} open={Boolean(menuAnchor)} onClose={closeMenu}>
93
+ <MenuItem onClick={startEdit}>{t('MessagingActions.EDIT')}</MenuItem>
94
+ <MenuItem onClick={() => { setConfirmingDelete(true); closeMenu(); }}>{t('MessagingActions.DELETE')}</MenuItem>
95
+ <MenuItem onClick={copyMessage}>{t('MessagingActions.COPY')}</MenuItem>
96
+ </Menu>
97
+ <Dialog open={confirmingDelete} onClose={() => !saving && setConfirmingDelete(false)}>
98
+ <DialogTitle>{t('MessagingActions.DELETE_CONFIRM_TITLE')}</DialogTitle>
99
+ <DialogContent>{t('MessagingActions.DELETE_CONFIRM_BODY')}</DialogContent>
100
+ <DialogActions><Button onClick={() => setConfirmingDelete(false)} disabled={saving}>{t('MessagingActions.CANCEL')}</Button><Button onClick={confirmDelete} disabled={saving} color="error">{t('MessagingActions.DELETE')}</Button></DialogActions>
101
+ </Dialog>
102
+ </Paper>
103
+ );
104
+ }