Merge pull request 'Fixed notification composition' (#158) from finishline into main

Reviewed-on: #158
This commit was merged in pull request #158.
This commit is contained in:
2026-10-22 23:36:39 +02:00
9 changed files with 2949 additions and 358 deletions
@@ -9,12 +9,12 @@ import {
CheckCircle2, CheckCircle2,
Bold, Bold,
Italic, Italic,
Underline,
Heading2, Heading2,
List, List,
ListOrdered, ListOrdered,
Quote, Quote,
Table, Table,
Code,
Minus, Minus,
Sparkles, Sparkles,
Eye, Eye,
@@ -25,7 +25,13 @@ import {
import { UserSearchPicker, SelectedUser } from './UserSearchPicker'; import { UserSearchPicker, SelectedUser } from './UserSearchPicker';
import { useInAppNotifications, DraftMessage } from '../../hooks/useInAppNotifications'; import { useInAppNotifications, DraftMessage } from '../../hooks/useInAppNotifications';
import { NotificationPlatform } from '../../types'; import { NotificationPlatform } from '../../types';
import { renderMarkdownToHtml, parseMarkdownToHtml, PFM_LOGO_DATA_URI } from '../../lib/markdownUtils'; import {
renderMarkdownToHtml,
parseMarkdownToHtml,
isHtmlContent,
stripMarkdown,
PFM_LOGO_DATA_URI,
} from '../../lib/markdownUtils';
import { BrandLogo } from '../ui/BrandLogo'; import { BrandLogo } from '../ui/BrandLogo';
import { useAuth } from '../../auth/AuthProvider'; import { useAuth } from '../../auth/AuthProvider';
@@ -49,7 +55,10 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
draftToEdit, draftToEdit,
}) => { }) => {
const { user, claims } = useAuth(); const { user, claims } = useAuth();
const tenantName = (claims?.tenant as string) || user?.tenant || 'Department of Science, Innovation & Technology'; const tenantName =
(claims?.tenant as string) ||
user?.tenant ||
'Department of Science, Innovation & Technology';
const { sendMessage, isSending, saveDraft, deleteDraft } = useInAppNotifications(); const { sendMessage, isSending, saveDraft, deleteDraft } = useInAppNotifications();
const [selectedUsers, setSelectedUsers] = useState<SelectedUser[]>([]); const [selectedUsers, setSelectedUsers] = useState<SelectedUser[]>([]);
@@ -60,10 +69,19 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [successNotice, setSuccessNotice] = useState<string | null>(null); const [successNotice, setSuccessNotice] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null); const editorRef = useRef<HTMLDivElement>(null);
const toInitialRichHtml = (raw: string): string => {
if (!raw || !raw.trim()) return '';
if (isHtmlContent(raw)) return raw;
return parseMarkdownToHtml(raw);
};
// Initialize or populate from draft or props // Initialize or populate from draft or props
useEffect(() => { useEffect(() => {
if (!isOpen) return;
let initialHtml = '';
if (draftToEdit) { if (draftToEdit) {
setSelectedUsers( setSelectedUsers(
draftToEdit.recipientId draftToEdit.recipientId
@@ -77,23 +95,40 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
: [] : []
); );
setSubject(draftToEdit.subject || ''); setSubject(draftToEdit.subject || '');
setMessage(draftToEdit.message || ''); initialHtml = toInitialRichHtml(draftToEdit.message || '');
setPlatform(NotificationPlatform.InApp); setMessage(initialHtml);
setPlatform(draftToEdit.platform || NotificationPlatform.InApp);
} else { } else {
setSelectedUsers(initialRecipient ? [initialRecipient] : []); setSelectedUsers(initialRecipient ? [initialRecipient] : []);
setSubject(initialSubject || ''); setSubject(initialSubject || '');
setMessage(initialMessage || ''); initialHtml = toInitialRichHtml(initialMessage || '');
setMessage(initialHtml);
setPlatform(initialPlatform); setPlatform(initialPlatform);
} }
setActiveTab('write'); setActiveTab('write');
setError(null); setError(null);
setSuccessNotice(null); setSuccessNotice(null);
setTimeout(() => {
if (editorRef.current) {
editorRef.current.innerHTML = initialHtml;
}
}, 0);
}, [isOpen, initialRecipient, initialSubject, initialMessage, initialPlatform, draftToEdit]); }, [isOpen, initialRecipient, initialSubject, initialMessage, initialPlatform, draftToEdit]);
// Restore editor innerHTML when switching back from preview tab
useEffect(() => {
if (activeTab === 'write' && editorRef.current) {
if (editorRef.current.innerHTML !== message) {
editorRef.current.innerHTML = message;
}
}
}, [activeTab]);
if (!isOpen) return null; if (!isOpen) return null;
const handleSelectUser = (user: SelectedUser) => { const handleSelectUser = (selected: SelectedUser) => {
setSelectedUsers([user]); setSelectedUsers([selected]);
setError(null); setError(null);
}; };
@@ -101,51 +136,59 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
setSelectedUsers([]); setSelectedUsers([]);
}; };
// Helper to insert markdown text at cursor in textarea const syncEditorContent = () => {
const insertMarkdown = (before: string, after: string = '', defaultText: string = '') => { if (editorRef.current) {
const textarea = textareaRef.current; setMessage(editorRef.current.innerHTML);
if (!textarea) return; }
};
const start = textarea.selectionStart; const applyFormat = (command: string, value?: string) => {
const end = textarea.selectionEnd; const editor = editorRef.current;
const text = textarea.value; if (!editor) return;
const selection = text.substring(start, end) || defaultText; editor.focus();
document.execCommand(command, false, value);
syncEditorContent();
};
const replacement = `${before}${selection}${after}`; const insertHtmlBlock = (htmlSnippet: string) => {
const newText = text.substring(0, start) + replacement + text.substring(end); const editor = editorRef.current;
if (!editor) return;
setMessage(newText); editor.focus();
const inserted = document.execCommand('insertHTML', false, htmlSnippet);
setTimeout(() => { if (!inserted) {
textarea.focus(); editor.innerHTML += htmlSnippet;
textarea.setSelectionRange( }
start + before.length, syncEditorContent();
start + before.length + selection.length
);
}, 0);
}; };
// Quick insertions for PFM branding and templates // Quick insertions for PFM branding and templates
const insertBrandedCallout = () => { const insertBrandedCallout = () => {
insertMarkdown( insertHtmlBlock(
`> [!NOTE]\n> **Post-Fund Management Official Notice**\n> Please verify all supporting evidence documentation before tranche disbursement sign-off.\n\n` `<div style="background:rgba(12,74,110,0.25);border-left:4px solid #38bdf8;padding:10px 14px;border-radius:8px;margin:10px 0;color:#bae6fd;font-size:12px;"><strong>Post-Fund Management Official Notice</strong><br/>Please verify all supporting evidence documentation before tranche disbursement sign-off.</div><p><br/></p>`
); );
}; };
const insertMilestoneTable = () => { const insertMilestoneTable = () => {
insertMarkdown( insertHtmlBlock(
`| Deliverable Code | Milestone Name | Status | Due Date |\n| :--- | :--- | :--- | :--- |\n| DEL-01 | Technical Architecture Review | Verified | 2026-11-15 |\n| DEL-02 | Financial Expenditure Proof | Under Review | 2026-12-01 |\n\n` `<div style="overflow-x:auto;margin:12px 0;"><table style="width:100%;border-collapse:collapse;border:1px solid #334155;font-size:12px;text-align:left;"><thead><tr style="background:#1e293b;color:#f8fafc;"><th style="padding:8px 12px;border:1px solid #334155;">Deliverable Code</th><th style="padding:8px 12px;border:1px solid #334155;">Milestone Name</th><th style="padding:8px 12px;border:1px solid #334155;">Status</th><th style="padding:8px 12px;border:1px solid #334155;">Due Date</th></tr></thead><tbody><tr style="background:#0f172a;color:#cbd5e1;"><td style="padding:8px 12px;border:1px solid #334155;">DEL-01</td><td style="padding:8px 12px;border:1px solid #334155;">Technical Architecture Review</td><td style="padding:8px 12px;border:1px solid #334155;">Verified</td><td style="padding:8px 12px;border:1px solid #334155;">2026-11-15</td></tr><tr style="background:#0f172a;color:#cbd5e1;"><td style="padding:8px 12px;border:1px solid #334155;">DEL-02</td><td style="padding:8px 12px;border:1px solid #334155;">Financial Expenditure Proof</td><td style="padding:8px 12px;border:1px solid #334155;">Under Review</td><td style="padding:8px 12px;border:1px solid #334155;">2026-12-01</td></tr></tbody></table></div><p><br/></p>`
); );
}; };
const insertPlatformLogo = () => { const insertPlatformLogo = () => {
insertMarkdown( insertHtmlBlock(
`![PFM Platform Logo](${PFM_LOGO_DATA_URI})\n\n` `<p><img src="${PFM_LOGO_DATA_URI}" alt="PFM Platform Logo" width="48" height="48" style="display:inline-block;border-radius:10px;margin:6px 0;" /></p><p><br/></p>`
); );
}; };
const hasMeaningfulContent = (html: string) => {
if (!html) return false;
if (stripMarkdown(html).trim().length > 0) return true;
return /<(img|table|hr)\b/i.test(html);
};
const handleSaveDraft = () => { const handleSaveDraft = () => {
if (!subject.trim() && !message.trim()) { const currentHtml = editorRef.current ? editorRef.current.innerHTML : message;
if (!subject.trim() && !hasMeaningfulContent(currentHtml)) {
setError('Please add a subject or message to save as draft'); setError('Please add a subject or message to save as draft');
return; return;
} }
@@ -157,18 +200,21 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
recipientEmail: firstUser?.email || '', recipientEmail: firstUser?.email || '',
recipientName: firstUser?.name || '', recipientName: firstUser?.name || '',
subject: subject.trim() || 'Untitled Draft', subject: subject.trim() || 'Untitled Draft',
message: message.trim(), message: currentHtml.trim(),
platform,
}); });
setSuccessNotice('Draft saved successfully in Drafts folder'); setSuccessNotice('Draft saved successfully in Drafts folder');
setTimeout(() => { setTimeout(() => {
onClose(); onClose();
}, 800); }, 600);
}; };
const handleSend = async (e: React.FormEvent) => { const handleSend = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const currentHtml = editorRef.current ? editorRef.current.innerHTML : message;
if (selectedUsers.length === 0) { if (selectedUsers.length === 0) {
setError('Please select a recipient from the tenant directory'); setError('Please select a recipient from the tenant directory');
return; return;
@@ -179,7 +225,7 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
return; return;
} }
if (!message.trim()) { if (!hasMeaningfulContent(currentHtml)) {
setError('Please enter a message body'); setError('Please enter a message body');
return; return;
} }
@@ -188,18 +234,15 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
try { try {
const recipient = selectedUsers[0]; const recipient = selectedUsers[0];
// Format payload according to chosen platform:
// InApp messages are saved in markdown format.
// Email notifications are transformed from markdown to branded executive HTML before saving.
const finalMessage = const finalMessage =
platform === NotificationPlatform.Email platform === NotificationPlatform.Email
? renderMarkdownToHtml(message.trim(), { ? renderMarkdownToHtml(currentHtml.trim(), {
applyBranding: true, applyBranding: true,
emailFormat: true, emailFormat: true,
subject: subject.trim(), subject: subject.trim(),
tenantName, tenantName,
}) })
: message.trim(); : currentHtml.trim();
await sendMessage({ await sendMessage({
recipientId: recipient.id, recipientId: recipient.id,
@@ -207,13 +250,14 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
recipientName: recipient.name, recipientName: recipient.name,
subject: subject.trim(), subject: subject.trim(),
message: finalMessage, message: finalMessage,
platform,
draftId: draftToEdit?.id, draftId: draftToEdit?.id,
}); });
setSuccessNotice( setSuccessNotice(
platform === NotificationPlatform.Email platform === NotificationPlatform.Email
? 'Email notification rendered to HTML and dispatched!' ? 'Email notification dispatched!'
: 'In-App Markdown message sent successfully!' : 'In-App message sent successfully!'
); );
setTimeout(() => { setTimeout(() => {
onClose(); onClose();
@@ -239,7 +283,9 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
subject: subject || 'Notice Preview', subject: subject || 'Notice Preview',
tenantName, tenantName,
}) })
: parseMarkdownToHtml(message); : message;
const plainCharCount = stripMarkdown(message).length;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/85 backdrop-blur-md animate-in fade-in duration-150"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/85 backdrop-blur-md animate-in fade-in duration-150">
@@ -258,11 +304,11 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
: 'Compose New Communication'} : 'Compose New Communication'}
</h3> </h3>
<span className="text-[10px] font-semibold text-blue-300 bg-blue-500/15 border border-blue-500/30 px-2 py-0.5 rounded-full"> <span className="text-[10px] font-semibold text-blue-300 bg-blue-500/15 border border-blue-500/30 px-2 py-0.5 rounded-full">
Rich Markdown Engine Rich Text Composer
</span> </span>
</div> </div>
<p className="text-[11px] text-slate-400"> <p className="text-[11px] text-slate-400">
Author in Markdown with live preview, SVG branding, and dual InApp / Email delivery Type and format your message using the toolbar for In-App or branded Email delivery
</p> </p>
</div> </div>
</div> </div>
@@ -312,7 +358,7 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
}`} }`}
> >
<Smartphone className="w-3.5 h-3.5" /> <Smartphone className="w-3.5 h-3.5" />
<span>In-App (Markdown)</span> <span>In-App</span>
</button> </button>
<button <button
@@ -325,7 +371,7 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
}`} }`}
> >
<Mail className="w-3.5 h-3.5" /> <Mail className="w-3.5 h-3.5" />
<span>Email (HTML + Brand)</span> <span>Email</span>
</button> </button>
</div> </div>
</div> </div>
@@ -335,12 +381,12 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
{platform === NotificationPlatform.InApp ? ( {platform === NotificationPlatform.InApp ? (
<span className="text-blue-300 flex items-center gap-1.5"> <span className="text-blue-300 flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-blue-400 animate-pulse" /> <span className="w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
Saved directly as <strong>Markdown</strong>. Delivered instantly to the recipient's InApp Mailbox & Notification Bell. Delivered instantly to the recipient's <strong>In-App</strong> Mailbox & Notification Bell.
</span> </span>
) : ( ) : (
<span className="text-emerald-300 flex items-center gap-1.5"> <span className="text-emerald-300 flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" /> <span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
Composed in Markdown, transformed into <strong>Branded Executive HTML</strong> with embedded SVG logo before dispatching. Formatted with <strong>PFM Executive Branding</strong> and embedded logo before dispatching via email.
</span> </span>
)} )}
</div> </div>
@@ -373,14 +419,14 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
/> />
</div> </div>
{/* Markdown Editor / Preview Tabs */} {/* Rich Text Editor / Branded Preview Tabs */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label className="text-xs font-semibold text-slate-300"> <label className="text-xs font-semibold text-slate-300">
Message Content <span className="text-rose-400">*</span> Message Content <span className="text-rose-400">*</span>
</label> </label>
{/* Write vs Preview Tabs */} {/* Compose vs Email Preview Tabs */}
<div className="flex items-center rounded-lg bg-slate-900 p-0.5 border border-slate-700/80"> <div className="flex items-center rounded-lg bg-slate-900 p-0.5 border border-slate-700/80">
<button <button
type="button" type="button"
@@ -392,11 +438,14 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
}`} }`}
> >
<Edit3 className="w-3 h-3" /> <Edit3 className="w-3 h-3" />
<span>Write Markdown</span> <span>Compose</span>
</button> </button>
<button <button
type="button" type="button"
onClick={() => setActiveTab('preview')} onClick={() => {
syncEditorContent();
setActiveTab('preview');
}}
className={`flex items-center gap-1.5 px-3 py-1 rounded-md text-xs font-medium transition cursor-pointer ${ className={`flex items-center gap-1.5 px-3 py-1 rounded-md text-xs font-medium transition cursor-pointer ${
activeTab === 'preview' activeTab === 'preview'
? 'bg-blue-600 text-white font-semibold shadow-xs' ? 'bg-blue-600 text-white font-semibold shadow-xs'
@@ -404,37 +453,48 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
}`} }`}
> >
<Eye className="w-3 h-3" /> <Eye className="w-3 h-3" />
<span>Preview Rendered</span> <span>Preview Delivery</span>
</button> </button>
</div> </div>
</div> </div>
{/* Markdown Toolbar (Only when activeTab === 'write') */}
{activeTab === 'write' ? ( {activeTab === 'write' ? (
<div className="rounded-2xl border border-slate-700/80 bg-slate-900/90 overflow-hidden shadow-inner focus-within:border-blue-500 transition"> <div className="rounded-2xl border border-slate-700/80 bg-slate-900/90 overflow-hidden shadow-inner focus-within:border-blue-500 transition">
{/* Toolbar */} {/* WYSIWYG Formatting Toolbar */}
<div className="px-3 py-2 border-b border-slate-800 bg-slate-950/70 flex flex-wrap items-center gap-1"> <div className="px-3 py-2 border-b border-slate-800 bg-slate-950/70 flex flex-wrap items-center gap-1">
<button <button
type="button" type="button"
onClick={() => insertMarkdown('**', '**', 'bold text')} onMouseDown={e => e.preventDefault()}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer" onClick={() => applyFormat('bold')}
title="Bold (**text**)" className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Bold"
> >
<Bold className="w-3.5 h-3.5" /> <Bold className="w-3.5 h-3.5" />
</button> </button>
<button <button
type="button" type="button"
onClick={() => insertMarkdown('*', '*', 'italic text')} onMouseDown={e => e.preventDefault()}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer" onClick={() => applyFormat('italic')}
title="Italic (*text*)" className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Italic"
> >
<Italic className="w-3.5 h-3.5" /> <Italic className="w-3.5 h-3.5" />
</button> </button>
<button <button
type="button" type="button"
onClick={() => insertMarkdown('### ', '', 'Heading Title')} onMouseDown={e => e.preventDefault()}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer" onClick={() => applyFormat('underline')}
title="Heading (### )" className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Underline"
>
<Underline className="w-3.5 h-3.5" />
</button>
<button
type="button"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('formatBlock', 'H3')}
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Heading"
> >
<Heading2 className="w-3.5 h-3.5" /> <Heading2 className="w-3.5 h-3.5" />
</button> </button>
@@ -443,25 +503,28 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
<button <button
type="button" type="button"
onClick={() => insertMarkdown('- ', '', 'List item')} onMouseDown={e => e.preventDefault()}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer" onClick={() => applyFormat('insertUnorderedList')}
title="Bullet List (- )" className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Bullet List"
> >
<List className="w-3.5 h-3.5" /> <List className="w-3.5 h-3.5" />
</button> </button>
<button <button
type="button" type="button"
onClick={() => insertMarkdown('1. ', '', 'First item')} onMouseDown={e => e.preventDefault()}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer" onClick={() => applyFormat('insertOrderedList')}
title="Numbered List (1. )" className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Numbered List"
> >
<ListOrdered className="w-3.5 h-3.5" /> <ListOrdered className="w-3.5 h-3.5" />
</button> </button>
<button <button
type="button" type="button"
onClick={() => insertMarkdown('> ', '', 'Quoted text')} onMouseDown={e => e.preventDefault()}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer" onClick={() => applyFormat('formatBlock', 'BLOCKQUOTE')}
title="Blockquote (> )" className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Quote Block"
> >
<Quote className="w-3.5 h-3.5" /> <Quote className="w-3.5 h-3.5" />
</button> </button>
@@ -470,25 +533,19 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
<button <button
type="button" type="button"
onMouseDown={e => e.preventDefault()}
onClick={insertMilestoneTable} onClick={insertMilestoneTable}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer" className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Insert Deliverable Table" title="Insert Deliverable Table"
> >
<Table className="w-3.5 h-3.5" /> <Table className="w-3.5 h-3.5" />
</button> </button>
<button <button
type="button" type="button"
onClick={() => insertMarkdown('```\n', '\n```', 'code block')} onMouseDown={e => e.preventDefault()}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer" onClick={() => applyFormat('insertHorizontalRule')}
title="Code Block (```)" className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
> title="Insert Divider Line"
<Code className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => insertMarkdown('\n---\n')}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Horizontal Rule (---)"
> >
<Minus className="w-3.5 h-3.5" /> <Minus className="w-3.5 h-3.5" />
</button> </button>
@@ -498,6 +555,7 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
{/* Platform Brand Insertions */} {/* Platform Brand Insertions */}
<button <button
type="button" type="button"
onMouseDown={e => e.preventDefault()}
onClick={insertBrandedCallout} onClick={insertBrandedCallout}
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-blue-500/10 hover:bg-blue-500/20 text-blue-300 text-[11px] font-medium border border-blue-500/25 transition cursor-pointer" className="flex items-center gap-1 px-2 py-1 rounded-lg bg-blue-500/10 hover:bg-blue-500/20 text-blue-300 text-[11px] font-medium border border-blue-500/25 transition cursor-pointer"
title="Insert official PFM callout block" title="Insert official PFM callout block"
@@ -508,46 +566,54 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
<button <button
type="button" type="button"
onMouseDown={e => e.preventDefault()}
onClick={insertPlatformLogo} onClick={insertPlatformLogo}
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-300 text-[11px] font-medium border border-emerald-500/25 transition cursor-pointer" className="flex items-center gap-1 px-2 py-1 rounded-lg bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-300 text-[11px] font-medium border border-emerald-500/25 transition cursor-pointer"
title="Embed official SVG PFM logo into markdown" title="Embed official PFM logo"
> >
<BrandLogo size={14} showGlow={false} animateOnHover={false} /> <BrandLogo size={14} showGlow={false} animateOnHover={false} />
<span>Embed Logo</span> <span>Embed Logo</span>
</button> </button>
</div> </div>
{/* Textarea */} {/* Rich Text WYSIWYG Surface */}
<textarea <div className="relative">
ref={textareaRef} {!hasMeaningfulContent(message) && (
rows={8} <div className="pointer-events-none absolute top-4 left-4 right-4 text-slate-500 text-xs select-none">
value={message} Type your message here and highlight text or use the toolbar above to format headings, bullet points, tables, and official notices...
onChange={e => setMessage(e.target.value)} </div>
placeholder="Compose your message in Markdown... Use formatting buttons above for headings, bullet points, deliverable tables, and official PFM notices." )}
className="w-full p-4 bg-transparent text-slate-100 placeholder-slate-400 text-xs focus:outline-none transition resize-y leading-relaxed font-mono" <div
ref={editorRef}
contentEditable
suppressContentEditableWarning
onInput={syncEditorContent}
onBlur={syncEditorContent}
className="w-full min-h-[200px] max-h-[320px] overflow-y-auto p-4 bg-transparent text-slate-100 text-xs focus:outline-none leading-relaxed font-sans [&_h1]:text-lg [&_h1]:font-bold [&_h1]:text-white [&_h1]:my-2 [&_h2]:text-base [&_h2]:font-bold [&_h2]:text-white [&_h2]:my-2 [&_h3]:text-sm [&_h3]:font-bold [&_h3]:text-white [&_h3]:my-1.5 [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:my-1.5 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:my-1.5 [&_blockquote]:border-l-4 [&_blockquote]:border-blue-500 [&_blockquote]:pl-3 [&_blockquote]:italic [&_blockquote]:text-slate-300 [&_blockquote]:my-2 [&_hr]:border-slate-700 [&_hr]:my-3"
/> />
</div> </div>
</div>
) : ( ) : (
/* Live Fully-Rendered Markdown / HTML Preview */ /* Live Fully-Rendered Delivery Preview */
<div className="rounded-2xl border border-slate-700/80 bg-slate-950/80 p-5 overflow-y-auto max-h-72 min-h-56"> <div className="rounded-2xl border border-slate-700/80 bg-slate-950/80 p-5 overflow-y-auto max-h-72 min-h-56">
<div className="mb-3 pb-2 border-b border-slate-800 flex items-center justify-between text-[11px] text-slate-400"> <div className="mb-3 pb-2 border-b border-slate-800 flex items-center justify-between text-[11px] text-slate-400">
<span className="flex items-center gap-1.5 text-blue-300 font-semibold"> <span className="flex items-center gap-1.5 text-blue-300 font-semibold">
<ShieldCheck className="w-3.5 h-3.5 text-blue-400" /> <ShieldCheck className="w-3.5 h-3.5 text-blue-400" />
Live Rendered Preview ({platform === NotificationPlatform.Email ? 'HTML Email' : 'InApp Markdown'}) Delivery Preview ({platform === NotificationPlatform.Email ? 'Email' : 'In-App'})
</span> </span>
<span className="text-[10px] text-slate-400 font-mono"> <span className="text-[10px] text-slate-400 font-mono">
Subject: {subject || 'Untitled'} Subject: {subject || 'Untitled'}
</span> </span>
</div> </div>
{message.trim() ? ( {hasMeaningfulContent(message) ? (
<div <div
className="prose prose-invert max-w-none text-xs leading-relaxed" className="prose prose-invert max-w-none text-xs leading-relaxed [&_h1]:text-white [&_h2]:text-white [&_h3]:text-white [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_blockquote]:border-l-4 [&_blockquote]:border-blue-500 [&_blockquote]:pl-3 [&_blockquote]:italic"
dangerouslySetInnerHTML={{ __html: livePreviewHtml }} dangerouslySetInnerHTML={{ __html: livePreviewHtml }}
/> />
) : ( ) : (
<div className="py-12 text-center text-slate-400 text-xs italic"> <div className="py-12 text-center text-slate-400 text-xs italic">
Type your markdown message in the Write tab to see it live-rendered here. Type your message in the Compose tab to preview how it will appear upon delivery.
</div> </div>
)} )}
</div> </div>
@@ -560,7 +626,7 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
<span>Tenant Partition:</span> <span>Tenant Partition:</span>
<strong className="text-slate-300">{tenantName}</strong> <strong className="text-slate-300">{tenantName}</strong>
</span> </span>
<span className="font-mono">{message.length} characters</span> <span className="font-mono">{plainCharCount} characters</span>
</div> </div>
{/* Modal Footer Action Buttons */} {/* Modal Footer Action Buttons */}
@@ -608,7 +674,7 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
)} )}
<span> <span>
{platform === NotificationPlatform.Email {platform === NotificationPlatform.Email
? 'Dispatch Email (HTML)' ? 'Dispatch Email'
: 'Send In-App Message'} : 'Send In-App Message'}
</span> </span>
</button> </button>
@@ -1,14 +1,11 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { import {
Code,
Eye,
Copy, Copy,
Check, Check,
ShieldCheck, ShieldCheck,
ExternalLink,
} from 'lucide-react'; } from 'lucide-react';
import { BrandLogo } from '../ui/BrandLogo'; import { BrandLogo } from '../ui/BrandLogo';
import { parseMarkdownToHtml, isHtmlContent } from '../../lib/markdownUtils'; import { parseMarkdownToHtml, isHtmlContent, stripMarkdown } from '../../lib/markdownUtils';
interface MarkdownViewerProps { interface MarkdownViewerProps {
content: string; content: string;
@@ -19,11 +16,9 @@ interface MarkdownViewerProps {
export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
content, content,
subject,
showBrandingHeader = true, showBrandingHeader = true,
className = '', className = '',
}) => { }) => {
const [viewMode, setViewMode] = useState<'rendered' | 'raw'>('rendered');
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const isHtml = useMemo(() => isHtmlContent(content), [content]); const isHtml = useMemo(() => isHtmlContent(content), [content]);
@@ -35,7 +30,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
}, [content, isHtml]); }, [content, isHtml]);
const handleCopy = () => { const handleCopy = () => {
navigator.clipboard.writeText(content); navigator.clipboard.writeText(stripMarkdown(content) || content);
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
}; };
@@ -48,49 +43,21 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
{showBrandingHeader && ( {showBrandingHeader && (
<div className="flex items-center gap-1.5 text-slate-300 font-semibold pr-2 border-r border-slate-700/60"> <div className="flex items-center gap-1.5 text-slate-300 font-semibold pr-2 border-r border-slate-700/60">
<BrandLogo size={18} showGlow={false} animateOnHover={false} /> <BrandLogo size={18} showGlow={false} animateOnHover={false} />
<span className="text-[11px] tracking-tight">PFM Markdown Engine</span> <span className="text-[11px] tracking-tight">PFM Communications</span>
</div> </div>
)} )}
<span className="text-[10px] text-blue-400 font-mono"> <span className="text-[10px] text-blue-400 font-mono">
{isHtml ? 'Format: HTML Email' : 'Format: GitHub Flavored Markdown'} In-App Message
</span> </span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Toggle View Mode */}
<div className="flex items-center rounded-lg bg-slate-950/70 p-0.5 border border-slate-800">
<button
type="button"
onClick={() => setViewMode('rendered')}
className={`flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-medium transition cursor-pointer ${
viewMode === 'rendered'
? 'bg-blue-600/30 text-white font-semibold shadow-xs'
: 'text-slate-400 hover:text-white'
}`}
>
<Eye className="w-3 h-3" />
<span>Rendered</span>
</button>
<button
type="button"
onClick={() => setViewMode('raw')}
className={`flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-medium transition cursor-pointer ${
viewMode === 'raw'
? 'bg-blue-600/30 text-white font-semibold shadow-xs'
: 'text-slate-400 hover:text-white'
}`}
>
<Code className="w-3 h-3" />
<span>Raw Source</span>
</button>
</div>
{/* Copy Button */} {/* Copy Button */}
<button <button
type="button" type="button"
onClick={handleCopy} onClick={handleCopy}
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white text-[11px] font-medium transition cursor-pointer border border-slate-700/60" className="flex items-center gap-1 px-2 py-1 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white text-[11px] font-medium transition cursor-pointer border border-slate-700/60"
title="Copy content to clipboard" title="Copy message text to clipboard"
> >
{copied ? ( {copied ? (
<> <>
@@ -108,25 +75,12 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
</div> </div>
{/* Content Rendering */} {/* Content Rendering */}
{viewMode === 'rendered' ? (
<div className="p-6 overflow-x-auto text-sm leading-relaxed text-slate-200 font-sans"> <div className="p-6 overflow-x-auto text-sm leading-relaxed text-slate-200 font-sans">
{isHtml ? (
<div <div
className="prose prose-invert max-w-none" className="prose prose-invert max-w-none [&_h1]:text-white [&_h1]:text-xl [&_h1]:font-bold [&_h1]:mb-3 [&_h2]:text-white [&_h2]:text-lg [&_h2]:font-bold [&_h2]:mb-2 [&_h3]:text-white [&_h3]:text-base [&_h3]:font-bold [&_h3]:mb-2 [&_h4]:text-white [&_h4]:font-semibold [&_a]:text-blue-400 [&_a:hover]:underline [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:my-2 [&_table]:w-full [&_table]:border-collapse [&_th]:border [&_th]:border-slate-700 [&_th]:bg-slate-800/80 [&_th]:p-2 [&_td]:border [&_td]:border-slate-800 [&_td]:p-2 [&_blockquote]:border-l-4 [&_blockquote]:border-blue-500 [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:my-2 [&_code]:bg-slate-800 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-blue-300"
dangerouslySetInnerHTML={{ __html: renderedHtml }} dangerouslySetInnerHTML={{ __html: renderedHtml }}
/> />
) : (
<div
className="prose prose-invert max-w-none [&_h1]:text-white [&_h2]:text-white [&_h3]:text-white [&_h4]:text-white [&_a]:text-blue-400 [&_a:hover]:underline [&_table]:border-collapse [&_th]:border [&_th]:border-slate-700 [&_th]:p-2 [&_td]:border [&_td]:border-slate-800 [&_td]:p-2 [&_blockquote]:border-l-4 [&_blockquote]:border-blue-500 [&_blockquote]:pl-4 [&_blockquote]:italic [&_code]:bg-slate-800 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-blue-300"
dangerouslySetInnerHTML={{ __html: renderedHtml }}
/>
)}
</div> </div>
) : (
<pre className="p-5 overflow-x-auto font-mono text-xs text-slate-300 bg-[#070b13] select-all whitespace-pre-wrap leading-relaxed">
{content}
</pre>
)}
{/* Branded Footer Stamp */} {/* Branded Footer Stamp */}
{showBrandingHeader && ( {showBrandingHeader && (
@@ -136,7 +90,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
<span>Verified PFM Secure Platform Formatting</span> <span>Verified PFM Secure Platform Formatting</span>
</div> </div>
<span className="font-mono text-[10px] text-slate-400"> <span className="font-mono text-[10px] text-slate-400">
{content.length} characters {stripMarkdown(content).length} characters
</span> </span>
</div> </div>
)} )}
@@ -39,70 +39,73 @@ export function useInAppNotifications() {
const trashStorageKey = `pfm_mailbox_trash_${userId || 'default'}`; const trashStorageKey = `pfm_mailbox_trash_${userId || 'default'}`;
const sentStorageKey = `pfm_mailbox_sent_${userId || 'default'}`; const sentStorageKey = `pfm_mailbox_sent_${userId || 'default'}`;
// Starred messages set const readStorageList = useCallback(<T,>(key: string, fallbackKey?: string): T[] => {
const [starredIds, setStarredIds] = useState<string[]>(() => {
try { try {
const stored = localStorage.getItem(starredStorageKey); const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : []; const parsed: T[] = stored ? JSON.parse(stored) : [];
if (fallbackKey && fallbackKey !== key) {
const fallbackStored = localStorage.getItem(fallbackKey);
const fallbackParsed: T[] = fallbackStored ? JSON.parse(fallbackStored) : [];
if (fallbackParsed.length > 0) {
const merged = [...parsed];
for (const item of fallbackParsed) {
const itemId = (item as any)?.id ?? item;
if (!merged.some(m => ((m as any)?.id ?? m) === itemId)) {
merged.push(item);
}
}
localStorage.setItem(key, JSON.stringify(merged));
localStorage.removeItem(fallbackKey);
return merged;
}
}
return parsed;
} catch { } catch {
return []; return [];
} }
}); }, []);
// Starred messages set
const [starredIds, setStarredIds] = useState<string[]>(() =>
readStorageList<string>(starredStorageKey, 'pfm_mailbox_starred_default')
);
// Trashed messages set // Trashed messages set
const [trashedIds, setTrashedIds] = useState<string[]>(() => { const [trashedIds, setTrashedIds] = useState<string[]>(() =>
try { readStorageList<string>(trashStorageKey, 'pfm_mailbox_trash_default')
const stored = localStorage.getItem(trashStorageKey); );
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
});
// Local drafts list // Local drafts list
const [drafts, setDrafts] = useState<DraftMessage[]>(() => { const [drafts, setDrafts] = useState<DraftMessage[]>(() =>
try { readStorageList<DraftMessage>(draftsStorageKey, 'pfm_mailbox_drafts_default')
const stored = localStorage.getItem(draftsStorageKey); );
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
});
// Local sent messages history // Local sent messages history
const [localSent, setLocalSent] = useState<Notification[]>(() => { const [localSent, setLocalSent] = useState<Notification[]>(() =>
try { readStorageList<Notification>(sentStorageKey, 'pfm_mailbox_sent_default')
const stored = localStorage.getItem(sentStorageKey); );
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
});
// Sync state to localStorage // Synchronize across hook instances and userId changes
useEffect(() => { const syncFromStorage = useCallback(() => {
try { setStarredIds(readStorageList<string>(starredStorageKey, 'pfm_mailbox_starred_default'));
localStorage.setItem(starredStorageKey, JSON.stringify(starredIds)); setTrashedIds(readStorageList<string>(trashStorageKey, 'pfm_mailbox_trash_default'));
} catch {} setDrafts(readStorageList<DraftMessage>(draftsStorageKey, 'pfm_mailbox_drafts_default'));
}, [starredIds, starredStorageKey]); setLocalSent(readStorageList<Notification>(sentStorageKey, 'pfm_mailbox_sent_default'));
}, [readStorageList, starredStorageKey, trashStorageKey, draftsStorageKey, sentStorageKey]);
useEffect(() => { useEffect(() => {
try { syncFromStorage();
localStorage.setItem(trashStorageKey, JSON.stringify(trashedIds)); }, [syncFromStorage]);
} catch {}
}, [trashedIds, trashStorageKey]);
useEffect(() => { useEffect(() => {
try { const handleSync = () => syncFromStorage();
localStorage.setItem(draftsStorageKey, JSON.stringify(drafts)); window.addEventListener('pfm-mailbox-sync', handleSync);
} catch {} window.addEventListener('storage', handleSync);
}, [drafts, draftsStorageKey]); return () => {
window.removeEventListener('pfm-mailbox-sync', handleSync);
useEffect(() => { window.removeEventListener('storage', handleSync);
try { };
localStorage.setItem(sentStorageKey, JSON.stringify(localSent)); }, [syncFromStorage]);
} catch {}
}, [localSent, sentStorageKey]);
// Fetch notifications from backend // Fetch notifications from backend
const { data: rawNotifications = [], isLoading, refetch } = useQuery<Notification[]>({ const { data: rawNotifications = [], isLoading, refetch } = useQuery<Notification[]>({
@@ -131,23 +134,16 @@ export function useInAppNotifications() {
); );
}, [rawNotifications]); }, [rawNotifications]);
// Active inbox notifications (InApp + Email for mailbox, excluding trashed and drafts) // Active in-app notifications in inbox (excluding trashed and drafts)
const allMailboxNotifications = useMemo(() => {
return rawNotifications.filter(
n =>
(Number(n.platform) === NotificationPlatform.InApp || Number(n.platform) === NotificationPlatform.Email) &&
!trashedIds.includes(String(n.id)) &&
Number(n.status) !== NotificationStatus.Draft
);
}, [rawNotifications, trashedIds]);
// Active in-app notifications in inbox
const inboxNotifications = useMemo(() => { const inboxNotifications = useMemo(() => {
return inAppNotifications.filter( return inAppNotifications.filter(
n => !trashedIds.includes(String(n.id)) && Number(n.status) !== NotificationStatus.Draft n => !trashedIds.includes(String(n.id)) && Number(n.status) !== NotificationStatus.Draft
); );
}, [inAppNotifications, trashedIds]); }, [inAppNotifications, trashedIds]);
// Active mailbox notifications (always In-App notifications)
const allMailboxNotifications = inboxNotifications;
// Unread InApp notifications: status !== Read && status !== Draft && status !== Cancelled && status !== Failed // Unread InApp notifications: status !== Read && status !== Draft && status !== Cancelled && status !== Failed
const unreadNotifications = useMemo(() => { const unreadNotifications = useMemo(() => {
return inboxNotifications.filter( return inboxNotifications.filter(
@@ -161,20 +157,19 @@ export function useInAppNotifications() {
const unreadCount = unreadNotifications.length; const unreadCount = unreadNotifications.length;
const top5Unread = useMemo(() => unreadNotifications.slice(0, 5), [unreadNotifications]); const top5Unread = useMemo(() => unreadNotifications.slice(0, 5), [unreadNotifications]);
// Starred notifications // Starred notifications (In-App only)
const starredNotifications = useMemo(() => { const starredNotifications = useMemo(() => {
return rawNotifications.filter( return inAppNotifications.filter(
n => n =>
starredIds.includes(String(n.id)) && starredIds.includes(String(n.id)) &&
!trashedIds.includes(String(n.id)) && !trashedIds.includes(String(n.id))
(Number(n.platform) === NotificationPlatform.InApp || Number(n.platform) === NotificationPlatform.Email)
); );
}, [rawNotifications, starredIds, trashedIds]); }, [inAppNotifications, starredIds, trashedIds]);
// Trashed notifications // Trashed notifications (In-App only)
const trashedNotifications = useMemo(() => { const trashedNotifications = useMemo(() => {
return rawNotifications.filter(n => trashedIds.includes(String(n.id))); return inAppNotifications.filter(n => trashedIds.includes(String(n.id)));
}, [rawNotifications, trashedIds]); }, [inAppNotifications, trashedIds]);
// Mutation to mark notification as read // Mutation to mark notification as read
const markAsReadMutation = useMutation({ const markAsReadMutation = useMutation({
@@ -206,27 +201,61 @@ export function useInAppNotifications() {
// Toggle star // Toggle star
const toggleStar = useCallback((id: string | number) => { const toggleStar = useCallback((id: string | number) => {
const strId = String(id); const strId = String(id);
setStarredIds(prev => (prev.includes(strId) ? prev.filter(x => x !== strId) : [...prev, strId])); setStarredIds(prev => {
}, []); const next = prev.includes(strId) ? prev.filter(x => x !== strId) : [...prev, strId];
try {
localStorage.setItem(starredStorageKey, JSON.stringify(next));
window.dispatchEvent(new Event('pfm-mailbox-sync'));
} catch {}
return next;
});
}, [starredStorageKey]);
// Delete message (move to trash) // Delete message (move to trash)
const deleteMessage = useCallback((id: string | number) => { const deleteMessage = useCallback((id: string | number) => {
const strId = String(id); const strId = String(id);
setTrashedIds(prev => (prev.includes(strId) ? prev : [...prev, strId])); setTrashedIds(prev => {
}, []); const next = prev.includes(strId) ? prev : [...prev, strId];
try {
localStorage.setItem(trashStorageKey, JSON.stringify(next));
window.dispatchEvent(new Event('pfm-mailbox-sync'));
} catch {}
return next;
});
}, [trashStorageKey]);
// Restore from trash // Restore from trash
const restoreMessage = useCallback((id: string | number) => { const restoreMessage = useCallback((id: string | number) => {
const strId = String(id); const strId = String(id);
setTrashedIds(prev => prev.filter(x => x !== strId)); setTrashedIds(prev => {
}, []); const next = prev.filter(x => x !== strId);
try {
localStorage.setItem(trashStorageKey, JSON.stringify(next));
window.dispatchEvent(new Event('pfm-mailbox-sync'));
} catch {}
return next;
});
}, [trashStorageKey]);
// Permanent delete // Permanent delete
const permanentDelete = useCallback((id: string | number) => { const permanentDelete = useCallback((id: string | number) => {
const strId = String(id); const strId = String(id);
setTrashedIds(prev => prev.filter(x => x !== strId)); setTrashedIds(prev => {
setLocalSent(prev => prev.filter(n => String(n.id) !== strId)); const next = prev.filter(x => x !== strId);
}, []); try {
localStorage.setItem(trashStorageKey, JSON.stringify(next));
} catch {}
return next;
});
setLocalSent(prev => {
const next = prev.filter(n => String(n.id) !== strId);
try {
localStorage.setItem(sentStorageKey, JSON.stringify(next));
window.dispatchEvent(new Event('pfm-mailbox-sync'));
} catch {}
return next;
});
}, [trashStorageKey, sentStorageKey]);
// Draft management // Draft management
const saveDraft = useCallback((draft: Omit<DraftMessage, 'id' | 'updatedAt'> & { id?: string }) => { const saveDraft = useCallback((draft: Omit<DraftMessage, 'id' | 'updatedAt'> & { id?: string }) => {
@@ -237,15 +266,31 @@ export function useInAppNotifications() {
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
setDrafts(prev => { setDrafts(prev => {
const filtered = prev.filter(d => d.id !== draftId); const current = readStorageList<DraftMessage>(draftsStorageKey, 'pfm_mailbox_drafts_default');
return [newDraft, ...filtered]; const base = current.length >= prev.length ? current : prev;
const filtered = base.filter(d => d.id !== draftId);
const next = [newDraft, ...filtered];
try {
localStorage.setItem(draftsStorageKey, JSON.stringify(next));
window.dispatchEvent(new Event('pfm-mailbox-sync'));
} catch {}
return next;
}); });
return draftId; return draftId;
}, []); }, [draftsStorageKey, readStorageList]);
const deleteDraft = useCallback((id: string) => { const deleteDraft = useCallback((id: string) => {
setDrafts(prev => prev.filter(d => d.id !== id)); setDrafts(prev => {
}, []); const current = readStorageList<DraftMessage>(draftsStorageKey, 'pfm_mailbox_drafts_default');
const base = current.length >= prev.length ? current : prev;
const next = base.filter(d => d.id !== id);
try {
localStorage.setItem(draftsStorageKey, JSON.stringify(next));
window.dispatchEvent(new Event('pfm-mailbox-sync'));
} catch {}
return next;
});
}, [draftsStorageKey, readStorageList]);
// Send new notification (InApp or Email) // Send new notification (InApp or Email)
const sendMessageMutation = useMutation({ const sendMessageMutation = useMutation({
@@ -288,7 +333,14 @@ export function useInAppNotifications() {
sentAt: new Date().toISOString(), sentAt: new Date().toISOString(),
}; };
setLocalSent(prev => [sentItem, ...prev]); setLocalSent(prev => {
const next = [sentItem, ...prev];
try {
localStorage.setItem(sentStorageKey, JSON.stringify(next));
window.dispatchEvent(new Event('pfm-mailbox-sync'));
} catch {}
return next;
});
// If sending a draft, remove the draft // If sending a draft, remove the draft
if (payload.draftId) { if (payload.draftId) {
@@ -64,8 +64,13 @@ export function isHtmlContent(content: string): boolean {
trimmed.startsWith('<html') || trimmed.startsWith('<html') ||
trimmed.startsWith('<div') || trimmed.startsWith('<div') ||
trimmed.startsWith('<table') || trimmed.startsWith('<table') ||
trimmed.startsWith('<p>') || trimmed.startsWith('<p') ||
(trimmed.includes('</') && trimmed.includes('>')) trimmed.startsWith('<h') ||
trimmed.startsWith('<ul') ||
trimmed.startsWith('<ol') ||
trimmed.startsWith('<blockquote') ||
(trimmed.includes('</') && trimmed.includes('>')) ||
/<(strong|b|em|i|u|h[1-6]|ul|ol|li|table|blockquote|div|p|img|hr|br)\b/i.test(trimmed)
); );
} }
@@ -224,7 +229,14 @@ export function renderMarkdownToHtml(
markdown: string, markdown: string,
options?: MarkdownRenderOptions options?: MarkdownRenderOptions
): string { ): string {
const contentHtml = parseMarkdownToHtml(markdown); const trimmed = (markdown || '').trim();
if (trimmed.startsWith('<!DOCTYPE') || trimmed.startsWith('<html')) {
return trimmed;
}
const contentHtml = isHtmlContent(trimmed)
? trimmed.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
: parseMarkdownToHtml(trimmed);
if (!options?.applyBranding && !options?.emailFormat) { if (!options?.applyBranding && !options?.emailFormat) {
return contentHtml; return contentHtml;
@@ -17,7 +17,6 @@ import {
RotateCcw, RotateCcw,
X, X,
Smartphone, Smartphone,
Filter,
} from 'lucide-react'; } from 'lucide-react';
import { useInAppNotifications, DraftMessage } from '../hooks/useInAppNotifications'; import { useInAppNotifications, DraftMessage } from '../hooks/useInAppNotifications';
import { ComposeMessageModal } from '../components/notifications/ComposeMessageModal'; import { ComposeMessageModal } from '../components/notifications/ComposeMessageModal';
@@ -28,7 +27,6 @@ import { useAuth } from '../auth/AuthProvider';
import { stripMarkdown } from '../lib/markdownUtils'; import { stripMarkdown } from '../lib/markdownUtils';
type MailboxFolder = 'inbox' | 'starred' | 'sent' | 'drafts' | 'trash'; type MailboxFolder = 'inbox' | 'starred' | 'sent' | 'drafts' | 'trash';
type PlatformFilter = 'all' | 'inapp' | 'email';
export const InboxPage: React.FC = () => { export const InboxPage: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
@@ -60,8 +58,6 @@ export const InboxPage: React.FC = () => {
// Active folder // Active folder
const [activeFolder, setActiveFolder] = useState<MailboxFolder>('inbox'); const [activeFolder, setActiveFolder] = useState<MailboxFolder>('inbox');
// Platform filter
const [platformFilter, setPlatformFilter] = useState<PlatformFilter>('all');
// Search query // Search query
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
// Filter for unread only in Inbox // Filter for unread only in Inbox
@@ -82,11 +78,11 @@ export const InboxPage: React.FC = () => {
const [inlineReplyText, setInlineReplyText] = useState(''); const [inlineReplyText, setInlineReplyText] = useState('');
const [inlineReplySuccess, setInlineReplySuccess] = useState(false); const [inlineReplySuccess, setInlineReplySuccess] = useState(false);
// Active folder message items // Active folder message items (always In-App notifications)
const folderMessages = useMemo(() => { const folderMessages = useMemo(() => {
switch (activeFolder) { switch (activeFolder) {
case 'inbox': case 'inbox':
return allMailboxNotifications; return inboxNotifications;
case 'starred': case 'starred':
return starredNotifications; return starredNotifications;
case 'sent': case 'sent':
@@ -96,20 +92,13 @@ export const InboxPage: React.FC = () => {
case 'drafts': case 'drafts':
return []; return [];
default: default:
return allMailboxNotifications; return inboxNotifications;
} }
}, [activeFolder, allMailboxNotifications, starredNotifications, sentNotifications, trashedNotifications]); }, [activeFolder, inboxNotifications, starredNotifications, sentNotifications, trashedNotifications]);
// Filtered by search, platform, and unread toggle // Filtered by search and unread toggle
const filteredMessages = useMemo(() => { const filteredMessages = useMemo(() => {
return folderMessages.filter(item => { return folderMessages.filter(item => {
// Platform filter
if (platformFilter === 'inapp' && Number(item.platform) !== NotificationPlatform.InApp) {
return false;
}
if (platformFilter === 'email' && Number(item.platform) !== NotificationPlatform.Email) {
return false;
}
// Unread only filter // Unread only filter
if (activeFolder === 'inbox' && showUnreadOnly) { if (activeFolder === 'inbox' && showUnreadOnly) {
if (Number(item.status) === NotificationStatus.Read) return false; if (Number(item.status) === NotificationStatus.Read) return false;
@@ -122,7 +111,7 @@ export const InboxPage: React.FC = () => {
const dest = (item.destination || '').toLowerCase(); const dest = (item.destination || '').toLowerCase();
return subject.includes(term) || message.includes(term) || dest.includes(term); return subject.includes(term) || message.includes(term) || dest.includes(term);
}); });
}, [folderMessages, platformFilter, activeFolder, showUnreadOnly, searchTerm]); }, [folderMessages, activeFolder, showUnreadOnly, searchTerm]);
// Filtered drafts for Drafts folder // Filtered drafts for Drafts folder
const filteredDrafts = useMemo(() => { const filteredDrafts = useMemo(() => {
@@ -266,11 +255,11 @@ export const InboxPage: React.FC = () => {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h1 className="text-sm font-bold text-white tracking-tight">Mailbox & Communications</h1> <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"> <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">
Markdown & Branded HTML Engine In-App Messaging
</span> </span>
</div> </div>
<p className="text-[11px] text-slate-400"> <p className="text-[11px] text-slate-400">
In-App markdown messaging and executive branded email notifications In-App notifications and internal platform communications
</p> </p>
</div> </div>
</div> </div>
@@ -406,10 +395,14 @@ export const InboxPage: React.FC = () => {
<FileText className="w-4 h-4 text-purple-400" /> <FileText className="w-4 h-4 text-purple-400" />
<span>Drafts</span> <span>Drafts</span>
</div> </div>
{drafts.length > 0 && ( {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"> <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} {drafts.length}
</span> </span>
) : (
<span className="text-[11px] text-slate-400 font-mono">
0
</span>
)} )}
</button> </button>
@@ -436,57 +429,6 @@ export const InboxPage: React.FC = () => {
</button> </button>
</nav> </nav>
{/* Platform Filter Buttons */}
<div className="pt-3 border-t border-slate-800/80 space-y-2">
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-400 px-2 block">
Channel Filter
</span>
<div className="flex flex-col gap-1">
<button
type="button"
onClick={() => setPlatformFilter('all')}
className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded-lg text-xs font-medium transition cursor-pointer ${
platformFilter === 'all'
? 'bg-slate-800 text-white font-semibold'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900'
}`}
>
<div className="flex items-center gap-2">
<Filter className="w-3.5 h-3.5 text-blue-400" />
<span>All Channels</span>
</div>
</button>
<button
type="button"
onClick={() => setPlatformFilter('inapp')}
className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded-lg text-xs font-medium transition cursor-pointer ${
platformFilter === 'inapp'
? 'bg-blue-600/25 text-blue-200 font-semibold border border-blue-500/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900'
}`}
>
<div className="flex items-center gap-2">
<Smartphone className="w-3.5 h-3.5 text-blue-400" />
<span>In-App Markdown</span>
</div>
</button>
<button
type="button"
onClick={() => setPlatformFilter('email')}
className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded-lg text-xs font-medium transition cursor-pointer ${
platformFilter === 'email'
? 'bg-emerald-600/25 text-emerald-200 font-semibold border border-emerald-500/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900'
}`}
>
<div className="flex items-center gap-2">
<Mail className="w-3.5 h-3.5 text-emerald-400" />
<span>Email HTML</span>
</div>
</button>
</div>
</div>
{/* Quick Filter: Unread Toggle */} {/* Quick Filter: Unread Toggle */}
{activeFolder === 'inbox' && ( {activeFolder === 'inbox' && (
<div className="pt-2 border-t border-slate-800/80"> <div className="pt-2 border-t border-slate-800/80">
@@ -507,10 +449,10 @@ export const InboxPage: React.FC = () => {
<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="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"> <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 className="w-2 h-2 rounded-full bg-emerald-400" />
<span>Format Standards</span> <span>In-App Notifications</span>
</div> </div>
<p className="text-[10px] text-slate-400 leading-snug"> <p className="text-[10px] text-slate-400 leading-snug">
InApp messages are stored as Markdown. Email notifications are transformed to branded HTML. Messages are delivered directly to your In-App Mailbox and Notification Bell.
</p> </p>
</div> </div>
</aside> </aside>
@@ -571,7 +513,7 @@ export const InboxPage: React.FC = () => {
> >
<div className="flex items-center justify-between gap-1"> <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"> <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 (Markdown) Draft
</span> </span>
<span className="text-[10px] text-slate-400 font-mono"> <span className="text-[10px] text-slate-400 font-mono">
{formatDate(d.updatedAt)} {formatDate(d.updatedAt)}
@@ -620,7 +562,6 @@ export const InboxPage: React.FC = () => {
const isSelected = selectedMessage?.id === item.id; const isSelected = selectedMessage?.id === item.id;
const isUnread = Number(item.status) !== NotificationStatus.Read; const isUnread = Number(item.status) !== NotificationStatus.Read;
const isStarred = starredIds.includes(String(item.id)); const isStarred = starredIds.includes(String(item.id));
const isEmail = Number(item.platform) === NotificationPlatform.Email;
return ( return (
<div <div
@@ -649,15 +590,9 @@ export const InboxPage: React.FC = () => {
</div> </div>
<div className="flex items-center gap-1.5 shrink-0"> <div className="flex items-center gap-1.5 shrink-0">
{isEmail ? (
<span className="text-[9px] font-bold text-emerald-300 bg-emerald-500/15 border border-emerald-500/30 px-1.5 py-0.2 rounded font-mono">
Email
</span>
) : (
<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"> <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">
InApp In-App
</span> </span>
)}
<button <button
type="button" type="button"
@@ -707,7 +642,7 @@ export const InboxPage: React.FC = () => {
<FileText className="w-12 h-12 text-slate-600 mb-3" /> <FileText className="w-12 h-12 text-slate-600 mb-3" />
<h3 className="text-sm font-bold text-white">Drafts Folder</h3> <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"> <p className="text-xs text-slate-400 max-w-md mt-1 mb-4">
Click any draft on the left pane to resume composing in Markdown and dispatch your notification. Click any draft on the left pane to resume composing and dispatch your notification.
</p> </p>
<button <button
type="button" type="button"
@@ -725,7 +660,7 @@ export const InboxPage: React.FC = () => {
</div> </div>
<h3 className="text-sm font-bold text-white">No Message Selected</h3> <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"> <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 rendered markdown and attachments. Select an in-app conversation or email notice from the list to view its contents and attachments.
</p> </p>
<button <button
type="button" type="button"
@@ -815,17 +750,10 @@ export const InboxPage: React.FC = () => {
{selectedMessage.subject || 'In-App Notification'} {selectedMessage.subject || 'In-App Notification'}
</h2> </h2>
<div className="flex flex-wrap items-center gap-2 mt-2"> <div className="flex flex-wrap items-center gap-2 mt-2">
{Number(selectedMessage.platform) === NotificationPlatform.Email ? (
<span className="text-[10px] font-semibold text-emerald-300 bg-emerald-500/15 border border-emerald-500/30 px-2 py-0.5 rounded-md flex items-center gap-1">
<Mail className="w-3 h-3 text-emerald-400" />
Channel: Email (Branded HTML)
</span>
) : (
<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"> <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" /> <Smartphone className="w-3 h-3 text-blue-400" />
Channel: InApp (Markdown) Channel: In-App
</span> </span>
)}
<span className="text-[10px] font-mono text-slate-400 bg-slate-900 px-2 py-0.5 rounded-md border border-slate-800"> <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} ID: #{selectedMessage.id}
@@ -852,7 +780,7 @@ export const InboxPage: React.FC = () => {
: selectedMessage.destination || 'PFM Automated Dispatcher'} : selectedMessage.destination || 'PFM Automated Dispatcher'}
</p> </p>
<span className="text-[10px] text-slate-400 font-mono"> <span className="text-[10px] text-slate-400 font-mono">
({Number(selectedMessage.platform) === NotificationPlatform.Email ? 'SMTP Protocol' : 'InApp Protocol'}) (In-App Notification)
</span> </span>
</div> </div>
<p className="text-[11px] text-slate-400 mt-0.5"> <p className="text-[11px] text-slate-400 mt-0.5">
@@ -884,10 +812,10 @@ export const InboxPage: React.FC = () => {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="text-xs font-bold text-white flex items-center gap-1.5"> <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" /> <Reply className="w-3.5 h-3.5 text-blue-400" />
<span>Quick Markdown Reply</span> <span>Quick Reply</span>
</h4> </h4>
<span className="text-[10px] text-slate-400"> <span className="text-[10px] text-slate-400">
Supports Markdown formatting (e.g. **bold**, - lists, `code`) Send a quick response below or open the rich text composer
</span> </span>
</div> </div>
@@ -902,8 +830,8 @@ export const InboxPage: React.FC = () => {
rows={4} rows={4}
value={inlineReplyText} value={inlineReplyText}
onChange={e => setInlineReplyText(e.target.value)} onChange={e => setInlineReplyText(e.target.value)}
placeholder="Type your markdown reply... (e.g. **Approved** milestone deliverable proof)" 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 font-mono leading-relaxed" 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"> <div className="flex items-center justify-between">
@@ -27,5 +27,5 @@ public static class Constants
public const int ShortLabelLength = 100; public const int ShortLabelLength = 100;
public const int TextLength = 1024; public const int TextLength = 1024;
public const int MediumTextLength = 2048; public const int MediumTextLength = 2048;
public const int LargeTextLength = 4096; public const int LargeTextLength = 1_048_576; // 1 MB — accommodates branded HTML emails with embedded logo
} }
@@ -0,0 +1,60 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PostFundManagement.Infrastructure.Database.Migrations
{
/// <inheritdoc />
public partial class IncreasedLargeTextLengthTo1MB : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Findings",
table: "SiteVisits",
type: "character varying(1048576)",
maxLength: 1048576,
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(4096)",
oldMaxLength: 4096,
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "Message",
table: "Notifications",
type: "character varying(1048576)",
maxLength: 1048576,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(4096)",
oldMaxLength: 4096);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Findings",
table: "SiteVisits",
type: "character varying(4096)",
maxLength: 4096,
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(1048576)",
oldMaxLength: 1048576,
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "Message",
table: "Notifications",
type: "character varying(4096)",
maxLength: 4096,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(1048576)",
oldMaxLength: 1048576);
}
}
}
@@ -958,8 +958,8 @@ namespace PostFundManagement.Infrastructure.Database.Migrations
b.Property<string>("Message") b.Property<string>("Message")
.IsRequired() .IsRequired()
.HasMaxLength(4096) .HasMaxLength(1048576)
.HasColumnType("character varying(4096)"); .HasColumnType("character varying(1048576)");
b.Property<long>("OrganisationId") b.Property<long>("OrganisationId")
.HasColumnType("bigint"); .HasColumnType("bigint");
@@ -1405,8 +1405,8 @@ namespace PostFundManagement.Infrastructure.Database.Migrations
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<string>("Findings") b.Property<string>("Findings")
.HasMaxLength(4096) .HasMaxLength(1048576)
.HasColumnType("character varying(4096)"); .HasColumnType("character varying(1048576)");
b.Property<string>("InspectorName") b.Property<string>("InspectorName")
.IsRequired() .IsRequired()