978 lines
43 KiB
TypeScript
978 lines
43 KiB
TypeScript
import React, { useState, useEffect, useMemo } from 'react';
|
|
import { useSearchParams } from 'react-router-dom';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import {
|
|
Inbox,
|
|
Send,
|
|
FileText,
|
|
Star,
|
|
Trash2,
|
|
Mail,
|
|
MailOpen,
|
|
Search,
|
|
PenSquare,
|
|
RefreshCw,
|
|
Reply,
|
|
CheckCircle2,
|
|
Clock,
|
|
RotateCcw,
|
|
X,
|
|
Smartphone,
|
|
ShieldAlert,
|
|
} from 'lucide-react';
|
|
import { useInAppNotifications, DraftMessage } from '../hooks/useInAppNotifications';
|
|
import { ComposeMessageModal } from '../components/notifications/ComposeMessageModal';
|
|
import { MarkdownViewer } from '../components/notifications/MarkdownViewer';
|
|
import { formatDate } from '../lib/formatters';
|
|
import { Notification, NotificationPlatform, NotificationStatus, User } from '../types';
|
|
import { useAuth } from '../auth/AuthProvider';
|
|
import { stripMarkdown } from '../lib/markdownUtils';
|
|
import { getUsers } from '../services/api';
|
|
|
|
type MailboxFolder = 'inbox' | 'starred' | 'sent' | 'drafts' | 'trash';
|
|
|
|
export const InboxPage: React.FC = () => {
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const targetIdParam = searchParams.get('id');
|
|
|
|
const { user, claims } = useAuth();
|
|
const currentUserId = String(user?.id || claims?.sub || '').trim().toLowerCase();
|
|
const currentUserEmail = (user?.email || claims?.email || '') as string;
|
|
|
|
const { data: directoryUsers = [] } = useQuery<User[]>({
|
|
queryKey: ['users-directory'],
|
|
queryFn: () => getUsers(1, 200),
|
|
});
|
|
|
|
const {
|
|
allMailboxNotifications,
|
|
inboxNotifications,
|
|
unreadCount,
|
|
starredNotifications,
|
|
trashedNotifications,
|
|
sentNotifications,
|
|
drafts,
|
|
starredIds,
|
|
isLoading,
|
|
refetch,
|
|
markAsRead,
|
|
toggleStar,
|
|
deleteMessage,
|
|
restoreMessage,
|
|
permanentDelete,
|
|
deleteDraft,
|
|
sendMessage,
|
|
isSending,
|
|
} = useInAppNotifications();
|
|
|
|
// Active folder
|
|
const [activeFolder, setActiveFolder] = useState<MailboxFolder>('inbox');
|
|
// Search query
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
// Filter for unread only in Inbox
|
|
const [showUnreadOnly, setShowUnreadOnly] = useState(false);
|
|
// Selected message to view in detail
|
|
const [selectedMessage, setSelectedMessage] = useState<Notification | null>(null);
|
|
// Selected draft to edit/resume
|
|
const [selectedDraft, setSelectedDraft] = useState<DraftMessage | null>(null);
|
|
|
|
// Compose modal state
|
|
const [isComposeOpen, setIsComposeOpen] = useState(false);
|
|
const [composeInitialRecipient, setComposeInitialRecipient] = useState<any>(undefined);
|
|
const [composeInitialSubject, setComposeInitialSubject] = useState('');
|
|
const [composeInitialMessage, setComposeInitialMessage] = useState('');
|
|
const [composeInitialPlatform, setComposeInitialPlatform] = useState<NotificationPlatform>(NotificationPlatform.InApp);
|
|
|
|
// Inline quick reply state
|
|
const [inlineReplyText, setInlineReplyText] = useState('');
|
|
const [inlineReplySuccess, setInlineReplySuccess] = useState(false);
|
|
// Self-reply prevention modal state
|
|
const [isSelfReplyModalOpen, setIsSelfReplyModalOpen] = useState(false);
|
|
|
|
// Active folder message items (always In-App notifications)
|
|
const folderMessages = useMemo(() => {
|
|
switch (activeFolder) {
|
|
case 'inbox':
|
|
return inboxNotifications;
|
|
case 'starred':
|
|
return starredNotifications;
|
|
case 'sent':
|
|
return sentNotifications;
|
|
case 'trash':
|
|
return trashedNotifications;
|
|
case 'drafts':
|
|
return [];
|
|
default:
|
|
return inboxNotifications;
|
|
}
|
|
}, [activeFolder, inboxNotifications, starredNotifications, sentNotifications, trashedNotifications]);
|
|
|
|
// Filtered by search and unread toggle
|
|
const filteredMessages = useMemo(() => {
|
|
return folderMessages.filter(item => {
|
|
// Unread only filter
|
|
if (activeFolder === 'inbox' && showUnreadOnly) {
|
|
if (Number(item.status) === NotificationStatus.Read) return false;
|
|
}
|
|
// Search term
|
|
if (!searchTerm.trim()) return true;
|
|
const term = searchTerm.toLowerCase();
|
|
const subject = (item.subject || '').toLowerCase();
|
|
const message = (item.message || '').toLowerCase();
|
|
const dest = (item.destination || '').toLowerCase();
|
|
return subject.includes(term) || message.includes(term) || dest.includes(term);
|
|
});
|
|
}, [folderMessages, activeFolder, showUnreadOnly, searchTerm]);
|
|
|
|
// Filtered drafts for Drafts folder
|
|
const filteredDrafts = useMemo(() => {
|
|
if (activeFolder !== 'drafts') return [];
|
|
if (!searchTerm.trim()) return drafts;
|
|
const term = searchTerm.toLowerCase();
|
|
return drafts.filter(
|
|
d =>
|
|
d.subject.toLowerCase().includes(term) ||
|
|
d.message.toLowerCase().includes(term) ||
|
|
d.recipientEmail.toLowerCase().includes(term)
|
|
);
|
|
}, [drafts, activeFolder, searchTerm]);
|
|
|
|
// Automatically select message when URL param ?id=... is present
|
|
useEffect(() => {
|
|
if (targetIdParam) {
|
|
const found = folderMessages.find(m => String(m.id) === targetIdParam);
|
|
if (found) {
|
|
setSelectedMessage(found);
|
|
if (Number(found.status) !== NotificationStatus.Read) {
|
|
markAsRead(found.id);
|
|
}
|
|
}
|
|
} else if (!selectedMessage && folderMessages.length > 0 && activeFolder !== 'drafts') {
|
|
setSelectedMessage(folderMessages[0]);
|
|
}
|
|
}, [targetIdParam, folderMessages, activeFolder]);
|
|
|
|
// Handle message selection
|
|
const handleSelectMessage = (item: Notification) => {
|
|
setSelectedMessage(item);
|
|
setSearchParams({ id: String(item.id) });
|
|
if (Number(item.status) !== NotificationStatus.Read) {
|
|
markAsRead(item.id);
|
|
}
|
|
setInlineReplyText('');
|
|
setInlineReplySuccess(false);
|
|
};
|
|
|
|
// Open Compose Modal for a new message
|
|
const handleOpenCompose = (defaultPlatform: NotificationPlatform = NotificationPlatform.InApp) => {
|
|
setSelectedDraft(null);
|
|
setComposeInitialRecipient(undefined);
|
|
setComposeInitialSubject('');
|
|
setComposeInitialMessage('');
|
|
setComposeInitialPlatform(defaultPlatform);
|
|
setIsComposeOpen(true);
|
|
};
|
|
|
|
const extractSenderEmail = (msg: Notification): string => {
|
|
const rawDest = msg.destination || '';
|
|
if (rawDest.includes('From:')) {
|
|
const fromPart = rawDest.split('From:')[1]?.split('|')[0]?.trim() || '';
|
|
return fromPart.replace(/[<>]/g, '').trim();
|
|
}
|
|
if (!rawDest.includes('To:') && rawDest.includes('@')) {
|
|
return rawDest.trim();
|
|
}
|
|
return '';
|
|
};
|
|
|
|
const isNotificationFromSelf = (msg: Notification): boolean => {
|
|
if (activeFolder === 'sent' || String(msg.id).startsWith('sent_')) {
|
|
return true;
|
|
}
|
|
const senderEmail = extractSenderEmail(msg).toLowerCase();
|
|
const myEmail = currentUserEmail.trim().toLowerCase();
|
|
if (myEmail && senderEmail && senderEmail === myEmail) {
|
|
return true;
|
|
}
|
|
if (!senderEmail && String(msg.recipient || '').trim().toLowerCase() === currentUserId) {
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
// Open Compose Modal for a Reply
|
|
const handleReplyMessage = (msg: Notification) => {
|
|
if (isNotificationFromSelf(msg)) {
|
|
setIsSelfReplyModalOpen(true);
|
|
return;
|
|
}
|
|
|
|
const replyEmail = extractSenderEmail(msg);
|
|
const matchedSender = replyEmail
|
|
? directoryUsers.find(u => (u.email || '').trim().toLowerCase() === replyEmail.toLowerCase())
|
|
: undefined;
|
|
|
|
if (
|
|
(matchedSender && String(matchedSender.id).trim().toLowerCase() === currentUserId) ||
|
|
(replyEmail && replyEmail.toLowerCase() === currentUserEmail.trim().toLowerCase())
|
|
) {
|
|
setIsSelfReplyModalOpen(true);
|
|
return;
|
|
}
|
|
|
|
const replySubject = msg.subject?.startsWith('Re:') ? msg.subject : `Re: ${msg.subject || 'Communication'}`;
|
|
const quotedBody = `\n\n--- Original Message (${formatDate(msg.createdAt)}) ---\n> ${stripMarkdown(msg.message || '')}`;
|
|
|
|
setSelectedDraft(null);
|
|
setComposeInitialRecipient(
|
|
replyEmail && matchedSender
|
|
? {
|
|
id: String(matchedSender.id),
|
|
email: replyEmail,
|
|
name: replyEmail.split('@')[0],
|
|
}
|
|
: undefined
|
|
);
|
|
setComposeInitialSubject(replySubject);
|
|
setComposeInitialMessage(quotedBody);
|
|
setComposeInitialPlatform(msg.platform as NotificationPlatform || NotificationPlatform.InApp);
|
|
setIsComposeOpen(true);
|
|
};
|
|
|
|
// Resume editing a saved draft
|
|
const handleEditDraft = (draft: DraftMessage) => {
|
|
setSelectedDraft(draft);
|
|
setComposeInitialRecipient(
|
|
draft.recipientId
|
|
? {
|
|
id: draft.recipientId,
|
|
email: draft.recipientEmail,
|
|
name: draft.recipientName,
|
|
}
|
|
: undefined
|
|
);
|
|
setComposeInitialSubject(draft.subject);
|
|
setComposeInitialMessage(draft.message);
|
|
setComposeInitialPlatform(draft.platform || NotificationPlatform.InApp);
|
|
setIsComposeOpen(true);
|
|
};
|
|
|
|
// Send inline quick reply
|
|
const handleSendInlineReply = async () => {
|
|
if (!selectedMessage || !inlineReplyText.trim()) return;
|
|
|
|
if (isNotificationFromSelf(selectedMessage)) {
|
|
setIsSelfReplyModalOpen(true);
|
|
return;
|
|
}
|
|
|
|
const replyEmail = extractSenderEmail(selectedMessage);
|
|
const matchedSender = replyEmail
|
|
? directoryUsers.find(u => (u.email || '').trim().toLowerCase() === replyEmail.toLowerCase())
|
|
: undefined;
|
|
|
|
const targetRecipientId = matchedSender ? String(matchedSender.id) : String(selectedMessage.recipient);
|
|
if (
|
|
targetRecipientId.trim().toLowerCase() === currentUserId ||
|
|
(replyEmail && replyEmail.toLowerCase() === currentUserEmail.trim().toLowerCase())
|
|
) {
|
|
setIsSelfReplyModalOpen(true);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await sendMessage({
|
|
recipientId: targetRecipientId,
|
|
recipientEmail: replyEmail || 'team@dsi.gov.za',
|
|
subject: selectedMessage.subject?.startsWith('Re:')
|
|
? selectedMessage.subject
|
|
: `Re: ${selectedMessage.subject || 'Notification'}`,
|
|
message: inlineReplyText.trim(),
|
|
platform: selectedMessage.platform as NotificationPlatform || NotificationPlatform.InApp,
|
|
});
|
|
|
|
setInlineReplySuccess(true);
|
|
setInlineReplyText('');
|
|
setTimeout(() => {
|
|
setInlineReplySuccess(false);
|
|
}, 3000);
|
|
} catch (err) {
|
|
console.error('Failed to send inline reply:', err);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex-1 flex flex-col h-full bg-[#090d16] text-slate-100 overflow-hidden font-sans">
|
|
{/* Top Mailbox Breadcrumb & Action Banner */}
|
|
<div className="h-14 px-6 border-b border-slate-800/80 bg-[#0d121f]/90 backdrop-blur-md flex items-center justify-between shrink-0">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 rounded-xl bg-blue-600/20 border border-blue-500/30 flex items-center justify-center text-blue-400">
|
|
<Mail className="w-4 h-4" />
|
|
</div>
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<h1 className="text-sm font-bold text-white tracking-tight">Mailbox & Communications</h1>
|
|
<span className="text-[10px] font-semibold text-blue-300 bg-blue-500/10 border border-blue-500/30 px-2 py-0.5 rounded-full flex items-center gap-1">
|
|
In-App Messaging
|
|
</span>
|
|
</div>
|
|
<p className="text-[11px] text-slate-400">
|
|
In-App notifications and internal platform communications
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => refetch()}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 text-xs font-medium text-slate-300 hover:text-white transition cursor-pointer shadow-xs"
|
|
title="Refresh mailbox messages"
|
|
>
|
|
<RefreshCw className={`w-3.5 h-3.5 ${isLoading ? 'animate-spin text-blue-400' : ''}`} />
|
|
<span>Refresh</span>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => handleOpenCompose()}
|
|
id="compose-message-main-btn"
|
|
className="flex items-center gap-2 px-4 py-1.5 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold shadow-lg shadow-blue-600/30 transition cursor-pointer"
|
|
>
|
|
<PenSquare className="w-3.5 h-3.5" />
|
|
<span>Compose</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main Mailbox Three-Pane Layout */}
|
|
<div className="flex-1 flex overflow-hidden">
|
|
{/* ======================================================== */}
|
|
{/* PANE 1: Mailbox Folders Sidebar */}
|
|
{/* ======================================================== */}
|
|
<aside className="w-60 shrink-0 border-r border-slate-800/80 bg-[#0b101b]/95 p-3 flex flex-col justify-between">
|
|
<div className="space-y-4">
|
|
{/* Primary Compose Action */}
|
|
<button
|
|
type="button"
|
|
onClick={() => handleOpenCompose()}
|
|
className="w-full flex items-center justify-center gap-2 py-2.5 px-4 rounded-2xl bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white text-xs font-bold shadow-lg shadow-blue-600/25 transition cursor-pointer"
|
|
>
|
|
<PenSquare className="w-4 h-4" />
|
|
<span>New Message</span>
|
|
</button>
|
|
|
|
{/* Folder Navigation */}
|
|
<nav className="space-y-1">
|
|
{/* Inbox */}
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setActiveFolder('inbox');
|
|
setSelectedDraft(null);
|
|
}}
|
|
className={`w-full flex items-center justify-between px-3 py-2 rounded-xl text-xs font-medium transition cursor-pointer ${
|
|
activeFolder === 'inbox'
|
|
? 'bg-blue-600/20 text-white border border-blue-500/30'
|
|
: 'text-slate-300 hover:text-white hover:bg-slate-800/60'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2.5">
|
|
<Inbox className="w-4 h-4 text-blue-400" />
|
|
<span>Inbox</span>
|
|
</div>
|
|
{unreadCount > 0 ? (
|
|
<span className="text-[10px] font-bold text-white bg-blue-600 px-2 py-0.5 rounded-full shadow-xs">
|
|
{unreadCount}
|
|
</span>
|
|
) : (
|
|
<span className="text-[11px] text-slate-400 font-mono">
|
|
{allMailboxNotifications.length}
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
{/* Starred */}
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setActiveFolder('starred');
|
|
setSelectedDraft(null);
|
|
}}
|
|
className={`w-full flex items-center justify-between px-3 py-2 rounded-xl text-xs font-medium transition cursor-pointer ${
|
|
activeFolder === 'starred'
|
|
? 'bg-amber-500/20 text-white border border-amber-500/30'
|
|
: 'text-slate-300 hover:text-white hover:bg-slate-800/60'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2.5">
|
|
<Star className="w-4 h-4 text-amber-400" />
|
|
<span>Starred</span>
|
|
</div>
|
|
<span className="text-[11px] text-slate-400 font-mono">
|
|
{starredNotifications.length}
|
|
</span>
|
|
</button>
|
|
|
|
{/* Sent */}
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setActiveFolder('sent');
|
|
setSelectedDraft(null);
|
|
}}
|
|
className={`w-full flex items-center justify-between px-3 py-2 rounded-xl text-xs font-medium transition cursor-pointer ${
|
|
activeFolder === 'sent'
|
|
? 'bg-indigo-600/20 text-white border border-indigo-500/30'
|
|
: 'text-slate-300 hover:text-white hover:bg-slate-800/60'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2.5">
|
|
<Send className="w-4 h-4 text-indigo-400" />
|
|
<span>Sent</span>
|
|
</div>
|
|
<span className="text-[11px] text-slate-400 font-mono">
|
|
{sentNotifications.length}
|
|
</span>
|
|
</button>
|
|
|
|
{/* Drafts */}
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setActiveFolder('drafts');
|
|
setSelectedMessage(null);
|
|
}}
|
|
className={`w-full flex items-center justify-between px-3 py-2 rounded-xl text-xs font-medium transition cursor-pointer ${
|
|
activeFolder === 'drafts'
|
|
? 'bg-purple-600/20 text-white border border-purple-500/30'
|
|
: 'text-slate-300 hover:text-white hover:bg-slate-800/60'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2.5">
|
|
<FileText className="w-4 h-4 text-purple-400" />
|
|
<span>Drafts</span>
|
|
</div>
|
|
{drafts.length > 0 ? (
|
|
<span className="text-[10px] font-bold text-purple-300 bg-purple-500/20 border border-purple-500/30 px-2 py-0.5 rounded-full">
|
|
{drafts.length}
|
|
</span>
|
|
) : (
|
|
<span className="text-[11px] text-slate-400 font-mono">
|
|
0
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
{/* Trash */}
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setActiveFolder('trash');
|
|
setSelectedDraft(null);
|
|
}}
|
|
className={`w-full flex items-center justify-between px-3 py-2 rounded-xl text-xs font-medium transition cursor-pointer ${
|
|
activeFolder === 'trash'
|
|
? 'bg-rose-600/20 text-white border border-rose-500/30'
|
|
: 'text-slate-300 hover:text-white hover:bg-slate-800/60'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2.5">
|
|
<Trash2 className="w-4 h-4 text-rose-400" />
|
|
<span>Trash</span>
|
|
</div>
|
|
<span className="text-[11px] text-slate-400 font-mono">
|
|
{trashedNotifications.length}
|
|
</span>
|
|
</button>
|
|
</nav>
|
|
|
|
{/* Quick Filter: Unread Toggle */}
|
|
{activeFolder === 'inbox' && (
|
|
<div className="pt-2 border-t border-slate-800/80">
|
|
<label className="flex items-center gap-2 px-2.5 py-1.5 rounded-xl hover:bg-slate-800/50 cursor-pointer text-xs text-slate-300">
|
|
<input
|
|
type="checkbox"
|
|
checked={showUnreadOnly}
|
|
onChange={e => setShowUnreadOnly(e.target.checked)}
|
|
className="rounded border-slate-700 bg-slate-900 text-blue-600 focus:ring-blue-500"
|
|
/>
|
|
<span>Unread only</span>
|
|
</label>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Sidebar Footer Branding */}
|
|
<div className="p-3 rounded-2xl bg-slate-900/40 border border-slate-800/60 space-y-1 text-[11px] text-slate-400">
|
|
<div className="flex items-center gap-1.5 text-slate-300 font-semibold">
|
|
<span className="w-2 h-2 rounded-full bg-emerald-400" />
|
|
<span>In-App Notifications</span>
|
|
</div>
|
|
<p className="text-[10px] text-slate-400 leading-snug">
|
|
Messages are delivered directly to your In-App Mailbox and Notification Bell.
|
|
</p>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* ======================================================== */}
|
|
{/* PANE 2: Message List / Summary Cards */}
|
|
{/* ======================================================== */}
|
|
<section className="w-80 sm:w-96 shrink-0 border-r border-slate-800/80 bg-[#0d121f]/60 flex flex-col overflow-hidden">
|
|
{/* Search Box Header */}
|
|
<div className="p-3 border-b border-slate-800/80 bg-[#0d121f]/90 space-y-2">
|
|
<div className="relative">
|
|
<Search className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
|
<input
|
|
type="text"
|
|
value={searchTerm}
|
|
onChange={e => setSearchTerm(e.target.value)}
|
|
placeholder="Search messages by subject, sender, or text..."
|
|
className="w-full pl-8 pr-7 py-1.5 rounded-xl bg-slate-900 border border-slate-700/80 text-xs text-slate-100 placeholder-slate-400 focus:border-blue-500 focus:outline-none transition shadow-inner"
|
|
/>
|
|
{searchTerm && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setSearchTerm('')}
|
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
|
|
>
|
|
<X className="w-3.5 h-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between text-[11px] text-slate-400 px-1">
|
|
<span className="capitalize font-semibold text-slate-200">
|
|
{activeFolder} ({activeFolder === 'drafts' ? filteredDrafts.length : filteredMessages.length})
|
|
</span>
|
|
{activeFolder === 'inbox' && unreadCount > 0 && (
|
|
<span className="text-blue-400 font-medium">{unreadCount} unread</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* List of Messages or Drafts */}
|
|
<div className="flex-1 overflow-y-auto divide-y divide-slate-800/60">
|
|
{activeFolder === 'drafts' ? (
|
|
filteredDrafts.length === 0 ? (
|
|
<div className="py-16 px-6 text-center text-slate-400">
|
|
<FileText className="w-8 h-8 mx-auto text-slate-600 mb-2" />
|
|
<p className="text-xs font-semibold text-slate-300">No Drafts Saved</p>
|
|
<p className="text-[11px] text-slate-400 mt-1">
|
|
When you save unfinished messages, they will appear here.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
filteredDrafts.map(d => (
|
|
<div
|
|
key={d.id}
|
|
onClick={() => handleEditDraft(d)}
|
|
className="p-3.5 hover:bg-slate-800/60 transition cursor-pointer group flex flex-col gap-1.5 relative"
|
|
>
|
|
<div className="flex items-center justify-between gap-1">
|
|
<span className="text-[10px] font-bold text-purple-300 bg-purple-500/15 border border-purple-500/30 px-1.5 py-0.2 rounded">
|
|
Draft
|
|
</span>
|
|
<span className="text-[10px] text-slate-400 font-mono">
|
|
{formatDate(d.updatedAt)}
|
|
</span>
|
|
</div>
|
|
|
|
<h4 className="text-xs font-semibold text-white group-hover:text-purple-300 transition truncate">
|
|
{d.subject || 'Untitled Draft'}
|
|
</h4>
|
|
|
|
<p className="text-[11px] text-slate-300 line-clamp-2 leading-relaxed">
|
|
{stripMarkdown(d.message || 'No content written yet.')}
|
|
</p>
|
|
|
|
<div className="flex items-center justify-between pt-1">
|
|
<span className="text-[10px] text-slate-400 truncate">
|
|
To: {d.recipientEmail || 'No recipient selected'}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={e => {
|
|
e.stopPropagation();
|
|
deleteDraft(d.id);
|
|
}}
|
|
className="text-slate-400 hover:text-rose-400 p-1 rounded transition"
|
|
title="Delete draft"
|
|
>
|
|
<Trash2 className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))
|
|
)
|
|
) : filteredMessages.length === 0 ? (
|
|
<div className="py-16 px-6 text-center text-slate-400">
|
|
<MailOpen className="w-8 h-8 mx-auto text-slate-600 mb-2" />
|
|
<p className="text-xs font-semibold text-slate-300">No Messages Found</p>
|
|
<p className="text-[11px] text-slate-400 mt-1">
|
|
{searchTerm
|
|
? `No messages matched "${searchTerm}"`
|
|
: `No items in ${activeFolder}`}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
filteredMessages.map(item => {
|
|
const isSelected = selectedMessage?.id === item.id;
|
|
const isUnread = Number(item.status) !== NotificationStatus.Read;
|
|
const isStarred = starredIds.includes(String(item.id));
|
|
|
|
return (
|
|
<div
|
|
key={item.id}
|
|
onClick={() => handleSelectMessage(item)}
|
|
className={`p-3.5 transition cursor-pointer flex flex-col gap-1 relative ${
|
|
isSelected
|
|
? 'bg-blue-600/15 border-l-4 border-l-blue-500 text-white shadow-inner'
|
|
: 'hover:bg-slate-800/50 border-l-4 border-l-transparent'
|
|
}`}
|
|
>
|
|
{/* Top line: unread dot, platform badge, star, time */}
|
|
<div className="flex items-center justify-between gap-1">
|
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
{isUnread && (
|
|
<span
|
|
className="w-2 h-2 rounded-full bg-blue-500 shrink-0 shadow-sm shadow-blue-500 animate-pulse"
|
|
title="Unread notification"
|
|
/>
|
|
)}
|
|
<span className="text-[11px] font-semibold text-slate-300 truncate">
|
|
{item.destination?.includes('From:')
|
|
? item.destination.split('From:')[1]?.trim().replace(/[<>]/g, '')
|
|
: item.destination || 'PFM System'}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1.5 shrink-0">
|
|
<span className="text-[9px] font-bold text-blue-300 bg-blue-500/15 border border-blue-500/30 px-1.5 py-0.2 rounded font-mono">
|
|
In-App
|
|
</span>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={e => {
|
|
e.stopPropagation();
|
|
toggleStar(item.id);
|
|
}}
|
|
className={`p-0.5 rounded transition ${
|
|
isStarred ? 'text-amber-400 hover:text-amber-300' : 'text-slate-400 hover:text-slate-300'
|
|
}`}
|
|
title={isStarred ? 'Unstar message' : 'Star message'}
|
|
>
|
|
<Star className={`w-3.5 h-3.5 ${isStarred ? 'fill-amber-400' : ''}`} />
|
|
</button>
|
|
<span className="text-[10px] text-slate-400 font-mono">
|
|
{formatDate(item.createdAt)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Subject */}
|
|
<h4
|
|
className={`text-xs truncate ${
|
|
isUnread ? 'font-bold text-white' : 'font-medium text-slate-300'
|
|
}`}
|
|
>
|
|
{item.subject || 'Statutory Platform Notice'}
|
|
</h4>
|
|
|
|
{/* Snippet preview stripped of markdown syntax */}
|
|
<p className="text-[11px] text-slate-400 line-clamp-2 leading-relaxed">
|
|
{stripMarkdown(item.message || 'No additional details provided.')}
|
|
</p>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
{/* ======================================================== */}
|
|
{/* PANE 3: Full Message Detail & Interactive Actions */}
|
|
{/* ======================================================== */}
|
|
<main className="flex-1 bg-[#090d16] flex flex-col overflow-y-auto">
|
|
{activeFolder === 'drafts' ? (
|
|
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center text-slate-400">
|
|
<FileText className="w-12 h-12 text-slate-600 mb-3" />
|
|
<h3 className="text-sm font-bold text-white">Drafts Folder</h3>
|
|
<p className="text-xs text-slate-400 max-w-md mt-1 mb-4">
|
|
Click any draft on the left pane to resume composing and dispatch your notification.
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleOpenCompose()}
|
|
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold shadow-lg shadow-blue-600/30 transition cursor-pointer"
|
|
>
|
|
<PenSquare className="w-4 h-4" />
|
|
<span>Create New Message</span>
|
|
</button>
|
|
</div>
|
|
) : !selectedMessage ? (
|
|
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center text-slate-400">
|
|
<div className="w-16 h-16 rounded-3xl bg-slate-900 border border-slate-800 flex items-center justify-center text-slate-600 mb-4 shadow-xl">
|
|
<Mail className="w-8 h-8" />
|
|
</div>
|
|
<h3 className="text-sm font-bold text-white">No Message Selected</h3>
|
|
<p className="text-xs text-slate-400 max-w-sm mt-1 mb-4">
|
|
Select an in-app conversation or email notice from the list to view its contents and attachments.
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleOpenCompose()}
|
|
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-slate-900 hover:bg-slate-800 text-slate-200 border border-slate-700 text-xs font-semibold transition cursor-pointer"
|
|
>
|
|
<PenSquare className="w-3.5 h-3.5 text-blue-400" />
|
|
<span>Compose New Message</span>
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="flex-1 flex flex-col">
|
|
{/* Message Header Toolbar */}
|
|
<div className="p-5 border-b border-slate-800/80 bg-[#0d121f]/60 flex items-center justify-between shrink-0">
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleReplyMessage(selectedMessage)}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold shadow-md shadow-blue-600/20 transition cursor-pointer"
|
|
title="Reply to message"
|
|
>
|
|
<Reply className="w-3.5 h-3.5" />
|
|
<span>Reply</span>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleStar(selectedMessage.id)}
|
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl border text-xs font-medium transition cursor-pointer ${
|
|
starredIds.includes(String(selectedMessage.id))
|
|
? 'bg-amber-500/15 border-amber-500/30 text-amber-300'
|
|
: 'bg-slate-900/80 border-slate-700/80 text-slate-300 hover:text-white'
|
|
}`}
|
|
>
|
|
<Star
|
|
className={`w-3.5 h-3.5 ${
|
|
starredIds.includes(String(selectedMessage.id)) ? 'fill-amber-400' : ''
|
|
}`}
|
|
/>
|
|
<span>{starredIds.includes(String(selectedMessage.id)) ? 'Starred' : 'Star'}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{activeFolder === 'trash' ? (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={() => restoreMessage(selectedMessage.id)}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-slate-900/80 hover:bg-slate-800 border border-slate-700 text-xs text-slate-200 transition cursor-pointer"
|
|
title="Restore message to Inbox"
|
|
>
|
|
<RotateCcw className="w-3.5 h-3.5 text-emerald-400" />
|
|
<span>Restore</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => permanentDelete(selectedMessage.id)}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-rose-950/40 hover:bg-rose-900/40 border border-rose-800/60 text-xs text-rose-300 transition cursor-pointer"
|
|
title="Permanently erase message"
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5 text-rose-400" />
|
|
<span>Delete Forever</span>
|
|
</button>
|
|
</>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
deleteMessage(selectedMessage.id);
|
|
setSelectedMessage(null);
|
|
}}
|
|
className="p-2 rounded-xl text-slate-400 hover:text-rose-400 hover:bg-rose-950/20 border border-transparent hover:border-rose-900/40 transition cursor-pointer"
|
|
title="Move message to Trash"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Message Details & Body */}
|
|
<div className="p-6 space-y-6 flex-1 overflow-y-auto max-w-4xl">
|
|
{/* Subject Header */}
|
|
<div>
|
|
<h2 className="text-xl font-bold text-white tracking-tight leading-tight">
|
|
{selectedMessage.subject || 'In-App Notification'}
|
|
</h2>
|
|
<div className="flex flex-wrap items-center gap-2 mt-2">
|
|
<span className="text-[10px] font-semibold text-blue-300 bg-blue-500/15 border border-blue-500/30 px-2 py-0.5 rounded-md flex items-center gap-1">
|
|
<Smartphone className="w-3 h-3 text-blue-400" />
|
|
Channel: In-App
|
|
</span>
|
|
|
|
<span className="text-[10px] font-mono text-slate-400 bg-slate-900 px-2 py-0.5 rounded-md border border-slate-800">
|
|
ID: #{selectedMessage.id}
|
|
</span>
|
|
<span className="text-[10px] text-slate-400">
|
|
Received: {formatDate(selectedMessage.createdAt)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Sender & Recipient Card */}
|
|
<div className="p-4 rounded-2xl bg-slate-900/60 border border-slate-800 flex items-start justify-between gap-4">
|
|
<div className="flex items-start gap-3">
|
|
<div className="w-10 h-10 rounded-2xl bg-gradient-to-br from-blue-600 to-indigo-600 flex items-center justify-center font-bold text-xs text-white shadow-md shadow-blue-500/20 shrink-0">
|
|
{selectedMessage.destination?.includes('@')
|
|
? selectedMessage.destination.split('@')[0].slice(0, 2).toUpperCase()
|
|
: 'PF'}
|
|
</div>
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<p className="text-xs font-bold text-white">
|
|
{selectedMessage.destination?.includes('From:')
|
|
? selectedMessage.destination.split('From:')[1]?.trim().replace(/[<>]/g, '')
|
|
: selectedMessage.destination || 'PFM Automated Dispatcher'}
|
|
</p>
|
|
<span className="text-[10px] text-slate-400 font-mono">
|
|
(In-App Notification)
|
|
</span>
|
|
</div>
|
|
<p className="text-[11px] text-slate-400 mt-0.5">
|
|
To:{' '}
|
|
<strong className="text-slate-300">
|
|
{selectedMessage.destination?.includes('To:')
|
|
? selectedMessage.destination.split('To:')[1]?.split('|')[0]?.trim()
|
|
: currentUserEmail || 'You'}
|
|
</strong>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<span className="text-[10px] text-slate-400 font-mono shrink-0 flex items-center gap-1">
|
|
<Clock className="w-3 h-3" />
|
|
{formatDate(selectedMessage.createdAt)}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Fully-Rendered Markdown & HTML Message Body with Branding */}
|
|
<MarkdownViewer
|
|
content={selectedMessage.message || ''}
|
|
subject={selectedMessage.subject || ''}
|
|
showBrandingHeader={true}
|
|
/>
|
|
|
|
{/* Inline Quick Reply Section */}
|
|
<div className="pt-6 border-t border-slate-800 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<h4 className="text-xs font-bold text-white flex items-center gap-1.5">
|
|
<Reply className="w-3.5 h-3.5 text-blue-400" />
|
|
<span>Quick Reply</span>
|
|
</h4>
|
|
<span className="text-[10px] text-slate-400">
|
|
Send a quick response below or open the rich text composer
|
|
</span>
|
|
</div>
|
|
|
|
{inlineReplySuccess && (
|
|
<div className="p-3 rounded-xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-300 text-xs flex items-center gap-2">
|
|
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
|
<span>Reply dispatched successfully!</span>
|
|
</div>
|
|
)}
|
|
|
|
<textarea
|
|
rows={4}
|
|
value={inlineReplyText}
|
|
onChange={e => setInlineReplyText(e.target.value)}
|
|
placeholder="Type your reply..."
|
|
className="w-full p-3.5 rounded-2xl bg-slate-900/90 border border-slate-700/80 hover:border-slate-600 focus:border-blue-500 text-slate-100 placeholder-slate-400 text-xs focus:outline-none transition shadow-inner resize-y leading-relaxed"
|
|
/>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleReplyMessage(selectedMessage)}
|
|
className="text-xs text-blue-400 hover:text-blue-300 font-medium transition cursor-pointer"
|
|
>
|
|
Open rich compose editor with live preview
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
disabled={isSending || !inlineReplyText.trim()}
|
|
onClick={handleSendInlineReply}
|
|
className="flex items-center gap-2 px-5 py-2 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold shadow-md shadow-blue-600/30 transition disabled:opacity-40 cursor-pointer"
|
|
>
|
|
{isSending ? (
|
|
<div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
|
) : (
|
|
<Send className="w-3.5 h-3.5" />
|
|
)}
|
|
<span>Send Reply</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</main>
|
|
</div>
|
|
|
|
{/* Compose Message Modal */}
|
|
<ComposeMessageModal
|
|
isOpen={isComposeOpen}
|
|
onClose={() => setIsComposeOpen(false)}
|
|
initialRecipient={composeInitialRecipient}
|
|
initialSubject={composeInitialSubject}
|
|
initialMessage={composeInitialMessage}
|
|
initialPlatform={composeInitialPlatform}
|
|
draftToEdit={selectedDraft}
|
|
/>
|
|
|
|
{/* Self-Reply Prevention Modal */}
|
|
{isSelfReplyModalOpen && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm animate-in fade-in duration-150">
|
|
<div className="w-full max-w-md bg-[#0d1322] border border-rose-500/30 rounded-2xl shadow-2xl overflow-hidden p-6 space-y-4">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-xl bg-rose-500/15 border border-rose-500/30 flex items-center justify-center text-rose-400 shrink-0">
|
|
<ShieldAlert className="w-5 h-5" />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-sm font-bold text-white">Action Not Allowed</h3>
|
|
<p className="text-[11px] text-rose-300 font-medium">Self-Reply Restricted</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsSelfReplyModalOpen(false)}
|
|
className="p-1 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<p className="text-xs text-slate-300 leading-relaxed">
|
|
You cannot reply to a notification that originated from your own account. Please compose a new message and select another recipient from the directory if you wish to follow up with a colleague.
|
|
</p>
|
|
|
|
<div className="pt-2 border-t border-slate-800 flex justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsSelfReplyModalOpen(false)}
|
|
className="px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold shadow-md shadow-blue-600/25 transition cursor-pointer"
|
|
>
|
|
Understood
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|