'use client'; /** * The contact form inbox at /admin/messages (#702). Messages live in * content/messages/ (lib/contact.ts); replying happens in the owner's own * mail client through a mailto: link, since Folio sends no mail. */ import { useState } from 'react'; import Link from 'next/link'; import * as Icons from './Icons'; import { reportIfSessionExpired } from './sessionExpiry'; import { useAdminFetch } from './useAdminFetch'; import AdminLoadState from './AdminLoadState'; import type { ContactMessage } from '@/lib/contact'; import { useNotify } from './Notifications'; import PageHeader from './PageHeader'; import { useConfirm } from './ConfirmDialog'; interface InboxData { enabled: boolean; notifyConfigured: boolean; retentionDays: number; messages: ContactMessage[]; } function replyHref(m: ContactMessage): string { const subject = encodeURIComponent('Re: your message'); const quoted = m.message .split('\n') .map((line) => `> ${line}`) .join('\n'); const body = encodeURIComponent(`\n\n${m.name} wrote:\n${quoted}`); // Encoded so a ? or & in the address cannot start the query, with the @ // put back: not every mail client decodes %40 in the address part. const to = encodeURIComponent(m.email).replace(/%40/g, '@'); return `mailto:${to}?subject=${subject}&body=${body}`; } export default function MessagesView() { const confirm = useConfirm(); const notify = useNotify(); const inbox = useAdminFetch('/api/admin/messages'); const data = inbox.data; const [open, setOpen] = useState(null); async function markRead(m: ContactMessage, read: boolean) { const res = await fetch('/api/admin/messages', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: m.id, read }), }); if (!res.ok) { reportIfSessionExpired(res); return; } inbox.mutate((d) => ({ ...d, messages: d.messages.map((x) => (x.id === m.id ? { ...x, read } : x)), })); } function toggle(m: ContactMessage) { const opening = open !== m.id; setOpen(opening ? m.id : null); if (opening && !m.read) markRead(m, true); } async function remove(m: ContactMessage) { const ok = await confirm({ title: `Delete the message from ${m.name}?`, message: 'It is removed from this server and cannot be restored.', confirmLabel: 'Delete', danger: true, }); if (!ok) return; const res = await fetch(`/api/admin/messages?id=${encodeURIComponent(m.id)}`, { method: 'DELETE', }); if (!res.ok) { if (!reportIfSessionExpired(res)) notify('error', `Delete failed (HTTP ${res.status}).`); return; } inbox.mutate((d) => ({ ...d, messages: d.messages.filter((x) => x.id !== m.id) })); } if (!data || inbox.error) { return ( ); } const unread = data.messages.filter((m) => !m.read).length; return (
Messages {unread > 0 && {unread} unread} } description={`Sent through the contact form at /contact. Stored on this server and deleted after ${data.retentionDays} days.`} actions={ } /> {!data.enabled && (
The contact form is switched off, so no new messages arrive.{' '} Turn it on in Settings → Legal.
)} {data.enabled && !data.notifyConfigured && (
No notification is set up, so new messages only show up here.{' '} Add an ntfy URL in Settings → Legal.
)} {data.messages.length === 0 ? (
No messages.
) : (
    {data.messages.map((m) => { const expanded = open === m.id; return (
  • {expanded && (

    {m.name} <{m.email}>

    {m.message}

    Reply by email
    )}
  • ); })}
)}
); }