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.
307 lines
13 KiB
TypeScript
307 lines
13 KiB
TypeScript
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) => ({ '&': '&', '<': '<', '>': '>' })[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"><{message.from_addr}></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>
|
||
)
|
||
}
|