email: charset decoding, inline attachments, UI fixes
- respect charset from Content-Type when decoding body and headers - detect inline attachments with filenames (not just disposition: attachment) - fix broken decodeQuotedPrintable reference in parseAttachments - white background for email iframe (emails designed for light bg) - sticky header in email list, scrollable message area - arrow key navigation between emails with scroll-into-view - /email/:id routing for deep linking to specific emails - empty folder shows "No emails in this folder" instead of losing header - fix attachment viewer header overlap with dialog close button Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -60,6 +60,7 @@ export function App() {
|
||||
<Route path="/projects/new" element={<Dashboard.NewProjectRedirect />} />
|
||||
<Route path="/projects/:id" element={<Dashboard.ProjectScreen />} />
|
||||
<Route path="/email" element={<Dashboard.EmailScreen />} />
|
||||
<Route path="/email/:emailId" element={<Dashboard.EmailScreen />} />
|
||||
<Route path="/browser" element={<Dashboard.BrowserScreen />} />
|
||||
<Route path="/terminal" element={<Dashboard.TerminalScreen />} />
|
||||
<Route path="/auth/signout" element={<Authentication.SignoutScreen />} />
|
||||
|
||||
@@ -96,6 +96,30 @@ export const EmailList = () => {
|
||||
const total = data?.total ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / LIMIT));
|
||||
|
||||
// Arrow key navigation
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (ev: KeyboardEvent) => {
|
||||
if (ev.key !== 'ArrowUp' && ev.key !== 'ArrowDown') return;
|
||||
if (messages.length === 0) return;
|
||||
ev.preventDefault();
|
||||
const currentIndex = selectedId ? messages.findIndex((m) => m.id === selectedId) : -1;
|
||||
let nextId: string | undefined;
|
||||
if (ev.key === 'ArrowDown') {
|
||||
const next = currentIndex + 1;
|
||||
if (next < messages.length) nextId = messages[next]!.id;
|
||||
} else {
|
||||
const prev = currentIndex - 1;
|
||||
if (prev >= 0) nextId = messages[prev]!.id;
|
||||
}
|
||||
if (nextId) {
|
||||
setSelectedId(nextId);
|
||||
document.querySelector(`[data-email-id="${nextId}"]`)?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [messages, selectedId]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm opacity-50">
|
||||
@@ -104,7 +128,9 @@ export const EmailList = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (messages.length === 0 && page === 1) {
|
||||
// Show onboarding empty state only when no emails exist at all
|
||||
const hasNoEmails = total === 0 && folder === 'inbox' && !allCount?.total;
|
||||
if (hasNoEmails && !isLoading && page === 1) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-sm opacity-50">
|
||||
<Mail className="h-8 w-8" />
|
||||
@@ -131,8 +157,8 @@ export const EmailList = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto">
|
||||
<div className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex shrink-0 items-center gap-2 border-b px-3 py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{FOLDERS.map((f) => {
|
||||
const Icon = f.icon;
|
||||
@@ -187,12 +213,16 @@ export const EmailList = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col divide-y divide-white/10">
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex flex-1 items-center justify-center text-sm opacity-40">No emails in this folder</div>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col divide-y divide-white/10 overflow-y-auto">
|
||||
{messages.map((msg: EmailSummary) => (
|
||||
<button
|
||||
key={msg.id}
|
||||
data-email-id={msg.id}
|
||||
onClick={() => setSelectedId(msg.id)}
|
||||
className={`flex flex-col gap-0.5 px-3 py-2.5 text-left transition-colors cursor-pointer ${
|
||||
className={`flex flex-col gap-0.5 px-3 py-2.5 text-left transition-colors cursor-pointer shrink-0 ${
|
||||
selectedId === msg.id ? 'bg-accent' : 'hover:bg-accent/50'
|
||||
}`}
|
||||
>
|
||||
@@ -213,6 +243,7 @@ export const EmailList = () => {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -27,8 +27,8 @@ const HtmlBody = ({ html }: { html: string }) => {
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; margin: 0; padding: 16px; color: #e0e0e0; background: transparent; }
|
||||
a { color: #60a5fa; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; margin: 0; padding: 16px; color: #1a1a1a; background: #ffffff; }
|
||||
a { color: #1a73e8; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
</style>
|
||||
</head>
|
||||
@@ -126,7 +126,7 @@ export const EmailReader = () => {
|
||||
fileName={openAttachment.fileName}
|
||||
root={openAttachment.root}
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-2 border-b px-4 py-1.5">
|
||||
<div className="flex shrink-0 items-center gap-2 border-b px-4 pr-12 py-1.5">
|
||||
<FileViewerHeader />
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { useMemo, useCallback, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import type { LayoutNode, PanelComponents } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
@@ -12,7 +13,23 @@ const PROMPT_PREFIX = `You are an email assistant. The user has a local SQLite e
|
||||
|
||||
export const EmailScreen = () => {
|
||||
const isMobile = useIsMobile();
|
||||
const { emailId } = useParams<{ emailId?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [selectedId, setSelectedId] = useGlobal<string | null>('EMAIL_SELECTED', null);
|
||||
|
||||
// Sync URL param → global state
|
||||
useEffect(() => {
|
||||
if (emailId && emailId !== selectedId) setSelectedId(emailId);
|
||||
}, [emailId]);
|
||||
|
||||
// Sync global state → URL
|
||||
useEffect(() => {
|
||||
if (selectedId && selectedId !== emailId) {
|
||||
navigate(`/email/${selectedId}`, { replace: true });
|
||||
} else if (!selectedId && emailId) {
|
||||
navigate('/email', { replace: true });
|
||||
}
|
||||
}, [selectedId]);
|
||||
const workspace = useDashboardState<LayoutNode>('screens/email', defaultLayout);
|
||||
|
||||
const components: PanelComponents = useMemo(
|
||||
|
||||
Reference in New Issue
Block a user