Files
mail-client/src/components/ReadingPane.tsx
T
KanjiG 20bfd2cf6b Initial commit: Outlook-competitor desktop mail client
Tauri v2 (Rust backend, React/TypeScript frontend). IMAP/POP3/SMTP core,
Google OAuth (mail/calendar/contacts sync), CalDAV, local calendar with
recurrence, rules engine, contacts, tasks, unified inbox, context menus,
drag & drop, snooze, templates, and more.
2026-07-28 20:56:05 +02:00

307 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from 'react'
import { save } from '@tauri-apps/plugin-dialog'
import {
CalendarCheck,
CalendarClock,
CalendarPlus,
CalendarX,
CheckSquare,
Download,
ListPlus,
Mail,
Paperclip,
} from 'lucide-react'
import type { AttachmentMeta, InviteInfo, InviteResponse, MessageFull } from '../types'
import { Avatar } from './Avatar'
import { api } from '../lib/api'
import { formatBytes, formatFullDate } from '../lib/format'
import { AttachmentPreviewModal, isPreviewable } from './AttachmentPreviewModal'
function isIcsAttachment(att: AttachmentMeta) {
return att.mime_type.toLowerCase() === 'text/calendar' || att.filename.toLowerCase().endsWith('.ics')
}
function InviteBanner({
info,
onRespond,
}: {
info: InviteInfo
onRespond: (response: InviteResponse) => Promise<void>
}) {
const [responding, setResponding] = useState<InviteResponse | null>(null)
const [responded, setResponded] = useState<InviteResponse | null>(null)
async function respond(r: InviteResponse) {
setResponding(r)
try {
await onRespond(r)
setResponded(r)
} finally {
setResponding(null)
}
}
const dateLabel = `${new Date(info.start_ts * 1000).toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'short' })}${
info.all_day ? '' : `, ${new Date(info.start_ts * 1000).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} ${new Date(info.end_ts * 1000).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`
}`
return (
<div className="border-b border-indigo-100 bg-indigo-50/60 px-7 py-3.5 dark:border-indigo-950 dark:bg-indigo-950/30">
<p className="text-[12.5px] font-medium text-indigo-800 dark:text-indigo-200">
Termineinladung: {info.summary}
</p>
<p className="text-[11.5px] text-indigo-500 dark:text-indigo-400">
{dateLabel}
{info.organizer_email && ` · von ${info.organizer_name || info.organizer_email}`}
</p>
{responded ? (
<p className="mt-2 text-[12px] font-medium text-indigo-700 dark:text-indigo-300">
{responded === 'accepted' && 'Angenommen — Antwort wurde gesendet.'}
{responded === 'declined' && 'Abgelehnt — Antwort wurde gesendet.'}
{responded === 'tentative' && 'Mit Vorbehalt beantwortet — Antwort wurde gesendet.'}
</p>
) : (
<div className="mt-2 flex gap-2">
<button
onClick={() => respond('accepted')}
disabled={responding !== null}
className="flex items-center gap-1.5 rounded-full bg-green-600 px-3 py-1.5 text-[12px] font-medium text-white shadow-sm transition hover:bg-green-500 disabled:opacity-60"
>
<CalendarCheck size={13} /> {responding === 'accepted' ? 'Sende…' : 'Annehmen'}
</button>
<button
onClick={() => respond('tentative')}
disabled={responding !== null}
className="flex items-center gap-1.5 rounded-full bg-amber-500 px-3 py-1.5 text-[12px] font-medium text-white shadow-sm transition hover:bg-amber-400 disabled:opacity-60"
>
<CalendarClock size={13} /> {responding === 'tentative' ? 'Sende…' : 'Vorbehalt'}
</button>
<button
onClick={() => respond('declined')}
disabled={responding !== null}
className="flex items-center gap-1.5 rounded-full bg-zinc-500 px-3 py-1.5 text-[12px] font-medium text-white shadow-sm transition hover:bg-zinc-400 disabled:opacity-60"
>
<CalendarX size={13} /> {responding === 'declined' ? 'Sende…' : 'Ablehnen'}
</button>
</div>
)}
</div>
)
}
function downloadAttachment(filename: string, mimeType: string, base64: string) {
const byteChars = atob(base64)
const bytes = new Uint8Array(byteChars.length)
for (let i = 0; i < byteChars.length; i++) bytes[i] = byteChars.charCodeAt(i)
const blob = new Blob([bytes], { type: mimeType || 'application/octet-stream' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
export function ReadingPane({
message,
loading,
selectionCount = 0,
onImportIcsAttachment,
onCreateTask,
onReplyToInvite,
}: {
message: MessageFull | null
loading: boolean
selectionCount?: number
onImportIcsAttachment?: (base64: string) => Promise<void>
onCreateTask?: (message: MessageFull) => void
onReplyToInvite?: (accountId: string, icsBase64: string, response: InviteResponse) => Promise<void>
}) {
const [importingIcs, setImportingIcs] = useState<number | null>(null)
const [previewAttachment, setPreviewAttachment] = useState<AttachmentMeta | null>(null)
const [invite, setInvite] = useState<{ base64: string; info: InviteInfo } | null>(null)
useEffect(() => {
setInvite(null)
if (!message || !onReplyToInvite) return
const icsAtt = message.attachments.find(isIcsAttachment)
if (!icsAtt) return
let cancelled = false
api
.previewInvite(icsAtt.content_base64)
.then((info) => {
if (!cancelled && info) setInvite({ base64: icsAtt.content_base64, info })
})
.catch(() => {})
return () => {
cancelled = true
}
}, [message, onReplyToInvite])
const srcDoc = useMemo(() => {
if (!message) return ''
if (message.body_html) {
return `<!doctype html><html><head><meta charset="utf-8"><base target="_blank"><style>
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:14px;line-height:1.55;color:#18181b;margin:0;padding:0;word-wrap:break-word;}
img{max-width:100%;height:auto;}
a{color:#4f46e5;}
@media (prefers-color-scheme: dark){ body{color:#e4e4e7;} a{color:#a5b4fc;} }
</style></head><body>${message.body_html}</body></html>`
}
const escaped = (message.body_text || '').replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[c]!)
return `<!doctype html><html><head><meta charset="utf-8"><style>
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:14px;line-height:1.55;color:#18181b;margin:0;white-space:pre-wrap;word-wrap:break-word;}
@media (prefers-color-scheme: dark){ body{color:#e4e4e7;} }
</style></head><body>${escaped}</body></html>`
}, [message])
async function handleExportEml(m: MessageFull) {
const suggested = `${m.subject.replace(/[\\/:*?"<>|]/g, '_').slice(0, 60) || 'nachricht'}.eml`
const path = await save({ defaultPath: suggested, filters: [{ name: 'E-Mail', extensions: ['eml'] }] })
if (!path) return
await api.exportMessageEml(m.account_id, m.folder, m.uid, path)
}
if (loading) {
return (
<div className="flex flex-1 items-center justify-center text-sm text-zinc-400">
Lade Nachricht
</div>
)
}
if (selectionCount > 1) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-indigo-50 text-indigo-400 dark:bg-indigo-500/10 dark:text-indigo-300">
<CheckSquare size={26} strokeWidth={1.4} />
</div>
<p className="text-sm text-zinc-400">{selectionCount} Nachrichten ausgewählt</p>
</div>
)
}
if (!message) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-zinc-100 text-zinc-300 dark:bg-zinc-900 dark:text-zinc-700">
<Mail size={26} strokeWidth={1.4} />
</div>
<p className="text-sm text-zinc-400">Wähle eine Nachricht aus</p>
</div>
)
}
return (
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-start justify-between gap-4 border-b border-zinc-200/80 px-7 py-5 dark:border-zinc-800/80">
<div className="min-w-0">
<h1 className="truncate text-[18px] font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
{message.subject || '(kein Betreff)'}
</h1>
<div className="mt-2.5 flex items-center gap-3">
<Avatar name={message.from_name || message.from_addr} size={38} />
<div className="min-w-0">
<p className="truncate text-[13px] font-medium text-zinc-800 dark:text-zinc-200">
{message.from_name || message.from_addr}
<span className="ml-1.5 font-normal text-zinc-400">&lt;{message.from_addr}&gt;</span>
</p>
<p className="truncate text-[12px] text-zinc-400">
an {message.to || 'mich'} · {formatFullDate(message.date)}
</p>
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-1.5">
{onCreateTask && (
<button
onClick={() => onCreateTask(message)}
title="Als Aufgabe markieren"
className="rounded-full border border-zinc-200 p-1.5 text-zinc-500 shadow-sm transition hover:border-indigo-200 hover:bg-indigo-50 hover:text-indigo-700 dark:border-zinc-700 dark:text-zinc-400 dark:hover:border-indigo-800 dark:hover:bg-indigo-500/10"
>
<ListPlus size={14} />
</button>
)}
<button
onClick={() => handleExportEml(message)}
title="Als .eml exportieren"
className="rounded-full border border-zinc-200 p-1.5 text-zinc-500 shadow-sm transition hover:border-indigo-200 hover:bg-indigo-50 hover:text-indigo-700 dark:border-zinc-700 dark:text-zinc-400 dark:hover:border-indigo-800 dark:hover:bg-indigo-500/10"
>
<Download size={14} />
</button>
</div>
</div>
{invite && onReplyToInvite && (
<InviteBanner
info={invite.info}
onRespond={(response) => onReplyToInvite(message.account_id, invite.base64, response)}
/>
)}
{message.attachments.length > 0 && (
<div className="flex flex-wrap gap-2 border-b border-zinc-200/80 px-7 py-3.5 dark:border-zinc-800/80">
{message.attachments.map((att, i) => (
<div key={i} className="flex items-center gap-1">
<button
onClick={() =>
isPreviewable(att) ? setPreviewAttachment(att) : downloadAttachment(att.filename, att.mime_type, att.content_base64)
}
className="flex items-center gap-2 rounded-full border border-zinc-200 bg-zinc-50 px-3 py-1.5 text-[12px] text-zinc-600 shadow-sm transition hover:border-indigo-200 hover:bg-indigo-50 hover:text-indigo-700 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300"
>
<Paperclip size={12} />
<span className="max-w-40 truncate">{att.filename}</span>
<span className="text-zinc-400">{formatBytes(att.size)}</span>
{isPreviewable(att) ? (
<span
onClick={(e) => {
e.stopPropagation()
downloadAttachment(att.filename, att.mime_type, att.content_base64)
}}
title="Herunterladen"
className="text-zinc-400 hover:text-indigo-600"
>
<Download size={11} />
</span>
) : (
<Download size={11} className="text-zinc-400" />
)}
</button>
{isIcsAttachment(att) && onImportIcsAttachment && (
<button
onClick={async () => {
setImportingIcs(i)
try {
await onImportIcsAttachment(att.content_base64)
} finally {
setImportingIcs(null)
}
}}
disabled={importingIcs === i}
title="Zum Kalender hinzufügen"
className="flex items-center gap-1.5 rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1.5 text-[12px] font-medium text-indigo-700 shadow-sm transition hover:bg-indigo-100 disabled:opacity-60 dark:border-indigo-800 dark:bg-indigo-500/10 dark:text-indigo-300"
>
<CalendarPlus size={12} />
{importingIcs === i ? 'Füge hinzu…' : 'Zum Kalender'}
</button>
)}
</div>
))}
</div>
)}
<iframe
title="message-body"
srcDoc={srcDoc}
sandbox=""
className="w-full flex-1 bg-white dark:bg-zinc-950"
/>
<AttachmentPreviewModal
attachment={previewAttachment}
onClose={() => setPreviewAttachment(null)}
onDownload={(att) => downloadAttachment(att.filename, att.mime_type, att.content_base64)}
/>
</div>
)
}