@churchapps/apphelper
Version:
Library of helper functions for React and NextJS ChurchApps
379 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 } 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
// Read directly from store to get the current value, not the closure variable
const currentSelectedPersonId = privateMessagesStateStore.selectedMessage ?
(privateMessagesStateStore.selectedMessage.fromPersonId === props.context.person.id) ?
privateMessagesStateStore.selectedMessage.toPersonId : privateMessagesStateStore.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();
}, []);
// Reload data when refreshKey changes
useEffect(() => {
loadData();
}, [props.refreshKey]);
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];
const 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();
};
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 } })] })] }) }, `skeleton-${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