@churchapps/apphelper 0.4.17 → 0.4.18

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