@churchapps/apphelper 0.4.50 → 0.5.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/dist/components/FormCardPayment.d.ts +1 -1
- package/dist/components/FormCardPayment.d.ts.map +1 -1
- package/dist/components/FormCardPayment.js +27 -10
- package/dist/components/FormCardPayment.js.map +1 -1
- package/dist/components/Loading.js +36 -36
- package/dist/components/notes/Notes.js +27 -27
- package/dist/helpers/index.d.ts +1 -1
- package/dist/helpers/index.d.ts.map +1 -1
- package/dist/helpers/index.js +1 -1
- package/dist/helpers/index.js.map +1 -1
- package/dist/public/css/cropper.css +309 -309
- package/dist/public/css/styles.css +111 -111
- package/package.json +72 -73
- package/public/css/cropper.css +309 -309
- package/public/css/styles.css +111 -111
- package/public/locales/de.json +269 -269
- package/public/locales/en.json +276 -276
- package/public/locales/es.json +272 -272
- package/public/locales/fr.json +269 -269
- package/public/locales/hi.json +269 -269
- package/public/locales/it.json +269 -269
- package/public/locales/ko.json +269 -269
- package/public/locales/no.json +269 -269
- package/public/locales/pt.json +269 -269
- package/public/locales/ru.json +269 -269
- package/public/locales/tl.json +269 -269
- package/public/locales/zh.json +269 -269
- package/src/components/DisplayBox.tsx +83 -83
- package/src/components/ErrorMessages.tsx +28 -28
- package/src/components/ExportLink.tsx +81 -81
- package/src/components/FloatingSupport.tsx +18 -18
- package/src/components/FormCardPayment.tsx +184 -169
- package/src/components/FormSubmissionEdit.tsx +168 -168
- package/src/components/HelpIcon.tsx +12 -12
- package/src/components/ImageEditor.tsx +161 -161
- package/src/components/InputBox.tsx +96 -96
- package/src/components/Loading.tsx +77 -77
- package/src/components/PageHeader.tsx +110 -110
- package/src/components/PersonAvatar.tsx +77 -77
- package/src/components/QuestionEdit.tsx +99 -99
- package/src/components/SmallButton.tsx +42 -42
- package/src/components/SupportModal.tsx +32 -32
- package/src/components/TabPanel.tsx +28 -28
- package/src/components/gallery/GalleryModal.tsx +173 -173
- package/src/components/gallery/StockPhotos.tsx +95 -95
- package/src/components/gallery/index.ts +1 -1
- package/src/components/header/Banner.tsx +11 -11
- package/src/components/header/PrimaryMenu.tsx +100 -100
- package/src/components/header/SecondaryMenu.tsx +23 -23
- package/src/components/header/SecondaryMenuAlt.tsx +40 -40
- package/src/components/header/SiteHeader.tsx +207 -207
- package/src/components/header/SupportDrawer.tsx +111 -111
- package/src/components/header/index.tsx +2 -2
- package/src/components/index.tsx +20 -20
- package/src/components/notes/AddNote.tsx +180 -180
- package/src/components/notes/Note.tsx +68 -68
- package/src/components/notes/Notes.tsx +208 -208
- package/src/components/notes/index.ts +3 -3
- package/src/components/wrapper/AppList.tsx +19 -19
- package/src/components/wrapper/ChurchList.tsx +154 -154
- package/src/components/wrapper/NavItem.tsx +47 -47
- package/src/components/wrapper/NewPrivateMessage.tsx +253 -253
- package/src/components/wrapper/Notifications.tsx +223 -223
- package/src/components/wrapper/PrivateMessageDetails.tsx +112 -112
- package/src/components/wrapper/PrivateMessages.tsx +576 -576
- package/src/components/wrapper/UserMenu.tsx +383 -383
- package/src/components/wrapper/index.tsx +8 -8
- package/src/helpers/AnalyticsHelper.ts +32 -32
- package/src/helpers/AppearanceHelper.ts +73 -73
- package/src/helpers/ArrayHelper.ts +87 -87
- package/src/helpers/CurrencyHelper.ts +10 -10
- package/src/helpers/DateHelper.ts +104 -104
- package/src/helpers/ErrorHelper.ts +43 -43
- package/src/helpers/EventHelper.ts +49 -49
- package/src/helpers/FileHelper.ts +31 -31
- package/src/helpers/Locale.ts +457 -457
- package/src/helpers/NotificationService.ts +296 -296
- package/src/helpers/PersonHelper.ts +62 -62
- package/src/helpers/SlugHelper.ts +37 -37
- package/src/helpers/SocketHelper.ts +296 -296
- package/src/helpers/UniqueIdHelper.ts +36 -36
- package/src/helpers/UserHelper.ts +104 -104
- package/src/helpers/createEmotionCache.ts +17 -17
- package/src/helpers/index.ts +58 -50
- package/src/hooks/index.ts +3 -3
- package/src/hooks/useMountedState.ts +16 -16
- package/src/hooks/useNotifications.ts +93 -93
- package/src/index.ts +2 -2
- package/src/types/interface-extensions.d.ts +12 -0
- package/tsconfig.json +31 -31
- package/dist/public/locales/de.json +0 -270
- package/dist/public/locales/en.json +0 -277
- package/dist/public/locales/es.json +0 -272
- package/dist/public/locales/fr.json +0 -270
- package/dist/public/locales/hi.json +0 -270
- package/dist/public/locales/it.json +0 -270
- package/dist/public/locales/ko.json +0 -270
- package/dist/public/locales/no.json +0 -270
- package/dist/public/locales/pt.json +0 -270
- package/dist/public/locales/ru.json +0 -270
- package/dist/public/locales/tl.json +0 -270
- package/dist/public/locales/zh.json +0 -270
|
@@ -1,576 +1,576 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import React, { useState, useEffect } from "react";
|
|
4
|
-
import { ApiHelper } from "@churchapps/helpers";
|
|
5
|
-
import {
|
|
6
|
-
Box,
|
|
7
|
-
Stack,
|
|
8
|
-
List,
|
|
9
|
-
ListItem,
|
|
10
|
-
ListItemAvatar,
|
|
11
|
-
ListItemText,
|
|
12
|
-
Typography,
|
|
13
|
-
IconButton,
|
|
14
|
-
Chip,
|
|
15
|
-
Divider,
|
|
16
|
-
Paper,
|
|
17
|
-
Skeleton,
|
|
18
|
-
useTheme
|
|
19
|
-
} from "@mui/material";
|
|
20
|
-
import { Add as AddIcon, ChatBubbleOutline as ChatIcon } from "@mui/icons-material";
|
|
21
|
-
import { PersonAvatar } from "../PersonAvatar";
|
|
22
|
-
import { PrivateMessageInterface, UserContextInterface } from "@churchapps/helpers";
|
|
23
|
-
import { ArrayHelper, DateHelper } from "../../helpers";
|
|
24
|
-
import { PrivateMessageDetails } from "./PrivateMessageDetails";
|
|
25
|
-
import { NewPrivateMessage } from "./NewPrivateMessage";
|
|
26
|
-
|
|
27
|
-
interface Props {
|
|
28
|
-
context: UserContextInterface;
|
|
29
|
-
refreshKey: number;
|
|
30
|
-
onUpdate: () => void;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// Create a persistent store for PrivateMessages state that survives component re-renders
|
|
34
|
-
const privateMessagesStateStore = {
|
|
35
|
-
selectedMessage: null as PrivateMessageInterface | null,
|
|
36
|
-
inAddMode: false,
|
|
37
|
-
listeners: new Set<() => void>(),
|
|
38
|
-
|
|
39
|
-
setSelectedMessage(value: PrivateMessageInterface | null) {
|
|
40
|
-
this.selectedMessage = value;
|
|
41
|
-
this.listeners.forEach((listener: () => void) => listener());
|
|
42
|
-
},
|
|
43
|
-
|
|
44
|
-
setInAddMode(value: boolean) {
|
|
45
|
-
this.inAddMode = value;
|
|
46
|
-
this.listeners.forEach((listener: () => void) => listener());
|
|
47
|
-
},
|
|
48
|
-
|
|
49
|
-
subscribe(listener: () => void) {
|
|
50
|
-
this.listeners.add(listener);
|
|
51
|
-
return () => this.listeners.delete(listener);
|
|
52
|
-
}
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
export const PrivateMessages: React.FC<Props> = React.memo((props) => {
|
|
56
|
-
|
|
57
|
-
const [privateMessages, setPrivateMessages] = useState<PrivateMessageInterface[]>([]);
|
|
58
|
-
const [, forceUpdate] = React.useReducer(x => x + 1, 0);
|
|
59
|
-
const [isLoading, setIsLoading] = useState(true);
|
|
60
|
-
|
|
61
|
-
// Subscribe to state changes
|
|
62
|
-
React.useEffect(() => {
|
|
63
|
-
return privateMessagesStateStore.subscribe(forceUpdate);
|
|
64
|
-
}, [forceUpdate]);
|
|
65
|
-
|
|
66
|
-
const selectedMessage = privateMessagesStateStore.selectedMessage;
|
|
67
|
-
const inAddMode = privateMessagesStateStore.inAddMode;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const loadData = async () => {
|
|
71
|
-
setIsLoading(true);
|
|
72
|
-
const pms: PrivateMessageInterface[] = await ApiHelper.get("/privateMessages", "MessagingApi");
|
|
73
|
-
|
|
74
|
-
// Store the current selected conversation ID if dialog is open
|
|
75
|
-
const currentSelectedPersonId = selectedMessage ?
|
|
76
|
-
(selectedMessage.fromPersonId === props.context.person.id) ?
|
|
77
|
-
selectedMessage.toPersonId : selectedMessage.fromPersonId
|
|
78
|
-
: null;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
// Group messages by person (conversation)
|
|
82
|
-
const conversationMap = new Map<string, PrivateMessageInterface>();
|
|
83
|
-
const peopleIds: string[] = [];
|
|
84
|
-
|
|
85
|
-
pms.forEach((pm) => {
|
|
86
|
-
const personId = (pm.fromPersonId === props.context.person.id) ? pm.toPersonId : pm.fromPersonId;
|
|
87
|
-
if (peopleIds.indexOf(personId) === -1) peopleIds.push(personId);
|
|
88
|
-
|
|
89
|
-
// Keep only the most recent message per conversation
|
|
90
|
-
const currentMessage = pm.conversation?.messages?.[0];
|
|
91
|
-
const existingPm = conversationMap.get(personId);
|
|
92
|
-
const existingMessage = existingPm?.conversation?.messages?.[0];
|
|
93
|
-
|
|
94
|
-
if (!conversationMap.has(personId)) {
|
|
95
|
-
// First message for this person
|
|
96
|
-
conversationMap.set(personId, pm);
|
|
97
|
-
} else if (currentMessage && existingMessage) {
|
|
98
|
-
// Compare timestamps to keep the most recent
|
|
99
|
-
const currentTime = new Date(currentMessage.timeUpdated || currentMessage.timeSent);
|
|
100
|
-
const existingTime = new Date(existingMessage.timeUpdated || existingMessage.timeSent);
|
|
101
|
-
if (currentTime > existingTime) {
|
|
102
|
-
conversationMap.set(personId, pm);
|
|
103
|
-
}
|
|
104
|
-
} else if (currentMessage && !existingMessage) {
|
|
105
|
-
// Current has message but existing doesn't, use current
|
|
106
|
-
conversationMap.set(personId, pm);
|
|
107
|
-
}
|
|
108
|
-
// If !currentMessage but existingMessage, keep existing (do nothing)
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
// Convert map back to array (one message per conversation)
|
|
112
|
-
let conversations = Array.from(conversationMap.values());
|
|
113
|
-
|
|
114
|
-
// Filter out conversations without messages first
|
|
115
|
-
conversations = conversations.filter(pm => pm.conversation?.messages?.[0]);
|
|
116
|
-
|
|
117
|
-
// Get the filtered people IDs (only for conversations with messages)
|
|
118
|
-
const filteredPeopleIds = conversations.map(pm =>
|
|
119
|
-
(pm.fromPersonId === props.context.person.id) ? pm.toPersonId : pm.fromPersonId
|
|
120
|
-
).filter((id, index, arr) => arr.indexOf(id) === index); // Remove duplicates
|
|
121
|
-
|
|
122
|
-
if (filteredPeopleIds.length > 0) {
|
|
123
|
-
try {
|
|
124
|
-
const people = await ApiHelper.get("/people/basic?ids=" + filteredPeopleIds.join(","), "MembershipApi");
|
|
125
|
-
|
|
126
|
-
conversations.forEach(pm => {
|
|
127
|
-
const personId = (pm.fromPersonId === props.context.person.id) ? pm.toPersonId : pm.fromPersonId;
|
|
128
|
-
pm.person = ArrayHelper.getOne(people, "id", personId);
|
|
129
|
-
});
|
|
130
|
-
} catch (error) {
|
|
131
|
-
console.error("❌ Failed to load people data:", error);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// Sort by most recent message (same logic as displayed in UI)
|
|
136
|
-
conversations.sort((a, b) => {
|
|
137
|
-
const aMessage = a.conversation?.messages?.[0];
|
|
138
|
-
const bMessage = b.conversation?.messages?.[0];
|
|
139
|
-
|
|
140
|
-
if (!aMessage && !bMessage) return 0;
|
|
141
|
-
if (!aMessage) return 1; // b comes first
|
|
142
|
-
if (!bMessage) return -1; // a comes first
|
|
143
|
-
|
|
144
|
-
const aTime = new Date(aMessage.timeUpdated || aMessage.timeSent).getTime();
|
|
145
|
-
const bTime = new Date(bMessage.timeUpdated || bMessage.timeSent).getTime();
|
|
146
|
-
|
|
147
|
-
// Most recent first (descending order)
|
|
148
|
-
return bTime - aTime;
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
setPrivateMessages(conversations);
|
|
152
|
-
|
|
153
|
-
// If a conversation is currently selected, update the selectedMessage to the new data
|
|
154
|
-
// This prevents the dialog from closing when new messages arrive
|
|
155
|
-
if (currentSelectedPersonId) {
|
|
156
|
-
const updatedSelectedMessage = conversations.find(pm => {
|
|
157
|
-
const personId = (pm.fromPersonId === props.context.person.id) ? pm.toPersonId : pm.fromPersonId;
|
|
158
|
-
return personId === currentSelectedPersonId;
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
if (updatedSelectedMessage) {
|
|
162
|
-
privateMessagesStateStore.setSelectedMessage(updatedSelectedMessage);
|
|
163
|
-
} else {
|
|
164
|
-
privateMessagesStateStore.setSelectedMessage(null);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
setIsLoading(false);
|
|
169
|
-
props.onUpdate();
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// Initialize data on mount
|
|
173
|
-
useEffect(() => {
|
|
174
|
-
loadData();
|
|
175
|
-
}, []); //eslint-disable-line
|
|
176
|
-
|
|
177
|
-
// Reload data when refreshKey changes
|
|
178
|
-
useEffect(() => {
|
|
179
|
-
loadData();
|
|
180
|
-
}, [props.refreshKey]); //eslint-disable-line
|
|
181
|
-
|
|
182
|
-
const getMessageList = () => {
|
|
183
|
-
if (privateMessages.length === 0) {
|
|
184
|
-
return (
|
|
185
|
-
<Box sx={{
|
|
186
|
-
textAlign: 'center',
|
|
187
|
-
py: 8,
|
|
188
|
-
px: 4,
|
|
189
|
-
height: '100%',
|
|
190
|
-
display: 'flex',
|
|
191
|
-
flexDirection: 'column',
|
|
192
|
-
justifyContent: 'center',
|
|
193
|
-
alignItems: 'center'
|
|
194
|
-
}}>
|
|
195
|
-
<Box sx={{
|
|
196
|
-
width: 120,
|
|
197
|
-
height: 120,
|
|
198
|
-
borderRadius: '50%',
|
|
199
|
-
bgcolor: 'rgba(25, 118, 210, 0.08)',
|
|
200
|
-
display: 'flex',
|
|
201
|
-
alignItems: 'center',
|
|
202
|
-
justifyContent: 'center',
|
|
203
|
-
mb: 3,
|
|
204
|
-
position: 'relative',
|
|
205
|
-
'&::before': {
|
|
206
|
-
content: '""',
|
|
207
|
-
position: 'absolute',
|
|
208
|
-
width: '100%',
|
|
209
|
-
height: '100%',
|
|
210
|
-
borderRadius: '50%',
|
|
211
|
-
border: '1px solid rgba(25, 118, 210, 0.12)',
|
|
212
|
-
animation: 'pulse 2s infinite'
|
|
213
|
-
},
|
|
214
|
-
'@keyframes pulse': {
|
|
215
|
-
'0%': {
|
|
216
|
-
transform: 'scale(1)',
|
|
217
|
-
opacity: 1
|
|
218
|
-
},
|
|
219
|
-
'50%': {
|
|
220
|
-
transform: 'scale(1.05)',
|
|
221
|
-
opacity: 0.7
|
|
222
|
-
},
|
|
223
|
-
'100%': {
|
|
224
|
-
transform: 'scale(1)',
|
|
225
|
-
opacity: 1
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
}}>
|
|
229
|
-
<ChatIcon sx={{
|
|
230
|
-
fontSize: 56,
|
|
231
|
-
color: 'primary.main',
|
|
232
|
-
opacity: 0.8
|
|
233
|
-
}} />
|
|
234
|
-
</Box>
|
|
235
|
-
<Typography variant="h5" sx={{
|
|
236
|
-
color: 'text.primary',
|
|
237
|
-
fontWeight: 600,
|
|
238
|
-
mb: 1.5,
|
|
239
|
-
letterSpacing: '-0.02em'
|
|
240
|
-
}}>
|
|
241
|
-
No conversations yet
|
|
242
|
-
</Typography>
|
|
243
|
-
<Typography variant="body1" sx={{
|
|
244
|
-
color: 'text.secondary',
|
|
245
|
-
maxWidth: 280,
|
|
246
|
-
lineHeight: 1.6,
|
|
247
|
-
mb: 3
|
|
248
|
-
}}>
|
|
249
|
-
Start meaningful conversations with your community members. Your messages will appear here.
|
|
250
|
-
</Typography>
|
|
251
|
-
<Box sx={{
|
|
252
|
-
p: 2,
|
|
253
|
-
borderRadius: 2,
|
|
254
|
-
bgcolor: 'rgba(25, 118, 210, 0.04)',
|
|
255
|
-
border: '1px solid rgba(25, 118, 210, 0.12)',
|
|
256
|
-
display: 'flex',
|
|
257
|
-
alignItems: 'center',
|
|
258
|
-
gap: 1.5
|
|
259
|
-
}}>
|
|
260
|
-
<AddIcon sx={{ color: 'primary.main', fontSize: 20 }} />
|
|
261
|
-
<Typography variant="body2" sx={{
|
|
262
|
-
color: 'primary.main',
|
|
263
|
-
fontWeight: 500
|
|
264
|
-
}}>
|
|
265
|
-
Click the + button above to start your first conversation
|
|
266
|
-
</Typography>
|
|
267
|
-
</Box>
|
|
268
|
-
</Box>
|
|
269
|
-
);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
return (
|
|
273
|
-
<List sx={{
|
|
274
|
-
width: '100%',
|
|
275
|
-
p: 0,
|
|
276
|
-
'& .MuiListItem-root': {
|
|
277
|
-
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)'
|
|
278
|
-
}
|
|
279
|
-
}}>
|
|
280
|
-
{privateMessages.map((pm, index) => {
|
|
281
|
-
const person = pm.person;
|
|
282
|
-
const message = pm.conversation?.messages?.[0];
|
|
283
|
-
|
|
284
|
-
// Only filter out if there's no message - show conversations even without person data
|
|
285
|
-
if (!message) {
|
|
286
|
-
return null;
|
|
287
|
-
}
|
|
288
|
-
const contents = message.content?.split("\n")[0];
|
|
289
|
-
let datePosted = new Date(message.timeUpdated || message.timeSent);
|
|
290
|
-
const displayDuration = DateHelper.getDisplayDuration(datePosted);
|
|
291
|
-
// Check if this conversation has unread messages
|
|
292
|
-
const isUnread = pm.notifyPersonId === props.context.person.id;
|
|
293
|
-
|
|
294
|
-
// Determine who sent the last message for better context
|
|
295
|
-
const isLastMessageFromMe = message.personId === props.context.person.id;
|
|
296
|
-
const isFromOtherPerson = pm.fromPersonId !== props.context.person.id;
|
|
297
|
-
|
|
298
|
-
return (
|
|
299
|
-
<Box key={pm.id} sx={{ px: 2, py: 0.5 }}>
|
|
300
|
-
<ListItem
|
|
301
|
-
component="button"
|
|
302
|
-
onClick={() => privateMessagesStateStore.setSelectedMessage(pm)}
|
|
303
|
-
sx={{
|
|
304
|
-
alignItems: 'flex-start',
|
|
305
|
-
p: 3,
|
|
306
|
-
cursor: 'pointer',
|
|
307
|
-
bgcolor: isUnread
|
|
308
|
-
? 'rgba(25, 118, 210, 0.04)'
|
|
309
|
-
: 'background.paper',
|
|
310
|
-
border: isUnread
|
|
311
|
-
? '1px solid rgba(25, 118, 210, 0.12)'
|
|
312
|
-
: '1px solid transparent',
|
|
313
|
-
borderRadius: 3,
|
|
314
|
-
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.02)',
|
|
315
|
-
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
316
|
-
transform: 'translateY(0)',
|
|
317
|
-
'&:hover': {
|
|
318
|
-
bgcolor: isUnread
|
|
319
|
-
? 'rgba(25, 118, 210, 0.06)'
|
|
320
|
-
: 'rgba(0, 0, 0, 0.02)',
|
|
321
|
-
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
|
|
322
|
-
transform: 'translateY(-1px)',
|
|
323
|
-
borderColor: 'rgba(0, 0, 0, 0.06)'
|
|
324
|
-
},
|
|
325
|
-
'&:active': {
|
|
326
|
-
transform: 'translateY(0)',
|
|
327
|
-
boxShadow: '0 2px 6px rgba(0, 0, 0, 0.06)'
|
|
328
|
-
},
|
|
329
|
-
position: 'relative',
|
|
330
|
-
overflow: 'hidden',
|
|
331
|
-
'&::before': isUnread ? {
|
|
332
|
-
content: '""',
|
|
333
|
-
position: 'absolute',
|
|
334
|
-
left: 0,
|
|
335
|
-
top: 0,
|
|
336
|
-
bottom: 0,
|
|
337
|
-
width: 4,
|
|
338
|
-
bgcolor: 'primary.main',
|
|
339
|
-
borderRadius: '0 2px 2px 0'
|
|
340
|
-
} : {}
|
|
341
|
-
}}
|
|
342
|
-
>
|
|
343
|
-
<ListItemAvatar sx={{ mr: 2 }}>
|
|
344
|
-
<Box sx={{ position: 'relative' }}>
|
|
345
|
-
<PersonAvatar person={person || { name: { display: "Unknown User" } } as any} size="medium" />
|
|
346
|
-
{isUnread && (
|
|
347
|
-
<Box
|
|
348
|
-
sx={{
|
|
349
|
-
position: 'absolute',
|
|
350
|
-
top: -2,
|
|
351
|
-
right: -2,
|
|
352
|
-
width: 14,
|
|
353
|
-
height: 14,
|
|
354
|
-
bgcolor: 'primary.main',
|
|
355
|
-
borderRadius: '50%',
|
|
356
|
-
border: '2px solid white',
|
|
357
|
-
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.2)'
|
|
358
|
-
}}
|
|
359
|
-
/>
|
|
360
|
-
)}
|
|
361
|
-
</Box>
|
|
362
|
-
</ListItemAvatar>
|
|
363
|
-
|
|
364
|
-
<ListItemText
|
|
365
|
-
sx={{ m: 0, flex: 1 }}
|
|
366
|
-
primary={
|
|
367
|
-
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" sx={{ mb: 0.5 }}>
|
|
368
|
-
<Typography
|
|
369
|
-
variant="h6"
|
|
370
|
-
sx={{
|
|
371
|
-
fontWeight: isUnread ? 600 : 500,
|
|
372
|
-
fontSize: '1rem',
|
|
373
|
-
color: 'text.primary',
|
|
374
|
-
lineHeight: 1.3
|
|
375
|
-
}}
|
|
376
|
-
>
|
|
377
|
-
{person?.name?.display || "Unknown User"}
|
|
378
|
-
</Typography>
|
|
379
|
-
<Typography
|
|
380
|
-
variant="caption"
|
|
381
|
-
sx={{
|
|
382
|
-
color: 'text.secondary',
|
|
383
|
-
fontSize: '0.75rem',
|
|
384
|
-
fontWeight: 500,
|
|
385
|
-
letterSpacing: '0.02em',
|
|
386
|
-
ml: 2,
|
|
387
|
-
flexShrink: 0
|
|
388
|
-
}}
|
|
389
|
-
>
|
|
390
|
-
{displayDuration}
|
|
391
|
-
</Typography>
|
|
392
|
-
</Stack>
|
|
393
|
-
}
|
|
394
|
-
secondary={
|
|
395
|
-
<Typography
|
|
396
|
-
variant="body2"
|
|
397
|
-
sx={{
|
|
398
|
-
color: 'text.secondary',
|
|
399
|
-
fontSize: '0.875rem',
|
|
400
|
-
fontWeight: isUnread ? 500 : 400,
|
|
401
|
-
lineHeight: 1.4,
|
|
402
|
-
overflow: 'hidden',
|
|
403
|
-
textOverflow: 'ellipsis',
|
|
404
|
-
display: '-webkit-box',
|
|
405
|
-
WebkitLineClamp: 2,
|
|
406
|
-
WebkitBoxOrient: 'vertical',
|
|
407
|
-
opacity: contents ? 1 : 0.6,
|
|
408
|
-
fontStyle: contents ? 'normal' : 'italic'
|
|
409
|
-
}}
|
|
410
|
-
>
|
|
411
|
-
{isLastMessageFromMe && contents && (
|
|
412
|
-
<Typography
|
|
413
|
-
component="span"
|
|
414
|
-
sx={{
|
|
415
|
-
fontWeight: 600,
|
|
416
|
-
color: 'text.primary',
|
|
417
|
-
opacity: 0.8
|
|
418
|
-
}}
|
|
419
|
-
>
|
|
420
|
-
You:{" "}
|
|
421
|
-
</Typography>
|
|
422
|
-
)}
|
|
423
|
-
{contents || 'No message preview available'}
|
|
424
|
-
</Typography>
|
|
425
|
-
}
|
|
426
|
-
/>
|
|
427
|
-
|
|
428
|
-
{isUnread && (
|
|
429
|
-
<Box sx={{ ml: 1, display: 'flex', alignItems: 'flex-start', pt: 0.5 }}>
|
|
430
|
-
<Chip
|
|
431
|
-
size="small"
|
|
432
|
-
label="New"
|
|
433
|
-
sx={{
|
|
434
|
-
height: 22,
|
|
435
|
-
fontSize: '0.7rem',
|
|
436
|
-
fontWeight: 600,
|
|
437
|
-
bgcolor: 'primary.main',
|
|
438
|
-
color: 'white',
|
|
439
|
-
'& .MuiChip-label': {
|
|
440
|
-
px: 1
|
|
441
|
-
}
|
|
442
|
-
}}
|
|
443
|
-
/>
|
|
444
|
-
</Box>
|
|
445
|
-
)}
|
|
446
|
-
</ListItem>
|
|
447
|
-
</Box>
|
|
448
|
-
);
|
|
449
|
-
})}
|
|
450
|
-
</List>
|
|
451
|
-
);
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
const handleBack = () => {
|
|
455
|
-
privateMessagesStateStore.setInAddMode(false);
|
|
456
|
-
privateMessagesStateStore.setSelectedMessage(null);
|
|
457
|
-
loadData();
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
const theme = useTheme();
|
|
461
|
-
|
|
462
|
-
if (inAddMode) return <NewPrivateMessage context={props.context} onSelectMessage={(pm: PrivateMessageInterface) => { privateMessagesStateStore.setSelectedMessage(pm); privateMessagesStateStore.setInAddMode(false); }} onBack={handleBack} />
|
|
463
|
-
if (selectedMessage) return <PrivateMessageDetails privateMessage={selectedMessage} context={props.context} onBack={handleBack} refreshKey={props.refreshKey} onMessageRead={loadData} />
|
|
464
|
-
|
|
465
|
-
return (
|
|
466
|
-
<Paper elevation={0} sx={{
|
|
467
|
-
height: '100%',
|
|
468
|
-
display: 'flex',
|
|
469
|
-
flexDirection: 'column',
|
|
470
|
-
bgcolor: 'background.default',
|
|
471
|
-
borderRadius: 0
|
|
472
|
-
}}>
|
|
473
|
-
<Box sx={{
|
|
474
|
-
p: 3,
|
|
475
|
-
borderBottom: '1px solid',
|
|
476
|
-
borderColor: 'divider',
|
|
477
|
-
bgcolor: 'background.paper',
|
|
478
|
-
backdropFilter: 'blur(10px)'
|
|
479
|
-
}}>
|
|
480
|
-
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
|
481
|
-
<Box>
|
|
482
|
-
<Typography variant="h5" component="h1" sx={{
|
|
483
|
-
fontWeight: 700,
|
|
484
|
-
color: 'text.primary',
|
|
485
|
-
letterSpacing: '-0.02em',
|
|
486
|
-
mb: 0.5
|
|
487
|
-
}}>
|
|
488
|
-
Messages
|
|
489
|
-
</Typography>
|
|
490
|
-
<Typography variant="body2" sx={{
|
|
491
|
-
color: 'text.secondary',
|
|
492
|
-
fontWeight: 500
|
|
493
|
-
}}>
|
|
494
|
-
{privateMessages.length === 0
|
|
495
|
-
? 'No conversations'
|
|
496
|
-
: `${privateMessages.length} conversation${privateMessages.length === 1 ? '' : 's'}`
|
|
497
|
-
}
|
|
498
|
-
</Typography>
|
|
499
|
-
</Box>
|
|
500
|
-
<IconButton
|
|
501
|
-
onClick={() => privateMessagesStateStore.setInAddMode(true)}
|
|
502
|
-
sx={{
|
|
503
|
-
bgcolor: 'primary.main',
|
|
504
|
-
color: 'white',
|
|
505
|
-
width: 48,
|
|
506
|
-
height: 48,
|
|
507
|
-
boxShadow: '0 4px 12px rgba(25, 118, 210, 0.3)',
|
|
508
|
-
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
509
|
-
'&:hover': {
|
|
510
|
-
bgcolor: 'primary.dark',
|
|
511
|
-
boxShadow: '0 6px 16px rgba(25, 118, 210, 0.4)',
|
|
512
|
-
transform: 'translateY(-1px)'
|
|
513
|
-
},
|
|
514
|
-
'&:active': {
|
|
515
|
-
transform: 'translateY(0)',
|
|
516
|
-
boxShadow: '0 2px 8px rgba(25, 118, 210, 0.3)'
|
|
517
|
-
}
|
|
518
|
-
}}
|
|
519
|
-
>
|
|
520
|
-
<AddIcon sx={{ fontSize: 24 }} />
|
|
521
|
-
</IconButton>
|
|
522
|
-
</Stack>
|
|
523
|
-
</Box>
|
|
524
|
-
|
|
525
|
-
<Box sx={{ flex: 1, overflow: 'auto' }}>
|
|
526
|
-
{isLoading ? (
|
|
527
|
-
<Box sx={{ p: 2 }}>
|
|
528
|
-
{[...Array(3)].map((_, index) => (
|
|
529
|
-
<Box key={index} sx={{ px: 2, py: 0.5, mb: 1 }}>
|
|
530
|
-
<Box sx={{
|
|
531
|
-
p: 3,
|
|
532
|
-
borderRadius: 3,
|
|
533
|
-
bgcolor: 'background.paper',
|
|
534
|
-
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.02)',
|
|
535
|
-
display: 'flex',
|
|
536
|
-
alignItems: 'flex-start'
|
|
537
|
-
}}>
|
|
538
|
-
<Skeleton
|
|
539
|
-
variant="circular"
|
|
540
|
-
width={56}
|
|
541
|
-
height={56}
|
|
542
|
-
sx={{ mr: 2, flexShrink: 0 }}
|
|
543
|
-
/>
|
|
544
|
-
<Box sx={{ flex: 1 }}>
|
|
545
|
-
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
|
546
|
-
<Skeleton variant="text" width="45%" height={24} sx={{ borderRadius: 1 }} />
|
|
547
|
-
<Skeleton variant="text" width="20%" height={18} sx={{ borderRadius: 1 }} />
|
|
548
|
-
</Box>
|
|
549
|
-
<Skeleton variant="text" width="85%" height={20} sx={{ borderRadius: 1 }} />
|
|
550
|
-
<Skeleton variant="text" width="65%" height={20} sx={{ borderRadius: 1, mt: 0.5 }} />
|
|
551
|
-
</Box>
|
|
552
|
-
</Box>
|
|
553
|
-
</Box>
|
|
554
|
-
))}
|
|
555
|
-
</Box>
|
|
556
|
-
) : (
|
|
557
|
-
getMessageList()
|
|
558
|
-
)}
|
|
559
|
-
</Box>
|
|
560
|
-
</Paper>
|
|
561
|
-
);
|
|
562
|
-
}, (prevProps, nextProps) => {
|
|
563
|
-
// Only re-render if context.person.id changes or if we're explicitly forcing with refreshKey
|
|
564
|
-
const personChanged = prevProps.context?.person?.id !== nextProps.context?.person?.id;
|
|
565
|
-
const refreshKeyChanged = prevProps.refreshKey !== nextProps.refreshKey;
|
|
566
|
-
|
|
567
|
-
if (personChanged) {
|
|
568
|
-
return false; // Re-render
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
if (refreshKeyChanged) {
|
|
572
|
-
return false; // Re-render
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
return true; // Skip re-render
|
|
576
|
-
});
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect } from "react";
|
|
4
|
+
import { ApiHelper } from "@churchapps/helpers";
|
|
5
|
+
import {
|
|
6
|
+
Box,
|
|
7
|
+
Stack,
|
|
8
|
+
List,
|
|
9
|
+
ListItem,
|
|
10
|
+
ListItemAvatar,
|
|
11
|
+
ListItemText,
|
|
12
|
+
Typography,
|
|
13
|
+
IconButton,
|
|
14
|
+
Chip,
|
|
15
|
+
Divider,
|
|
16
|
+
Paper,
|
|
17
|
+
Skeleton,
|
|
18
|
+
useTheme
|
|
19
|
+
} from "@mui/material";
|
|
20
|
+
import { Add as AddIcon, ChatBubbleOutline as ChatIcon } from "@mui/icons-material";
|
|
21
|
+
import { PersonAvatar } from "../PersonAvatar";
|
|
22
|
+
import { PrivateMessageInterface, UserContextInterface } from "@churchapps/helpers";
|
|
23
|
+
import { ArrayHelper, DateHelper } from "../../helpers";
|
|
24
|
+
import { PrivateMessageDetails } from "./PrivateMessageDetails";
|
|
25
|
+
import { NewPrivateMessage } from "./NewPrivateMessage";
|
|
26
|
+
|
|
27
|
+
interface Props {
|
|
28
|
+
context: UserContextInterface;
|
|
29
|
+
refreshKey: number;
|
|
30
|
+
onUpdate: () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Create a persistent store for PrivateMessages state that survives component re-renders
|
|
34
|
+
const privateMessagesStateStore = {
|
|
35
|
+
selectedMessage: null as PrivateMessageInterface | null,
|
|
36
|
+
inAddMode: false,
|
|
37
|
+
listeners: new Set<() => void>(),
|
|
38
|
+
|
|
39
|
+
setSelectedMessage(value: PrivateMessageInterface | null) {
|
|
40
|
+
this.selectedMessage = value;
|
|
41
|
+
this.listeners.forEach((listener: () => void) => listener());
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
setInAddMode(value: boolean) {
|
|
45
|
+
this.inAddMode = value;
|
|
46
|
+
this.listeners.forEach((listener: () => void) => listener());
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
subscribe(listener: () => void) {
|
|
50
|
+
this.listeners.add(listener);
|
|
51
|
+
return () => this.listeners.delete(listener);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const PrivateMessages: React.FC<Props> = React.memo((props) => {
|
|
56
|
+
|
|
57
|
+
const [privateMessages, setPrivateMessages] = useState<PrivateMessageInterface[]>([]);
|
|
58
|
+
const [, forceUpdate] = React.useReducer(x => x + 1, 0);
|
|
59
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
60
|
+
|
|
61
|
+
// Subscribe to state changes
|
|
62
|
+
React.useEffect(() => {
|
|
63
|
+
return privateMessagesStateStore.subscribe(forceUpdate);
|
|
64
|
+
}, [forceUpdate]);
|
|
65
|
+
|
|
66
|
+
const selectedMessage = privateMessagesStateStore.selectedMessage;
|
|
67
|
+
const inAddMode = privateMessagesStateStore.inAddMode;
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
const loadData = async () => {
|
|
71
|
+
setIsLoading(true);
|
|
72
|
+
const pms: PrivateMessageInterface[] = await ApiHelper.get("/privateMessages", "MessagingApi");
|
|
73
|
+
|
|
74
|
+
// Store the current selected conversation ID if dialog is open
|
|
75
|
+
const currentSelectedPersonId = selectedMessage ?
|
|
76
|
+
(selectedMessage.fromPersonId === props.context.person.id) ?
|
|
77
|
+
selectedMessage.toPersonId : selectedMessage.fromPersonId
|
|
78
|
+
: null;
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
// Group messages by person (conversation)
|
|
82
|
+
const conversationMap = new Map<string, PrivateMessageInterface>();
|
|
83
|
+
const peopleIds: string[] = [];
|
|
84
|
+
|
|
85
|
+
pms.forEach((pm) => {
|
|
86
|
+
const personId = (pm.fromPersonId === props.context.person.id) ? pm.toPersonId : pm.fromPersonId;
|
|
87
|
+
if (peopleIds.indexOf(personId) === -1) peopleIds.push(personId);
|
|
88
|
+
|
|
89
|
+
// Keep only the most recent message per conversation
|
|
90
|
+
const currentMessage = pm.conversation?.messages?.[0];
|
|
91
|
+
const existingPm = conversationMap.get(personId);
|
|
92
|
+
const existingMessage = existingPm?.conversation?.messages?.[0];
|
|
93
|
+
|
|
94
|
+
if (!conversationMap.has(personId)) {
|
|
95
|
+
// First message for this person
|
|
96
|
+
conversationMap.set(personId, pm);
|
|
97
|
+
} else if (currentMessage && existingMessage) {
|
|
98
|
+
// Compare timestamps to keep the most recent
|
|
99
|
+
const currentTime = new Date(currentMessage.timeUpdated || currentMessage.timeSent);
|
|
100
|
+
const existingTime = new Date(existingMessage.timeUpdated || existingMessage.timeSent);
|
|
101
|
+
if (currentTime > existingTime) {
|
|
102
|
+
conversationMap.set(personId, pm);
|
|
103
|
+
}
|
|
104
|
+
} else if (currentMessage && !existingMessage) {
|
|
105
|
+
// Current has message but existing doesn't, use current
|
|
106
|
+
conversationMap.set(personId, pm);
|
|
107
|
+
}
|
|
108
|
+
// If !currentMessage but existingMessage, keep existing (do nothing)
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Convert map back to array (one message per conversation)
|
|
112
|
+
let conversations = Array.from(conversationMap.values());
|
|
113
|
+
|
|
114
|
+
// Filter out conversations without messages first
|
|
115
|
+
conversations = conversations.filter(pm => pm.conversation?.messages?.[0]);
|
|
116
|
+
|
|
117
|
+
// Get the filtered people IDs (only for conversations with messages)
|
|
118
|
+
const filteredPeopleIds = conversations.map(pm =>
|
|
119
|
+
(pm.fromPersonId === props.context.person.id) ? pm.toPersonId : pm.fromPersonId
|
|
120
|
+
).filter((id, index, arr) => arr.indexOf(id) === index); // Remove duplicates
|
|
121
|
+
|
|
122
|
+
if (filteredPeopleIds.length > 0) {
|
|
123
|
+
try {
|
|
124
|
+
const people = await ApiHelper.get("/people/basic?ids=" + filteredPeopleIds.join(","), "MembershipApi");
|
|
125
|
+
|
|
126
|
+
conversations.forEach(pm => {
|
|
127
|
+
const personId = (pm.fromPersonId === props.context.person.id) ? pm.toPersonId : pm.fromPersonId;
|
|
128
|
+
pm.person = ArrayHelper.getOne(people, "id", personId);
|
|
129
|
+
});
|
|
130
|
+
} catch (error) {
|
|
131
|
+
console.error("❌ Failed to load people data:", error);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Sort by most recent message (same logic as displayed in UI)
|
|
136
|
+
conversations.sort((a, b) => {
|
|
137
|
+
const aMessage = a.conversation?.messages?.[0];
|
|
138
|
+
const bMessage = b.conversation?.messages?.[0];
|
|
139
|
+
|
|
140
|
+
if (!aMessage && !bMessage) return 0;
|
|
141
|
+
if (!aMessage) return 1; // b comes first
|
|
142
|
+
if (!bMessage) return -1; // a comes first
|
|
143
|
+
|
|
144
|
+
const aTime = new Date(aMessage.timeUpdated || aMessage.timeSent).getTime();
|
|
145
|
+
const bTime = new Date(bMessage.timeUpdated || bMessage.timeSent).getTime();
|
|
146
|
+
|
|
147
|
+
// Most recent first (descending order)
|
|
148
|
+
return bTime - aTime;
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
setPrivateMessages(conversations);
|
|
152
|
+
|
|
153
|
+
// If a conversation is currently selected, update the selectedMessage to the new data
|
|
154
|
+
// This prevents the dialog from closing when new messages arrive
|
|
155
|
+
if (currentSelectedPersonId) {
|
|
156
|
+
const updatedSelectedMessage = conversations.find(pm => {
|
|
157
|
+
const personId = (pm.fromPersonId === props.context.person.id) ? pm.toPersonId : pm.fromPersonId;
|
|
158
|
+
return personId === currentSelectedPersonId;
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
if (updatedSelectedMessage) {
|
|
162
|
+
privateMessagesStateStore.setSelectedMessage(updatedSelectedMessage);
|
|
163
|
+
} else {
|
|
164
|
+
privateMessagesStateStore.setSelectedMessage(null);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
setIsLoading(false);
|
|
169
|
+
props.onUpdate();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Initialize data on mount
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
loadData();
|
|
175
|
+
}, []); //eslint-disable-line
|
|
176
|
+
|
|
177
|
+
// Reload data when refreshKey changes
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
loadData();
|
|
180
|
+
}, [props.refreshKey]); //eslint-disable-line
|
|
181
|
+
|
|
182
|
+
const getMessageList = () => {
|
|
183
|
+
if (privateMessages.length === 0) {
|
|
184
|
+
return (
|
|
185
|
+
<Box sx={{
|
|
186
|
+
textAlign: 'center',
|
|
187
|
+
py: 8,
|
|
188
|
+
px: 4,
|
|
189
|
+
height: '100%',
|
|
190
|
+
display: 'flex',
|
|
191
|
+
flexDirection: 'column',
|
|
192
|
+
justifyContent: 'center',
|
|
193
|
+
alignItems: 'center'
|
|
194
|
+
}}>
|
|
195
|
+
<Box sx={{
|
|
196
|
+
width: 120,
|
|
197
|
+
height: 120,
|
|
198
|
+
borderRadius: '50%',
|
|
199
|
+
bgcolor: 'rgba(25, 118, 210, 0.08)',
|
|
200
|
+
display: 'flex',
|
|
201
|
+
alignItems: 'center',
|
|
202
|
+
justifyContent: 'center',
|
|
203
|
+
mb: 3,
|
|
204
|
+
position: 'relative',
|
|
205
|
+
'&::before': {
|
|
206
|
+
content: '""',
|
|
207
|
+
position: 'absolute',
|
|
208
|
+
width: '100%',
|
|
209
|
+
height: '100%',
|
|
210
|
+
borderRadius: '50%',
|
|
211
|
+
border: '1px solid rgba(25, 118, 210, 0.12)',
|
|
212
|
+
animation: 'pulse 2s infinite'
|
|
213
|
+
},
|
|
214
|
+
'@keyframes pulse': {
|
|
215
|
+
'0%': {
|
|
216
|
+
transform: 'scale(1)',
|
|
217
|
+
opacity: 1
|
|
218
|
+
},
|
|
219
|
+
'50%': {
|
|
220
|
+
transform: 'scale(1.05)',
|
|
221
|
+
opacity: 0.7
|
|
222
|
+
},
|
|
223
|
+
'100%': {
|
|
224
|
+
transform: 'scale(1)',
|
|
225
|
+
opacity: 1
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}}>
|
|
229
|
+
<ChatIcon sx={{
|
|
230
|
+
fontSize: 56,
|
|
231
|
+
color: 'primary.main',
|
|
232
|
+
opacity: 0.8
|
|
233
|
+
}} />
|
|
234
|
+
</Box>
|
|
235
|
+
<Typography variant="h5" sx={{
|
|
236
|
+
color: 'text.primary',
|
|
237
|
+
fontWeight: 600,
|
|
238
|
+
mb: 1.5,
|
|
239
|
+
letterSpacing: '-0.02em'
|
|
240
|
+
}}>
|
|
241
|
+
No conversations yet
|
|
242
|
+
</Typography>
|
|
243
|
+
<Typography variant="body1" sx={{
|
|
244
|
+
color: 'text.secondary',
|
|
245
|
+
maxWidth: 280,
|
|
246
|
+
lineHeight: 1.6,
|
|
247
|
+
mb: 3
|
|
248
|
+
}}>
|
|
249
|
+
Start meaningful conversations with your community members. Your messages will appear here.
|
|
250
|
+
</Typography>
|
|
251
|
+
<Box sx={{
|
|
252
|
+
p: 2,
|
|
253
|
+
borderRadius: 2,
|
|
254
|
+
bgcolor: 'rgba(25, 118, 210, 0.04)',
|
|
255
|
+
border: '1px solid rgba(25, 118, 210, 0.12)',
|
|
256
|
+
display: 'flex',
|
|
257
|
+
alignItems: 'center',
|
|
258
|
+
gap: 1.5
|
|
259
|
+
}}>
|
|
260
|
+
<AddIcon sx={{ color: 'primary.main', fontSize: 20 }} />
|
|
261
|
+
<Typography variant="body2" sx={{
|
|
262
|
+
color: 'primary.main',
|
|
263
|
+
fontWeight: 500
|
|
264
|
+
}}>
|
|
265
|
+
Click the + button above to start your first conversation
|
|
266
|
+
</Typography>
|
|
267
|
+
</Box>
|
|
268
|
+
</Box>
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return (
|
|
273
|
+
<List sx={{
|
|
274
|
+
width: '100%',
|
|
275
|
+
p: 0,
|
|
276
|
+
'& .MuiListItem-root': {
|
|
277
|
+
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)'
|
|
278
|
+
}
|
|
279
|
+
}}>
|
|
280
|
+
{privateMessages.map((pm, index) => {
|
|
281
|
+
const person = pm.person;
|
|
282
|
+
const message = pm.conversation?.messages?.[0];
|
|
283
|
+
|
|
284
|
+
// Only filter out if there's no message - show conversations even without person data
|
|
285
|
+
if (!message) {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
const contents = message.content?.split("\n")[0];
|
|
289
|
+
let datePosted = new Date(message.timeUpdated || message.timeSent);
|
|
290
|
+
const displayDuration = DateHelper.getDisplayDuration(datePosted);
|
|
291
|
+
// Check if this conversation has unread messages
|
|
292
|
+
const isUnread = pm.notifyPersonId === props.context.person.id;
|
|
293
|
+
|
|
294
|
+
// Determine who sent the last message for better context
|
|
295
|
+
const isLastMessageFromMe = message.personId === props.context.person.id;
|
|
296
|
+
const isFromOtherPerson = pm.fromPersonId !== props.context.person.id;
|
|
297
|
+
|
|
298
|
+
return (
|
|
299
|
+
<Box key={pm.id} sx={{ px: 2, py: 0.5 }}>
|
|
300
|
+
<ListItem
|
|
301
|
+
component="button"
|
|
302
|
+
onClick={() => privateMessagesStateStore.setSelectedMessage(pm)}
|
|
303
|
+
sx={{
|
|
304
|
+
alignItems: 'flex-start',
|
|
305
|
+
p: 3,
|
|
306
|
+
cursor: 'pointer',
|
|
307
|
+
bgcolor: isUnread
|
|
308
|
+
? 'rgba(25, 118, 210, 0.04)'
|
|
309
|
+
: 'background.paper',
|
|
310
|
+
border: isUnread
|
|
311
|
+
? '1px solid rgba(25, 118, 210, 0.12)'
|
|
312
|
+
: '1px solid transparent',
|
|
313
|
+
borderRadius: 3,
|
|
314
|
+
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.02)',
|
|
315
|
+
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
316
|
+
transform: 'translateY(0)',
|
|
317
|
+
'&:hover': {
|
|
318
|
+
bgcolor: isUnread
|
|
319
|
+
? 'rgba(25, 118, 210, 0.06)'
|
|
320
|
+
: 'rgba(0, 0, 0, 0.02)',
|
|
321
|
+
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
|
|
322
|
+
transform: 'translateY(-1px)',
|
|
323
|
+
borderColor: 'rgba(0, 0, 0, 0.06)'
|
|
324
|
+
},
|
|
325
|
+
'&:active': {
|
|
326
|
+
transform: 'translateY(0)',
|
|
327
|
+
boxShadow: '0 2px 6px rgba(0, 0, 0, 0.06)'
|
|
328
|
+
},
|
|
329
|
+
position: 'relative',
|
|
330
|
+
overflow: 'hidden',
|
|
331
|
+
'&::before': isUnread ? {
|
|
332
|
+
content: '""',
|
|
333
|
+
position: 'absolute',
|
|
334
|
+
left: 0,
|
|
335
|
+
top: 0,
|
|
336
|
+
bottom: 0,
|
|
337
|
+
width: 4,
|
|
338
|
+
bgcolor: 'primary.main',
|
|
339
|
+
borderRadius: '0 2px 2px 0'
|
|
340
|
+
} : {}
|
|
341
|
+
}}
|
|
342
|
+
>
|
|
343
|
+
<ListItemAvatar sx={{ mr: 2 }}>
|
|
344
|
+
<Box sx={{ position: 'relative' }}>
|
|
345
|
+
<PersonAvatar person={person || { name: { display: "Unknown User" } } as any} size="medium" />
|
|
346
|
+
{isUnread && (
|
|
347
|
+
<Box
|
|
348
|
+
sx={{
|
|
349
|
+
position: 'absolute',
|
|
350
|
+
top: -2,
|
|
351
|
+
right: -2,
|
|
352
|
+
width: 14,
|
|
353
|
+
height: 14,
|
|
354
|
+
bgcolor: 'primary.main',
|
|
355
|
+
borderRadius: '50%',
|
|
356
|
+
border: '2px solid white',
|
|
357
|
+
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.2)'
|
|
358
|
+
}}
|
|
359
|
+
/>
|
|
360
|
+
)}
|
|
361
|
+
</Box>
|
|
362
|
+
</ListItemAvatar>
|
|
363
|
+
|
|
364
|
+
<ListItemText
|
|
365
|
+
sx={{ m: 0, flex: 1 }}
|
|
366
|
+
primary={
|
|
367
|
+
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" sx={{ mb: 0.5 }}>
|
|
368
|
+
<Typography
|
|
369
|
+
variant="h6"
|
|
370
|
+
sx={{
|
|
371
|
+
fontWeight: isUnread ? 600 : 500,
|
|
372
|
+
fontSize: '1rem',
|
|
373
|
+
color: 'text.primary',
|
|
374
|
+
lineHeight: 1.3
|
|
375
|
+
}}
|
|
376
|
+
>
|
|
377
|
+
{person?.name?.display || "Unknown User"}
|
|
378
|
+
</Typography>
|
|
379
|
+
<Typography
|
|
380
|
+
variant="caption"
|
|
381
|
+
sx={{
|
|
382
|
+
color: 'text.secondary',
|
|
383
|
+
fontSize: '0.75rem',
|
|
384
|
+
fontWeight: 500,
|
|
385
|
+
letterSpacing: '0.02em',
|
|
386
|
+
ml: 2,
|
|
387
|
+
flexShrink: 0
|
|
388
|
+
}}
|
|
389
|
+
>
|
|
390
|
+
{displayDuration}
|
|
391
|
+
</Typography>
|
|
392
|
+
</Stack>
|
|
393
|
+
}
|
|
394
|
+
secondary={
|
|
395
|
+
<Typography
|
|
396
|
+
variant="body2"
|
|
397
|
+
sx={{
|
|
398
|
+
color: 'text.secondary',
|
|
399
|
+
fontSize: '0.875rem',
|
|
400
|
+
fontWeight: isUnread ? 500 : 400,
|
|
401
|
+
lineHeight: 1.4,
|
|
402
|
+
overflow: 'hidden',
|
|
403
|
+
textOverflow: 'ellipsis',
|
|
404
|
+
display: '-webkit-box',
|
|
405
|
+
WebkitLineClamp: 2,
|
|
406
|
+
WebkitBoxOrient: 'vertical',
|
|
407
|
+
opacity: contents ? 1 : 0.6,
|
|
408
|
+
fontStyle: contents ? 'normal' : 'italic'
|
|
409
|
+
}}
|
|
410
|
+
>
|
|
411
|
+
{isLastMessageFromMe && contents && (
|
|
412
|
+
<Typography
|
|
413
|
+
component="span"
|
|
414
|
+
sx={{
|
|
415
|
+
fontWeight: 600,
|
|
416
|
+
color: 'text.primary',
|
|
417
|
+
opacity: 0.8
|
|
418
|
+
}}
|
|
419
|
+
>
|
|
420
|
+
You:{" "}
|
|
421
|
+
</Typography>
|
|
422
|
+
)}
|
|
423
|
+
{contents || 'No message preview available'}
|
|
424
|
+
</Typography>
|
|
425
|
+
}
|
|
426
|
+
/>
|
|
427
|
+
|
|
428
|
+
{isUnread && (
|
|
429
|
+
<Box sx={{ ml: 1, display: 'flex', alignItems: 'flex-start', pt: 0.5 }}>
|
|
430
|
+
<Chip
|
|
431
|
+
size="small"
|
|
432
|
+
label="New"
|
|
433
|
+
sx={{
|
|
434
|
+
height: 22,
|
|
435
|
+
fontSize: '0.7rem',
|
|
436
|
+
fontWeight: 600,
|
|
437
|
+
bgcolor: 'primary.main',
|
|
438
|
+
color: 'white',
|
|
439
|
+
'& .MuiChip-label': {
|
|
440
|
+
px: 1
|
|
441
|
+
}
|
|
442
|
+
}}
|
|
443
|
+
/>
|
|
444
|
+
</Box>
|
|
445
|
+
)}
|
|
446
|
+
</ListItem>
|
|
447
|
+
</Box>
|
|
448
|
+
);
|
|
449
|
+
})}
|
|
450
|
+
</List>
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const handleBack = () => {
|
|
455
|
+
privateMessagesStateStore.setInAddMode(false);
|
|
456
|
+
privateMessagesStateStore.setSelectedMessage(null);
|
|
457
|
+
loadData();
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const theme = useTheme();
|
|
461
|
+
|
|
462
|
+
if (inAddMode) return <NewPrivateMessage context={props.context} onSelectMessage={(pm: PrivateMessageInterface) => { privateMessagesStateStore.setSelectedMessage(pm); privateMessagesStateStore.setInAddMode(false); }} onBack={handleBack} />
|
|
463
|
+
if (selectedMessage) return <PrivateMessageDetails privateMessage={selectedMessage} context={props.context} onBack={handleBack} refreshKey={props.refreshKey} onMessageRead={loadData} />
|
|
464
|
+
|
|
465
|
+
return (
|
|
466
|
+
<Paper elevation={0} sx={{
|
|
467
|
+
height: '100%',
|
|
468
|
+
display: 'flex',
|
|
469
|
+
flexDirection: 'column',
|
|
470
|
+
bgcolor: 'background.default',
|
|
471
|
+
borderRadius: 0
|
|
472
|
+
}}>
|
|
473
|
+
<Box sx={{
|
|
474
|
+
p: 3,
|
|
475
|
+
borderBottom: '1px solid',
|
|
476
|
+
borderColor: 'divider',
|
|
477
|
+
bgcolor: 'background.paper',
|
|
478
|
+
backdropFilter: 'blur(10px)'
|
|
479
|
+
}}>
|
|
480
|
+
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
|
481
|
+
<Box>
|
|
482
|
+
<Typography variant="h5" component="h1" sx={{
|
|
483
|
+
fontWeight: 700,
|
|
484
|
+
color: 'text.primary',
|
|
485
|
+
letterSpacing: '-0.02em',
|
|
486
|
+
mb: 0.5
|
|
487
|
+
}}>
|
|
488
|
+
Messages
|
|
489
|
+
</Typography>
|
|
490
|
+
<Typography variant="body2" sx={{
|
|
491
|
+
color: 'text.secondary',
|
|
492
|
+
fontWeight: 500
|
|
493
|
+
}}>
|
|
494
|
+
{privateMessages.length === 0
|
|
495
|
+
? 'No conversations'
|
|
496
|
+
: `${privateMessages.length} conversation${privateMessages.length === 1 ? '' : 's'}`
|
|
497
|
+
}
|
|
498
|
+
</Typography>
|
|
499
|
+
</Box>
|
|
500
|
+
<IconButton
|
|
501
|
+
onClick={() => privateMessagesStateStore.setInAddMode(true)}
|
|
502
|
+
sx={{
|
|
503
|
+
bgcolor: 'primary.main',
|
|
504
|
+
color: 'white',
|
|
505
|
+
width: 48,
|
|
506
|
+
height: 48,
|
|
507
|
+
boxShadow: '0 4px 12px rgba(25, 118, 210, 0.3)',
|
|
508
|
+
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
509
|
+
'&:hover': {
|
|
510
|
+
bgcolor: 'primary.dark',
|
|
511
|
+
boxShadow: '0 6px 16px rgba(25, 118, 210, 0.4)',
|
|
512
|
+
transform: 'translateY(-1px)'
|
|
513
|
+
},
|
|
514
|
+
'&:active': {
|
|
515
|
+
transform: 'translateY(0)',
|
|
516
|
+
boxShadow: '0 2px 8px rgba(25, 118, 210, 0.3)'
|
|
517
|
+
}
|
|
518
|
+
}}
|
|
519
|
+
>
|
|
520
|
+
<AddIcon sx={{ fontSize: 24 }} />
|
|
521
|
+
</IconButton>
|
|
522
|
+
</Stack>
|
|
523
|
+
</Box>
|
|
524
|
+
|
|
525
|
+
<Box sx={{ flex: 1, overflow: 'auto' }}>
|
|
526
|
+
{isLoading ? (
|
|
527
|
+
<Box sx={{ p: 2 }}>
|
|
528
|
+
{[...Array(3)].map((_, index) => (
|
|
529
|
+
<Box key={index} sx={{ px: 2, py: 0.5, mb: 1 }}>
|
|
530
|
+
<Box sx={{
|
|
531
|
+
p: 3,
|
|
532
|
+
borderRadius: 3,
|
|
533
|
+
bgcolor: 'background.paper',
|
|
534
|
+
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.02)',
|
|
535
|
+
display: 'flex',
|
|
536
|
+
alignItems: 'flex-start'
|
|
537
|
+
}}>
|
|
538
|
+
<Skeleton
|
|
539
|
+
variant="circular"
|
|
540
|
+
width={56}
|
|
541
|
+
height={56}
|
|
542
|
+
sx={{ mr: 2, flexShrink: 0 }}
|
|
543
|
+
/>
|
|
544
|
+
<Box sx={{ flex: 1 }}>
|
|
545
|
+
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
|
546
|
+
<Skeleton variant="text" width="45%" height={24} sx={{ borderRadius: 1 }} />
|
|
547
|
+
<Skeleton variant="text" width="20%" height={18} sx={{ borderRadius: 1 }} />
|
|
548
|
+
</Box>
|
|
549
|
+
<Skeleton variant="text" width="85%" height={20} sx={{ borderRadius: 1 }} />
|
|
550
|
+
<Skeleton variant="text" width="65%" height={20} sx={{ borderRadius: 1, mt: 0.5 }} />
|
|
551
|
+
</Box>
|
|
552
|
+
</Box>
|
|
553
|
+
</Box>
|
|
554
|
+
))}
|
|
555
|
+
</Box>
|
|
556
|
+
) : (
|
|
557
|
+
getMessageList()
|
|
558
|
+
)}
|
|
559
|
+
</Box>
|
|
560
|
+
</Paper>
|
|
561
|
+
);
|
|
562
|
+
}, (prevProps, nextProps) => {
|
|
563
|
+
// Only re-render if context.person.id changes or if we're explicitly forcing with refreshKey
|
|
564
|
+
const personChanged = prevProps.context?.person?.id !== nextProps.context?.person?.id;
|
|
565
|
+
const refreshKeyChanged = prevProps.refreshKey !== nextProps.refreshKey;
|
|
566
|
+
|
|
567
|
+
if (personChanged) {
|
|
568
|
+
return false; // Re-render
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (refreshKeyChanged) {
|
|
572
|
+
return false; // Re-render
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
return true; // Skip re-render
|
|
576
|
+
});
|