@churchapps/apphelper
Version:
Library of helper functions for React and NextJS ChurchApps
383 lines • 22.5 kB
JavaScript
"use client";
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import React, { useState, useEffect } from "react";
import { ApiHelper } from "@churchapps/helpers";
import { Box, Stack, List, ListItem, ListItemAvatar, ListItemText, Typography, IconButton, Chip, Paper, Skeleton, useTheme } from "@mui/material";
import { Add as AddIcon, ChatBubbleOutline as ChatIcon } from "@mui/icons-material";
import { PersonAvatar } from "../PersonAvatar";
import { ArrayHelper, DateHelper } from "../../helpers";
import { PrivateMessageDetails } from "./PrivateMessageDetails";
import { NewPrivateMessage } from "./NewPrivateMessage";
// Create a persistent store for PrivateMessages state that survives component re-renders
const privateMessagesStateStore = {
selectedMessage: null,
inAddMode: false,
listeners: new Set(),
setSelectedMessage(value) {
this.selectedMessage = value;
this.listeners.forEach((listener) => listener());
},
setInAddMode(value) {
this.inAddMode = value;
this.listeners.forEach((listener) => listener());
},
subscribe(listener) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
};
export const PrivateMessages = React.memo((props) => {
const [privateMessages, setPrivateMessages] = useState([]);
const [, forceUpdate] = React.useReducer(x => x + 1, 0);
const [isLoading, setIsLoading] = useState(true);
// Subscribe to state changes
React.useEffect(() => {
return privateMessagesStateStore.subscribe(forceUpdate);
}, [forceUpdate]);
const selectedMessage = privateMessagesStateStore.selectedMessage;
const inAddMode = privateMessagesStateStore.inAddMode;
const loadData = async () => {
setIsLoading(true);
const pms = await ApiHelper.get("/privateMessages", "MessagingApi");
// Store the current selected conversation ID if dialog is open
const currentSelectedPersonId = selectedMessage ?
(selectedMessage.fromPersonId === props.context.person.id) ?
selectedMessage.toPersonId : selectedMessage.fromPersonId
: null;
// Group messages by person (conversation)
const conversationMap = new Map();
const peopleIds = [];
pms.forEach((pm) => {
const personId = (pm.fromPersonId === props.context.person.id) ? pm.toPersonId : pm.fromPersonId;
if (peopleIds.indexOf(personId) === -1)
peopleIds.push(personId);
// Keep only the most recent message per conversation
const currentMessage = pm.conversation?.messages?.[0];
const existingPm = conversationMap.get(personId);
const existingMessage = existingPm?.conversation?.messages?.[0];
if (!conversationMap.has(personId)) {
// First message for this person
conversationMap.set(personId, pm);
}
else if (currentMessage && existingMessage) {
// Compare timestamps to keep the most recent
const currentTime = new Date(currentMessage.timeUpdated || currentMessage.timeSent);
const existingTime = new Date(existingMessage.timeUpdated || existingMessage.timeSent);
if (currentTime > existingTime) {
conversationMap.set(personId, pm);
}
}
else if (currentMessage && !existingMessage) {
// Current has message but existing doesn't, use current
conversationMap.set(personId, pm);
}
// If !currentMessage but existingMessage, keep existing (do nothing)
});
// Convert map back to array (one message per conversation)
let conversations = Array.from(conversationMap.values());
// Filter out conversations without messages first
conversations = conversations.filter(pm => pm.conversation?.messages?.[0]);
// Get the filtered people IDs (only for conversations with messages)
const filteredPeopleIds = conversations.map(pm => (pm.fromPersonId === props.context.person.id) ? pm.toPersonId : pm.fromPersonId).filter((id, index, arr) => arr.indexOf(id) === index); // Remove duplicates
if (filteredPeopleIds.length > 0) {
try {
const people = await ApiHelper.get("/people/basic?ids=" + filteredPeopleIds.join(","), "MembershipApi");
conversations.forEach(pm => {
const personId = (pm.fromPersonId === props.context.person.id) ? pm.toPersonId : pm.fromPersonId;
pm.person = ArrayHelper.getOne(people, "id", personId);
});
}
catch (error) {
console.error("❌ Failed to load people data:", error);
}
}
// Sort by most recent message (same logic as displayed in UI)
conversations.sort((a, b) => {
const aMessage = a.conversation?.messages?.[0];
const bMessage = b.conversation?.messages?.[0];
if (!aMessage && !bMessage)
return 0;
if (!aMessage)
return 1; // b comes first
if (!bMessage)
return -1; // a comes first
const aTime = new Date(aMessage.timeUpdated || aMessage.timeSent).getTime();
const bTime = new Date(bMessage.timeUpdated || bMessage.timeSent).getTime();
// Most recent first (descending order)
return bTime - aTime;
});
setPrivateMessages(conversations);
// If a conversation is currently selected, update the selectedMessage to the new data
// This prevents the dialog from closing when new messages arrive
if (currentSelectedPersonId) {
const updatedSelectedMessage = conversations.find(pm => {
const personId = (pm.fromPersonId === props.context.person.id) ? pm.toPersonId : pm.fromPersonId;
return personId === currentSelectedPersonId;
});
if (updatedSelectedMessage) {
privateMessagesStateStore.setSelectedMessage(updatedSelectedMessage);
}
else {
privateMessagesStateStore.setSelectedMessage(null);
}
}
setIsLoading(false);
props.onUpdate();
};
// Initialize data on mount
useEffect(() => {
loadData();
}, []); //eslint-disable-line
// Reload data when refreshKey changes
useEffect(() => {
loadData();
}, [props.refreshKey]); //eslint-disable-line
const getMessageList = () => {
if (privateMessages.length === 0) {
return (_jsxs(Box, { sx: {
textAlign: 'center',
py: 8,
px: 4,
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center'
}, children: [_jsx(Box, { sx: {
width: 120,
height: 120,
borderRadius: '50%',
bgcolor: 'rgba(25, 118, 210, 0.08)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
mb: 3,
position: 'relative',
'&::before': {
content: '""',
position: 'absolute',
width: '100%',
height: '100%',
borderRadius: '50%',
border: '1px solid rgba(25, 118, 210, 0.12)',
animation: 'pulse 2s infinite'
},
'@keyframes pulse': {
'0%': {
transform: 'scale(1)',
opacity: 1
},
'50%': {
transform: 'scale(1.05)',
opacity: 0.7
},
'100%': {
transform: 'scale(1)',
opacity: 1
}
}
}, children: _jsx(ChatIcon, { sx: {
fontSize: 56,
color: 'primary.main',
opacity: 0.8
} }) }), _jsx(Typography, { variant: "h5", sx: {
color: 'text.primary',
fontWeight: 600,
mb: 1.5,
letterSpacing: '-0.02em'
}, children: "No conversations yet" }), _jsx(Typography, { variant: "body1", sx: {
color: 'text.secondary',
maxWidth: 280,
lineHeight: 1.6,
mb: 3
}, children: "Start meaningful conversations with your community members. Your messages will appear here." }), _jsxs(Box, { sx: {
p: 2,
borderRadius: 2,
bgcolor: 'rgba(25, 118, 210, 0.04)',
border: '1px solid rgba(25, 118, 210, 0.12)',
display: 'flex',
alignItems: 'center',
gap: 1.5
}, children: [_jsx(AddIcon, { sx: { color: 'primary.main', fontSize: 20 } }), _jsx(Typography, { variant: "body2", sx: {
color: 'primary.main',
fontWeight: 500
}, children: "Click the + button above to start your first conversation" })] })] }));
}
return (_jsx(List, { sx: {
width: '100%',
p: 0,
'& .MuiListItem-root': {
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)'
}
}, children: privateMessages.map((pm, index) => {
const person = pm.person;
const message = pm.conversation?.messages?.[0];
// Only filter out if there's no message - show conversations even without person data
if (!message) {
return null;
}
const contents = message.content?.split("\n")[0];
let datePosted = new Date(message.timeUpdated || message.timeSent);
const displayDuration = DateHelper.getDisplayDuration(datePosted);
// Check if this conversation has unread messages
const isUnread = pm.notifyPersonId === props.context.person.id;
// Determine who sent the last message for better context
const isLastMessageFromMe = message.personId === props.context.person.id;
const isFromOtherPerson = pm.fromPersonId !== props.context.person.id;
return (_jsx(Box, { sx: { px: 2, py: 0.5 }, children: _jsxs(ListItem, { component: "button", onClick: () => privateMessagesStateStore.setSelectedMessage(pm), sx: {
alignItems: 'flex-start',
p: 3,
cursor: 'pointer',
bgcolor: isUnread
? 'rgba(25, 118, 210, 0.04)'
: 'background.paper',
border: isUnread
? '1px solid rgba(25, 118, 210, 0.12)'
: '1px solid transparent',
borderRadius: 3,
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.02)',
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
transform: 'translateY(0)',
'&:hover': {
bgcolor: isUnread
? 'rgba(25, 118, 210, 0.06)'
: 'rgba(0, 0, 0, 0.02)',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
transform: 'translateY(-1px)',
borderColor: 'rgba(0, 0, 0, 0.06)'
},
'&:active': {
transform: 'translateY(0)',
boxShadow: '0 2px 6px rgba(0, 0, 0, 0.06)'
},
position: 'relative',
overflow: 'hidden',
'&::before': isUnread ? {
content: '""',
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: 4,
bgcolor: 'primary.main',
borderRadius: '0 2px 2px 0'
} : {}
}, children: [_jsx(ListItemAvatar, { sx: { mr: 2 }, children: _jsxs(Box, { sx: { position: 'relative' }, children: [_jsx(PersonAvatar, { person: person || { name: { display: "Unknown User" } }, size: "medium" }), isUnread && (_jsx(Box, { sx: {
position: 'absolute',
top: -2,
right: -2,
width: 14,
height: 14,
bgcolor: 'primary.main',
borderRadius: '50%',
border: '2px solid white',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.2)'
} }))] }) }), _jsx(ListItemText, { sx: { m: 0, flex: 1 }, primary: _jsxs(Stack, { direction: "row", justifyContent: "space-between", alignItems: "flex-start", sx: { mb: 0.5 }, children: [_jsx(Typography, { variant: "h6", sx: {
fontWeight: isUnread ? 600 : 500,
fontSize: '1rem',
color: 'text.primary',
lineHeight: 1.3
}, children: person?.name?.display || "Unknown User" }), _jsx(Typography, { variant: "caption", sx: {
color: 'text.secondary',
fontSize: '0.75rem',
fontWeight: 500,
letterSpacing: '0.02em',
ml: 2,
flexShrink: 0
}, children: displayDuration })] }), secondary: _jsxs(Typography, { variant: "body2", sx: {
color: 'text.secondary',
fontSize: '0.875rem',
fontWeight: isUnread ? 500 : 400,
lineHeight: 1.4,
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
opacity: contents ? 1 : 0.6,
fontStyle: contents ? 'normal' : 'italic'
}, children: [isLastMessageFromMe && contents && (_jsxs(Typography, { component: "span", sx: {
fontWeight: 600,
color: 'text.primary',
opacity: 0.8
}, children: ["You:", " "] })), contents || 'No message preview available'] }) }), isUnread && (_jsx(Box, { sx: { ml: 1, display: 'flex', alignItems: 'flex-start', pt: 0.5 }, children: _jsx(Chip, { size: "small", label: "New", sx: {
height: 22,
fontSize: '0.7rem',
fontWeight: 600,
bgcolor: 'primary.main',
color: 'white',
'& .MuiChip-label': {
px: 1
}
} }) }))] }) }, pm.id));
}) }));
};
const handleBack = () => {
privateMessagesStateStore.setInAddMode(false);
privateMessagesStateStore.setSelectedMessage(null);
loadData();
};
const theme = useTheme();
if (inAddMode)
return _jsx(NewPrivateMessage, { context: props.context, onSelectMessage: (pm) => { privateMessagesStateStore.setSelectedMessage(pm); privateMessagesStateStore.setInAddMode(false); }, onBack: handleBack });
if (selectedMessage)
return _jsx(PrivateMessageDetails, { privateMessage: selectedMessage, context: props.context, onBack: handleBack, refreshKey: props.refreshKey, onMessageRead: loadData });
return (_jsxs(Paper, { elevation: 0, sx: {
height: '100%',
display: 'flex',
flexDirection: 'column',
bgcolor: 'background.default',
borderRadius: 0
}, children: [_jsx(Box, { sx: {
p: 3,
borderBottom: '1px solid',
borderColor: 'divider',
bgcolor: 'background.paper',
backdropFilter: 'blur(10px)'
}, children: _jsxs(Stack, { direction: "row", justifyContent: "space-between", alignItems: "center", children: [_jsxs(Box, { children: [_jsx(Typography, { variant: "h5", component: "h1", sx: {
fontWeight: 700,
color: 'text.primary',
letterSpacing: '-0.02em',
mb: 0.5
}, children: "Messages" }), _jsx(Typography, { variant: "body2", sx: {
color: 'text.secondary',
fontWeight: 500
}, children: privateMessages.length === 0
? 'No conversations'
: `${privateMessages.length} conversation${privateMessages.length === 1 ? '' : 's'}` })] }), _jsx(IconButton, { onClick: () => privateMessagesStateStore.setInAddMode(true), sx: {
bgcolor: 'primary.main',
color: 'white',
width: 48,
height: 48,
boxShadow: '0 4px 12px rgba(25, 118, 210, 0.3)',
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
bgcolor: 'primary.dark',
boxShadow: '0 6px 16px rgba(25, 118, 210, 0.4)',
transform: 'translateY(-1px)'
},
'&:active': {
transform: 'translateY(0)',
boxShadow: '0 2px 8px rgba(25, 118, 210, 0.3)'
}
}, children: _jsx(AddIcon, { sx: { fontSize: 24 } }) })] }) }), _jsx(Box, { sx: { flex: 1, overflow: 'auto' }, children: isLoading ? (_jsx(Box, { sx: { p: 2 }, children: [...Array(3)].map((_, index) => (_jsx(Box, { sx: { px: 2, py: 0.5, mb: 1 }, children: _jsxs(Box, { sx: {
p: 3,
borderRadius: 3,
bgcolor: 'background.paper',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.02)',
display: 'flex',
alignItems: 'flex-start'
}, children: [_jsx(Skeleton, { variant: "circular", width: 56, height: 56, sx: { mr: 2, flexShrink: 0 } }), _jsxs(Box, { sx: { flex: 1 }, children: [_jsxs(Box, { sx: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }, children: [_jsx(Skeleton, { variant: "text", width: "45%", height: 24, sx: { borderRadius: 1 } }), _jsx(Skeleton, { variant: "text", width: "20%", height: 18, sx: { borderRadius: 1 } })] }), _jsx(Skeleton, { variant: "text", width: "85%", height: 20, sx: { borderRadius: 1 } }), _jsx(Skeleton, { variant: "text", width: "65%", height: 20, sx: { borderRadius: 1, mt: 0.5 } })] })] }) }, index))) })) : (getMessageList()) })] }));
}, (prevProps, nextProps) => {
// Only re-render if context.person.id changes or if we're explicitly forcing with refreshKey
const personChanged = prevProps.context?.person?.id !== nextProps.context?.person?.id;
const refreshKeyChanged = prevProps.refreshKey !== nextProps.refreshKey;
if (personChanged) {
return false; // Re-render
}
if (refreshKeyChanged) {
return false; // Re-render
}
return true; // Skip re-render
});
//# sourceMappingURL=PrivateMessages.js.map