feat: add unified mailbox across accounts and sidebar icons toggle

This commit is contained in:
Linus Rath
2026-04-14 17:36:13 +02:00
parent 7fcefa53c9
commit f22699fe20
28 changed files with 1889 additions and 649 deletions
+123 -10
View File
@@ -9,7 +9,9 @@ import { EmailComposer } from "@/components/email/email-composer";
import type { ComposerDraftData } from "@/components/email/email-composer";
import { ThreadConversationView } from "@/components/email/thread-conversation-view";
import { MobileHeader, MobileViewerHeader } from "@/components/layout/mobile-header";
import { ThreadGroup, Email } from "@/lib/jmap/types";
import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/lib/jmap/types";
import { useAccountStore } from "@/stores/account-store";
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { useEmailStore } from "@/stores/email-store";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
@@ -155,8 +157,54 @@ export default function Home() {
hasMoreEmails,
fetchTagCounts,
fetchEmailContent,
isUnifiedView,
fetchUnifiedEmails: fetchUnifiedEmailsAction,
refreshUnifiedCounts,
exitUnifiedView,
} = useEmailStore();
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
const accounts = useAccountStore((s) => s.accounts);
const connectedAccountsSignature = useMemo(
() => accounts.filter((a) => a.isConnected).map((a) => a.id).sort().join(","),
[accounts],
);
const buildUnifiedAccounts = useCallback((): UnifiedAccountClient[] => {
const connected = useAccountStore.getState().accounts.filter((a) => a.isConnected);
const clients = useAuthStore.getState().getAllConnectedClients();
const result: UnifiedAccountClient[] = [];
for (const account of connected) {
const accountClient = clients.get(account.id);
if (!accountClient) continue;
result.push({
accountId: account.id,
accountLabel: account.label || account.email,
client: accountClient,
mailboxes: [],
});
}
return result;
}, []);
const populateUnifiedAccountMailboxes = useCallback(
async (list: UnifiedAccountClient[]): Promise<UnifiedAccountClient[]> => {
const populated = await Promise.all(
list.map(async (entry) => {
try {
const mailboxes = await entry.client.getMailboxes();
return { ...entry, mailboxes };
} catch (err) {
debug.error('Failed to load mailboxes for unified account', entry.accountId, err);
return entry;
}
}),
);
return populated;
},
[],
);
// Browser back / forward integration. The restore handler reads the
// latest values from a ref so we don't have to recreate the callback on
// every render (and so the popstate listener is never stale).
@@ -485,13 +533,30 @@ export default function Home() {
};
}, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, handleStateChange, setPushConnected]);
// Keep unified mailbox counts in sync when the feature is enabled and more
// than one account is connected. Runs whenever the set of connected accounts
// or the primary account's mailboxes change (a proxy for "something worth
// recounting happened").
useEffect(() => {
if (!enableUnifiedMailbox || !isAuthenticated || !client) return;
const built = buildUnifiedAccounts();
if (built.length < 2) return;
populateUnifiedAccountMailboxes(built).then((populated) => {
refreshUnifiedCounts(populated);
});
}, [enableUnifiedMailbox, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildUnifiedAccounts, populateUnifiedAccountMailboxes, refreshUnifiedCounts]);
// Auto-fetch full email content when an email is auto-selected (e.g. after delete/archive)
useEffect(() => {
if (!selectedEmail || !client) return;
// If the email lacks bodyValues, it was auto-selected from the list and needs full content
if (!selectedEmail.bodyValues) {
const perAccountClient = isUnifiedView && selectedEmail.accountId
? useAuthStore.getState().getClientForAccount(selectedEmail.accountId)
: undefined;
const fetchClient = perAccountClient ?? client;
setLoadingEmail(true);
fetchEmailContent(client, selectedEmail.id).finally(() => {
fetchEmailContent(fetchClient, selectedEmail.id).finally(() => {
setLoadingEmail(false);
});
}
@@ -886,6 +951,32 @@ export default function Home() {
};
const handleMailboxSelect = async (mailboxId: string) => {
if (isUnifiedMailboxId(mailboxId)) {
const role = UNIFIED_ROLE_BY_ID[mailboxId];
if (!role) return;
selectMailbox(mailboxId);
selectEmail(null);
if (isMobile) {
setSidebarOpen(false);
setActiveView("list");
}
if (isTablet) {
setTabletListVisible(true);
}
const built = buildUnifiedAccounts();
const populated = await populateUnifiedAccountMailboxes(built);
await fetchUnifiedEmailsAction(populated, role);
refreshUnifiedCounts(populated);
return;
}
if (isUnifiedView) {
exitUnifiedView();
}
selectMailbox(mailboxId);
selectEmail(null); // Clear selected email when switching mailboxes
@@ -969,6 +1060,7 @@ export default function Home() {
const handleSearch = async (query: string) => {
if (!client) return;
if (isUnifiedView) return;
setSearchQuery(query);
if (!isFilterEmpty(searchFilters)) {
await advancedSearch(client);
@@ -987,6 +1079,7 @@ export default function Home() {
const handleAdvancedSearch = async () => {
if (!client) return;
if (isUnifiedView) return;
await advancedSearch(client);
};
@@ -996,9 +1089,9 @@ export default function Home() {
clearTimeout(advancedSearchDebounceRef.current);
}
advancedSearchDebounceRef.current = setTimeout(() => {
if (client) advancedSearch(client);
if (client && !isUnifiedView) advancedSearch(client);
}, 300);
}, [client, advancedSearch]);
}, [client, advancedSearch, isUnifiedView]);
useEffect(() => {
return () => {
@@ -1127,13 +1220,29 @@ export default function Home() {
// Fetch the full content
try {
// Find selected mailbox to determine accountId (for shared folders)
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
// Only pass accountId for shared mailboxes
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
// In unified view each email carries its own accountId. Use that
// account's client so we fetch from the server that actually owns it.
const listEmail = emails.find(e => e.id === email.id);
const emailAccountId = isUnifiedView ? listEmail?.accountId : undefined;
const perAccountClient = emailAccountId
? useAuthStore.getState().getClientForAccount(emailAccountId)
: undefined;
const fetchClient = perAccountClient ?? client;
const fullEmail = await client.getEmail(email.id, accountId);
// For shared folders on the primary client, we still need to pass the
// shared account's id. In unified view we use the per-account client
// directly, so no explicit accountId is needed.
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const accountId = perAccountClient
? undefined
: mailbox?.isShared ? mailbox.accountId : undefined;
const fullEmail = await fetchClient.getEmail(email.id, accountId);
if (fullEmail) {
if (emailAccountId) {
fullEmail.accountId = emailAccountId;
fullEmail.accountLabel = listEmail?.accountLabel;
}
selectEmail(fullEmail);
// Mark-as-read logic is now handled by useEffect
}
@@ -1397,6 +1506,8 @@ export default function Home() {
className={cn("pl-9 h-9", searchQuery && "pr-8")}
data-search-input
data-tour="search-input"
disabled={isUnifiedView}
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : undefined}
/>
{searchQuery && (
<button
@@ -1412,13 +1523,15 @@ export default function Home() {
<button
type="button"
onClick={toggleAdvancedSearch}
disabled={isUnifiedView}
className={cn(
"relative flex-shrink-0 p-2 rounded-md transition-colors",
isUnifiedView && "opacity-50 cursor-not-allowed",
isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
title={t("advanced_search.toggle_filters")}
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : t("advanced_search.toggle_filters")}
>
<Filter className="w-4 h-4" />
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
+18
View File
@@ -12,6 +12,7 @@ import { toast } from "@/stores/toast-store";
import { sanitizeEmailHtml } from "@/lib/email-sanitization";
import { useAuthStore } from "@/stores/auth-store";
import { useIdentityStore } from "@/stores/identity-store";
import { useAccountStore } from "@/stores/account-store";
import { useSmimeStore } from "@/stores/smime-store";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
@@ -84,6 +85,7 @@ interface EmailComposerProps {
body?: string;
htmlBody?: string;
receivedAt?: string;
accountId?: string;
};
}
@@ -254,12 +256,28 @@ export function EmailComposer({
if (matchedIdentityId) {
setSelectedIdentityId(matchedIdentityId);
return;
}
// Fallback: match identity by the account's email when replying from unified view
if (replyTo?.accountId) {
const account = useAccountStore.getState().getAccountById(replyTo.accountId);
if (account?.email) {
const accountEmail = account.email.trim().toLowerCase();
const accountIdentity = identities.find(
(identity) => identity.email.trim().toLowerCase() === accountEmail
);
if (accountIdentity) {
setSelectedIdentityId(accountIdentity.id);
}
}
}
}, [
autoSelectReplyIdentity,
identities,
initialData?.selectedIdentityId,
mode,
replyTo?.accountId,
replyTo?.bcc,
replyTo?.cc,
replyTo?.to,
+35 -1
View File
@@ -9,6 +9,7 @@ import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSqu
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
import { useEmailStore } from "@/stores/email-store";
import { useAccountStore } from "@/stores/account-store";
import { getThreadColorTag, getEmailColorTags } from "@/lib/thread-utils";
import { useEmailDrag } from "@/hooks/use-email-drag";
import { useLongPress } from "@/hooks/use-long-press";
@@ -63,6 +64,9 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const isUnifiedView = useEmailStore((state) => state.isUnifiedView);
const getAccountById = useAccountStore((state) => state.getAccountById);
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
const isChecked = selectedEmailIds.has(email.id);
const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
@@ -184,6 +188,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{isFocusedMailLayout ? (
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-3">
{isUnifiedView && email.accountId && accountColor && (
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: accountColor }}
title={email.accountLabel}
/>
)}
<span className={cn(
'w-32 shrink-0 truncate text-sm lg:w-40',
isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
@@ -228,6 +239,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
<>
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-1">
{isUnifiedView && email.accountId && accountColor && (
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: accountColor }}
title={email.accountLabel}
/>
)}
<span className={cn(
"truncate text-sm",
isUnread
@@ -345,7 +363,9 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && latestEmail.preview ? ` ${latestEmail.preview}` : '';
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView } = useEmailStore();
const getAccountById = useAccountStore((state) => state.getAccountById);
const threadAccountColor = latestEmail.accountId ? getAccountById(latestEmail.accountId)?.avatarColor : undefined;
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
@@ -548,6 +568,13 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{isFocusedMailLayout ? (
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-3">
{isUnifiedView && latestEmail.accountId && threadAccountColor && (
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: threadAccountColor }}
title={latestEmail.accountLabel}
/>
)}
<span className={cn(
'w-32 shrink-0 truncate text-sm lg:w-44',
hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
@@ -602,6 +629,13 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
<>
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-1">
{isUnifiedView && latestEmail.accountId && threadAccountColor && (
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: threadAccountColor }}
title={latestEmail.accountLabel}
/>
)}
<span className={cn(
"truncate text-sm",
hasUnread
File diff suppressed because it is too large Load Diff
+24 -1
View File
@@ -10,6 +10,7 @@ import { useTour } from '@/components/tour/tour-provider';
import { Button } from '@/components/ui/button';
import { PlayCircle } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store';
import { useAccountStore } from '@/stores/account-store';
const DENSITY_PREVIEW: Record<Density, { py: string; gap: string; showAvatar: boolean; showPreview: boolean }> = {
'extra-compact': { py: 'py-0.5', gap: 'gap-1.5', showAvatar: false, showPreview: false },
@@ -67,9 +68,10 @@ export function AppearanceSettings() {
const t = useTranslations('settings.appearance');
const tTour = useTranslations('tour');
const { theme, setTheme } = useThemeStore();
const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, updateSetting } = useSettingsStore();
const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, colorfulSidebarIcons, updateSetting } = useSettingsStore();
const { startTour, resetTourCompletion } = useTour();
const { isSettingLocked, isSettingHidden } = usePolicyStore();
const accounts = useAccountStore(s => s.accounts);
return (
<SettingsSection title={t('title')} description={t('description')}>
@@ -161,6 +163,27 @@ export function AppearanceSettings() {
/>
</SettingItem>
{/* Colorful Sidebar Icons */}
<SettingItem label={t('colorful_sidebar_icons.label')} description={t('colorful_sidebar_icons.description')}>
<ToggleSwitch
checked={colorfulSidebarIcons}
onChange={(checked) => updateSetting('colorfulSidebarIcons', checked)}
/>
</SettingItem>
{/* Unified Mailbox */}
{accounts.length > 1 && (
<SettingItem
label={t('unified_mailbox.label')}
description={t('unified_mailbox.description')}
>
<ToggleSwitch
checked={enableUnifiedMailbox}
onChange={(v) => updateSetting('enableUnifiedMailbox', v)}
/>
</SettingItem>
)}
{/* Animations */}
{!isSettingHidden('animationsEnabled') && (
<SettingItem label={t('animations.label')} description={t('animations.description')} locked={isSettingLocked('animationsEnabled')}>
+178 -248
View File
@@ -1,17 +1,19 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { Shield, Mail, X, AlertTriangle, Trophy, RotateCcw, Inbox, MailCheck } from "lucide-react";
import { Shield, Mail, X, AlertTriangle, MailCheck, RotateCcw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const GAME_WIDTH = 400;
const GAME_HEIGHT = 520;
const FORTRESS_Y = GAME_HEIGHT - 48;
const SPAWN_INTERVAL_START = 850;
const SPAWN_INTERVAL_MIN = 320;
const INBOX_Y = GAME_HEIGHT - 40;
const SPAWN_INTERVAL_START = 900;
const SPAWN_INTERVAL_MIN = 340;
const GAME_DURATION = 30;
const ENEMY_SPEED_START = 1.2;
const ENEMY_SPEED_INCREASE = 0.04;
const MAX_MISSES = 3;
interface Enemy {
id: number;
@@ -21,81 +23,85 @@ interface Enemy {
type: "spam" | "phishing" | "legit";
}
type GameState = "idle" | "playing" | "won" | "lost";
type GameState = "idle" | "playing" | "over";
export function SpamSiegeGame({ onClose }: { onClose: () => void }) {
const [gameState, setGameState] = useState<GameState>("idle");
const [enemies, setEnemies] = useState<Enemy[]>([]);
const [score, setScore] = useState(0);
const [timeLeft, setTimeLeft] = useState(GAME_DURATION);
const [shieldHealth, setShieldHealth] = useState(3);
const [hitEffects, setHitEffects] = useState<{ id: number; x: number; y: number; color: string }[]>([]);
const [destroyEffects, setDestroyEffects] = useState<{ id: number; x: number; y: number }[]>([]);
const [deliverEffects, setDeliverEffects] = useState<{ id: number; x: number; y: number }[]>([]);
const [misses, setMisses] = useState(0);
const [survived, setSurvived] = useState(false);
const nextId = useRef(0);
const animFrameRef = useRef<number>(0);
const lastTimeRef = useRef<number>(0);
const spawnTimerRef = useRef<number>(0);
const gameStateRef = useRef<GameState>("idle");
const elapsedRef = useRef(0);
const destroyedRef = useRef(new Set<number>());
const clickedRef = useRef(new Set<number>());
const enemiesRef = useRef<Enemy[]>([]);
const missesRef = useRef(0);
const scoreRef = useRef(0);
useEffect(() => {
gameStateRef.current = gameState;
}, [gameState]);
const endGame = useCallback((didSurvive: boolean) => {
setSurvived(didSurvive);
setGameState("over");
}, []);
const startGame = useCallback(() => {
setGameState("playing");
setEnemies([]);
setScore(0);
setTimeLeft(GAME_DURATION);
setShieldHealth(3);
setHitEffects([]);
setDestroyEffects([]);
setDeliverEffects([]);
setMisses(0);
setSurvived(false);
nextId.current = 0;
spawnTimerRef.current = 0;
elapsedRef.current = 0;
destroyedRef.current = new Set();
clickedRef.current = new Set();
enemiesRef.current = [];
missesRef.current = 0;
scoreRef.current = 0;
lastTimeRef.current = performance.now();
}, []);
const spawnEnemy = useCallback(() => {
const id = nextId.current++;
const rand = Math.random();
const type = rand > 0.7 ? "legit" : rand > 0.45 ? "phishing" : "spam";
const type = rand > 0.75 ? "legit" : rand > 0.45 ? "phishing" : "spam";
const x = 20 + Math.random() * (GAME_WIDTH - 60);
const elapsed = elapsedRef.current;
const speed = ENEMY_SPEED_START + (elapsed / 1000) * ENEMY_SPEED_INCREASE;
setEnemies((prev) => [...prev, { id, x, y: -30, speed, type }]);
const speed = ENEMY_SPEED_START + (elapsedRef.current / 1000) * ENEMY_SPEED_INCREASE;
enemiesRef.current = [...enemiesRef.current, { id, x, y: -32, speed, type }];
setEnemies(enemiesRef.current);
}, []);
const handleHover = useCallback((enemy: Enemy) => {
if (destroyedRef.current.has(enemy.id)) return;
destroyedRef.current.add(enemy.id);
const handleClick = useCallback(
(ev: React.MouseEvent, enemy: Enemy) => {
ev.stopPropagation();
if (clickedRef.current.has(enemy.id)) return;
clickedRef.current.add(enemy.id);
if (enemy.type === "legit") {
// Penalty for blocking legit mail
setShieldHealth((prev) => {
const nh = prev - 1;
if (nh <= 0) setGameState("lost");
return Math.max(0, nh);
});
setScore((prev) => Math.max(0, prev - 15));
const effectId = nextId.current++;
setHitEffects((p) => [...p, { id: effectId, x: enemy.x, y: enemy.y, color: "rgba(34, 197, 94, 0.5)" }]);
setTimeout(() => setHitEffects((p) => p.filter((h) => h.id !== effectId)), 500);
} else {
setScore((prev) => prev + 10);
const effectId = nextId.current++;
setDestroyEffects((prev) => [...prev, { id: effectId, x: enemy.x, y: enemy.y }]);
setTimeout(() => setDestroyEffects((prev) => prev.filter((e) => e.id !== effectId)), 400);
}
enemiesRef.current = enemiesRef.current.filter((e) => e.id !== enemy.id);
setEnemies(enemiesRef.current);
setEnemies((prev) => prev.filter((e) => e.id !== enemy.id));
}, []);
if (enemy.type === "legit") {
missesRef.current += 1;
setMisses(missesRef.current);
scoreRef.current = Math.max(0, scoreRef.current - 15);
setScore(scoreRef.current);
if (missesRef.current >= MAX_MISSES) endGame(false);
} else {
scoreRef.current += enemy.type === "phishing" ? 15 : 10;
setScore(scoreRef.current);
}
},
[endGame]
);
// Game loop
useEffect(() => {
if (gameState !== "playing") return;
@@ -106,15 +112,13 @@ export function SpamSiegeGame({ onClose }: { onClose: () => void }) {
lastTimeRef.current = now;
elapsedRef.current += dt;
// Timer
const newTimeLeft = GAME_DURATION - Math.floor(elapsedRef.current / 1000);
setTimeLeft(Math.max(0, newTimeLeft));
if (newTimeLeft <= 0) {
setGameState("won");
endGame(true);
return;
}
// Spawn
spawnTimerRef.current += dt;
const spawnInterval = Math.max(
SPAWN_INTERVAL_MIN,
@@ -125,261 +129,187 @@ export function SpamSiegeGame({ onClose }: { onClose: () => void }) {
spawnEnemy();
}
// Move enemies
setEnemies((prev) => {
const next: Enemy[] = [];
let spamBreached = false;
for (const e of prev) {
const ny = e.y + e.speed * (dt / 16);
if (ny >= FORTRESS_Y) {
if (e.type === "legit") {
// Legit mail delivered — bonus
setScore((s) => s + 5);
const effectId = nextId.current++;
setDeliverEffects((p) => [...p, { id: effectId, x: e.x, y: FORTRESS_Y }]);
setTimeout(() => setDeliverEffects((p) => p.filter((d) => d.id !== effectId)), 500);
} else {
spamBreached = true;
const effectId = nextId.current++;
setHitEffects((p) => [...p, { id: effectId, x: e.x, y: FORTRESS_Y, color: "rgba(219, 45, 84, 0.3)" }]);
setTimeout(() => setHitEffects((p) => p.filter((h) => h.id !== effectId)), 500);
}
} else {
next.push({ ...e, y: ny });
}
const nextEnemies: Enemy[] = [];
let missed = 0;
let scoreDelta = 0;
for (const e of enemiesRef.current) {
const ny = e.y + e.speed * (dt / 16);
if (ny >= INBOX_Y) {
if (e.type === "legit") scoreDelta += 5;
else missed++;
} else {
nextEnemies.push({ ...e, y: ny });
}
if (spamBreached) {
setShieldHealth((prev) => {
const nh = prev - 1;
if (nh <= 0) setGameState("lost");
return Math.max(0, nh);
});
}
enemiesRef.current = nextEnemies;
setEnemies(nextEnemies);
if (scoreDelta > 0) {
scoreRef.current += scoreDelta;
setScore(scoreRef.current);
}
if (missed > 0) {
missesRef.current += missed;
setMisses(missesRef.current);
if (missesRef.current >= MAX_MISSES) {
endGame(false);
return;
}
return next;
});
}
animFrameRef.current = requestAnimationFrame(tick);
};
animFrameRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(animFrameRef.current);
}, [gameState, spawnEnemy]);
const getEnemyStyle = (type: Enemy["type"]) => {
switch (type) {
case "phishing":
return { bg: "rgba(234, 179, 8, 0.15)", border: "rgba(234, 179, 8, 0.4)", color: "rgb(234, 179, 8)" };
case "legit":
return { bg: "rgba(34, 197, 94, 0.12)", border: "rgba(34, 197, 94, 0.4)", color: "rgb(34, 197, 94)" };
default:
return { bg: "rgba(219, 45, 84, 0.1)", border: "rgba(219, 45, 84, 0.3)", color: "rgb(219, 45, 84)" };
}
};
}, [gameState, spawnEnemy, endGame]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="relative rounded-xl border border-border bg-card shadow-2xl overflow-hidden select-none"
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onClick={onClose}
>
<div
className="relative rounded-lg border border-border bg-card shadow-xl overflow-hidden select-none"
style={{ width: GAME_WIDTH, maxWidth: "95vw" }}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-card">
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4" style={{ color: "rgb(219, 45, 84)" }} />
<span className="text-sm font-semibold text-foreground">Spam Siege</span>
<Shield className="w-4 h-4 text-primary" />
<span className="text-sm font-medium text-foreground">Spam Siege</span>
</div>
<button onClick={onClose} className="p-1 rounded hover:bg-muted transition-colors">
<button
onClick={onClose}
className="p-1 rounded hover:bg-muted transition-colors"
aria-label="Close"
>
<X className="w-4 h-4 text-muted-foreground" />
</button>
</div>
{/* HUD */}
<div className="flex items-center justify-between px-4 py-2 bg-muted/30 border-b border-border text-xs">
<div className="flex items-center gap-3">
<span className="text-muted-foreground">Score: <span className="font-semibold text-foreground">{score}</span></span>
<span className="text-muted-foreground">Time: <span className="font-semibold text-foreground">{timeLeft}s</span></span>
</div>
<div className="flex items-center gap-1">
{[...Array(3)].map((_, i) => (
<Shield
key={i}
className="w-3.5 h-3.5 transition-colors"
style={{ color: i < shieldHealth ? "rgb(219, 45, 84)" : "rgb(100, 100, 100)" }}
fill={i < shieldHealth ? "rgb(219, 45, 84)" : "none"}
strokeWidth={i < shieldHealth ? 0 : 1.5}
/>
))}
<div className="flex items-center justify-between px-4 py-2 bg-muted/40 border-b border-border text-xs text-muted-foreground">
<div className="flex items-center gap-4">
<span>
Score <span className="font-medium text-foreground tabular-nums">{score}</span>
</span>
<span>
Time <span className="font-medium text-foreground tabular-nums">{timeLeft}s</span>
</span>
</div>
<span>
Misses{" "}
<span
className={cn(
"font-medium tabular-nums",
misses >= MAX_MISSES - 1 ? "text-destructive" : "text-foreground"
)}
>
{misses}/{MAX_MISSES}
</span>
</span>
</div>
{/* Game area */}
<div
className="relative bg-background overflow-hidden"
style={{ height: GAME_HEIGHT }}
>
{/* Grid lines for depth */}
<div className="absolute inset-0 opacity-[0.03]" style={{
backgroundImage: "linear-gradient(to bottom, currentColor 1px, transparent 1px), linear-gradient(to right, currentColor 1px, transparent 1px)",
backgroundSize: "40px 40px",
}} />
{/* Fortress wall */}
<div className="absolute left-0 right-0 bottom-0 flex flex-col items-center" style={{ height: GAME_HEIGHT - FORTRESS_Y }}>
<div className="relative w-full">
{/* Shield centered above the line */}
<div className="absolute -top-5 left-1/2 -translate-x-1/2 z-10">
<Shield
className="w-7 h-7 drop-shadow-sm"
style={{ color: shieldHealth > 0 ? "rgb(219, 45, 84)" : "rgb(100, 100, 100)" }}
fill={shieldHealth > 0 ? "rgba(219, 45, 84, 0.2)" : "none"}
/>
</div>
{/* Solid line */}
<div
className="h-[2px] w-full"
style={{ backgroundColor: shieldHealth > 0 ? "rgba(219, 45, 84, 0.35)" : "rgba(100, 100, 100, 0.3)" }}
/>
</div>
{/* Subtle gradient fill below */}
<div
className="flex-1 w-full"
style={{
background: shieldHealth > 0
? "linear-gradient(to bottom, rgba(219, 45, 84, 0.06), transparent)"
: "linear-gradient(to bottom, rgba(100, 100, 100, 0.04), transparent)",
}}
/>
<div
className="absolute left-0 right-0 flex items-center gap-2 px-4"
style={{ top: INBOX_Y }}
>
<div className="h-px flex-1 bg-border" />
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
Inbox
</span>
<div className="h-px flex-1 bg-border" />
</div>
{/* Enemies */}
{enemies.map((e) => {
const style = getEnemyStyle(e.type);
const variant =
e.type === "phishing"
? "text-warning border-warning/40 bg-warning/10 hover:bg-warning/20"
: e.type === "legit"
? "text-success border-success/40 bg-success/10 hover:bg-success/20"
: "text-destructive border-destructive/40 bg-destructive/10 hover:bg-destructive/20";
const Icon =
e.type === "phishing" ? AlertTriangle : e.type === "legit" ? MailCheck : Mail;
return (
<div
<button
key={e.id}
className="absolute flex items-center justify-center w-8 h-8 rounded-md transition-transform"
style={{
left: e.x,
top: e.y,
backgroundColor: style.bg,
border: `1px solid ${style.border}`,
}}
onMouseEnter={() => handleHover(e)}
>
{e.type === "phishing" ? (
<AlertTriangle className="w-4 h-4" style={{ color: style.color }} />
) : e.type === "legit" ? (
<MailCheck className="w-4 h-4" style={{ color: style.color }} />
) : (
<Mail className="w-4 h-4" style={{ color: style.color }} />
type="button"
className={cn(
"absolute flex items-center justify-center w-8 h-8 rounded-md border cursor-pointer",
"active:scale-95 transition-transform",
variant
)}
</div>
style={{ left: e.x, top: e.y }}
onMouseEnter={(ev) => handleClick(ev, e)}
onClick={(ev) => handleClick(ev, e)}
>
<Icon className="w-4 h-4" />
</button>
);
})}
{/* Destroy effects */}
{destroyEffects.map((e) => (
<div
key={e.id}
className="absolute pointer-events-none animate-ping"
style={{ left: e.x + 4, top: e.y + 4 }}
>
<X className="w-5 h-5 text-muted-foreground/50" />
</div>
))}
{/* Deliver effects (legit mail arrived) */}
{deliverEffects.map((e) => (
<div
key={e.id}
className="absolute pointer-events-none animate-ping"
style={{ left: e.x + 4, top: e.y - 8 }}
>
<Inbox className="w-5 h-5" style={{ color: "rgb(34, 197, 94)" }} />
</div>
))}
{/* Hit effects on fortress */}
{hitEffects.map((e) => (
<div
key={e.id}
className="absolute pointer-events-none"
style={{ left: e.x, top: e.y - 10 }}
>
<div className="w-6 h-6 rounded-full animate-ping" style={{ backgroundColor: e.color }} />
</div>
))}
{/* Idle overlay */}
{gameState === "idle" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/80">
<Shield className="w-14 h-14" style={{ color: "rgb(219, 45, 84)" }} fill="rgba(219, 45, 84, 0.1)" />
<div className="text-center">
<p className="text-base font-semibold text-foreground">Spam Siege</p>
<p className="text-xs text-muted-foreground mt-1.5 max-w-[280px] leading-relaxed">
Hover over threats to block them. Let legitimate mail through. Survive {GAME_DURATION} seconds.
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/95 px-8 text-center">
<Shield className="w-10 h-10 text-primary" />
<div className="space-y-1.5">
<p className="text-base font-medium text-foreground">Spam Siege</p>
<p className="text-xs text-muted-foreground leading-relaxed">
Click spam and phishing before they hit your inbox. Don&apos;t block legitimate
mail. Three misses and it&apos;s over.
</p>
<div className="flex items-center justify-center gap-4 mt-3 text-[11px] text-muted-foreground">
<span className="inline-flex items-center gap-1">
<Mail className="w-3 h-3" style={{ color: "rgb(219, 45, 84)" }} /> Spam
</span>
<span className="inline-flex items-center gap-1">
<AlertTriangle className="w-3 h-3" style={{ color: "rgb(234, 179, 8)" }} /> Phishing
</span>
<span className="inline-flex items-center gap-1">
<MailCheck className="w-3 h-3" style={{ color: "rgb(34, 197, 94)" }} /> Legit
</span>
</div>
</div>
<Button size="sm" onClick={startGame} className="mt-1 text-white" style={{ backgroundColor: "rgb(219, 45, 84)" }}>
<Shield className="w-3.5 h-3.5 mr-1.5" />
Defend
<div className="flex items-center gap-4 text-[11px] text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<Mail className="w-3 h-3 text-destructive" />
Spam
</span>
<span className="inline-flex items-center gap-1.5">
<AlertTriangle className="w-3 h-3 text-warning" />
Phishing
</span>
<span className="inline-flex items-center gap-1.5">
<MailCheck className="w-3 h-3 text-success" />
Legit
</span>
</div>
<Button size="sm" onClick={startGame}>
Start
</Button>
</div>
)}
{/* Won overlay */}
{gameState === "won" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/80">
<Trophy className="w-14 h-14" style={{ color: "rgb(219, 45, 84)" }} />
<div className="text-center">
<p className="text-base font-semibold text-foreground">Fortress Secured</p>
<p className="text-xs text-muted-foreground mt-1">
Score: <span className="font-semibold text-foreground">{score}</span>
{gameState === "over" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/95 px-8 text-center">
<Shield
className={cn(
"w-10 h-10",
survived ? "text-success" : "text-muted-foreground/40"
)}
/>
<div className="space-y-1">
<p className="text-base font-medium text-foreground">
{survived ? "Inbox held" : "Inbox overrun"}
</p>
<p className="text-xs text-muted-foreground">
Final score{" "}
<span className="font-medium text-foreground tabular-nums">{score}</span>
</p>
</div>
<div className="flex gap-2 mt-1">
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={onClose}>
Close
</Button>
<Button size="sm" onClick={startGame} className="text-white" style={{ backgroundColor: "rgb(219, 45, 84)" }}>
<Button size="sm" onClick={startGame}>
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />
Again
</Button>
</div>
</div>
)}
{/* Lost overlay */}
{gameState === "lost" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/80">
<Shield className="w-14 h-14 text-muted-foreground/40" />
<div className="text-center">
<p className="text-base font-semibold text-foreground">Fortress Breached</p>
<p className="text-xs text-muted-foreground mt-1">
Score: <span className="font-semibold text-foreground">{score}</span>
</p>
</div>
<div className="flex gap-2 mt-1">
<Button size="sm" variant="outline" onClick={onClose}>
Close
</Button>
<Button size="sm" onClick={startGame} className="text-white" style={{ backgroundColor: "rgb(219, 45, 84)" }}>
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />
Retry
</Button>
</div>
</div>
)}
</div>
</div>
</div>
+184
View File
@@ -0,0 +1,184 @@
import { type SVGProps, type ReactElement } from "react";
type FlagProps = SVGProps<SVGSVGElement>;
const flagClass = "inline-block rounded-[2px] shrink-0";
const W = 20;
const H = 15;
/** Great Britain Union Jack (simplified) */
export function FlagGB(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 30" width={W} height={H} className={flagClass} {...props}>
<rect width="60" height="30" fill="#012169" />
<path d="M0,0 L60,30 M60,0 L0,30" stroke="#fff" strokeWidth="6" />
<path d="M0,0 L60,30 M60,0 L0,30" stroke="#C8102E" strokeWidth="2" />
<path d="M30,0 V30 M0,15 H60" stroke="#fff" strokeWidth="10" />
<path d="M30,0 V30 M0,15 H60" stroke="#C8102E" strokeWidth="6" />
</svg>
);
}
/** France Blue, White, Red vertical */
export function FlagFR(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2" width={W} height={H} className={flagClass} {...props}>
<rect width="1" height="2" fill="#002395" />
<rect x="1" width="1" height="2" fill="#fff" />
<rect x="2" width="1" height="2" fill="#ED2939" />
</svg>
);
}
/** Japan White with red circle */
export function FlagJP(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2" width={W} height={H} className={flagClass} {...props}>
<rect width="3" height="2" fill="#fff" />
<circle cx="1.5" cy="1" r="0.6" fill="#BC002D" />
</svg>
);
}
/** South Korea Simplified */
export function FlagKR(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2" width={W} height={H} className={flagClass} {...props}>
<rect width="3" height="2" fill="#fff" />
<circle cx="1.5" cy="1" r="0.55" fill="#CD2E3A" />
<path d="M1.5,1 a0.275,0.275 0 0,1 0,0.55 a0.275,0.275 0 0,0 0,-0.55" fill="#0047A0" />
<path d="M1.5,1 a0.275,0.275 0 0,0 0,-0.55 a0.275,0.275 0 0,1 0,0.55" fill="#0047A0" />
</svg>
);
}
/** Spain Red, Yellow, Red horizontal */
export function FlagES(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2" width={W} height={H} className={flagClass} {...props}>
<rect width="3" height="0.5" fill="#AA151B" />
<rect y="0.5" width="3" height="1" fill="#F1BF00" />
<rect y="1.5" width="3" height="0.5" fill="#AA151B" />
</svg>
);
}
/** Italy Green, White, Red vertical */
export function FlagIT(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2" width={W} height={H} className={flagClass} {...props}>
<rect width="1" height="2" fill="#009246" />
<rect x="1" width="1" height="2" fill="#fff" />
<rect x="2" width="1" height="2" fill="#CE2B37" />
</svg>
);
}
/** Germany Black, Red, Gold horizontal */
export function FlagDE(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 5 3" width={W} height={H} className={flagClass} {...props}>
<rect width="5" height="1" fill="#000" />
<rect y="1" width="5" height="1" fill="#DD0000" />
<rect y="2" width="5" height="1" fill="#FFCC00" />
</svg>
);
}
/** Latvia Maroon, White, Maroon horizontal */
export function FlagLV(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 10" width={W} height={H} className={flagClass} {...props}>
<rect width="20" height="4" fill="#9E3039" />
<rect y="4" width="20" height="2" fill="#fff" />
<rect y="6" width="20" height="4" fill="#9E3039" />
</svg>
);
}
/** Netherlands Red, White, Blue horizontal */
export function FlagNL(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 9 6" width={W} height={H} className={flagClass} {...props}>
<rect width="9" height="2" fill="#AE1C28" />
<rect y="2" width="9" height="2" fill="#fff" />
<rect y="4" width="9" height="2" fill="#21468B" />
</svg>
);
}
/** Poland White, Red horizontal */
export function FlagPL(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 5" width={W} height={H} className={flagClass} {...props}>
<rect width="8" height="2.5" fill="#fff" />
<rect y="2.5" width="8" height="2.5" fill="#DC143C" />
</svg>
);
}
/** Brazil Green, yellow diamond (simplified) */
export function FlagBR(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 14" width={W} height={H} className={flagClass} {...props}>
<rect width="20" height="14" fill="#009B3A" />
<polygon points="10,1.5 18.5,7 10,12.5 1.5,7" fill="#FEDF00" />
<circle cx="10" cy="7" r="3" fill="#002776" />
</svg>
);
}
/** Russia White, Blue, Red horizontal */
export function FlagRU(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 9 6" width={W} height={H} className={flagClass} {...props}>
<rect width="9" height="2" fill="#fff" />
<rect y="2" width="9" height="2" fill="#0039A6" />
<rect y="4" width="9" height="2" fill="#D52B1E" />
</svg>
);
}
/** Ukraine Blue, Yellow horizontal */
export function FlagUA(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2" width={W} height={H} className={flagClass} {...props}>
<rect width="3" height="1" fill="#005BBB" />
<rect y="1" width="3" height="1" fill="#FFD500" />
</svg>
);
}
/** China Red with yellow stars (simplified) */
export function FlagCN(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 20" width={W} height={H} className={flagClass} {...props}>
<rect width="30" height="20" fill="#DE2910" />
<g fill="#FFDE00">
<polygon points="5,2 6,5 3.2,3.2 6.8,3.2 4,5" />
<polygon points="10,1 10.6,2.7 9,1.8 11,1.8 9.4,2.7" />
<polygon points="12,3 12.6,4.7 11,3.8 13,3.8 11.4,4.7" />
<polygon points="12,6 12.6,7.7 11,6.8 13,6.8 11.4,7.7" />
<polygon points="10,8 10.6,9.7 9,8.8 11,8.8 9.4,9.7" />
</g>
</svg>
);
}
/** Map locale codes to flag components */
export const flagComponents: Record<string, (props: FlagProps) => ReactElement> = {
en: FlagGB,
fr: FlagFR,
ja: FlagJP,
ko: FlagKR,
es: FlagES,
it: FlagIT,
de: FlagDE,
lv: FlagLV,
nl: FlagNL,
pl: FlagPL,
pt: FlagBR,
ru: FlagRU,
uk: FlagUA,
zh: FlagCN,
};
+96 -23
View File
@@ -1,37 +1,110 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { useLocale } from 'next-intl';
import { useLocaleStore } from '@/stores/locale-store';
import { Select } from '@/components/settings/settings-section';
import { ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import { flagComponents } from './flag-icons';
const languages = [
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'Français' },
{ value: 'ja', label: '日本語' },
{ value: 'ko', label: '한국어' },
{ value: 'es', label: 'Español' },
{ value: 'it', label: 'Italiano' },
{ value: 'de', label: 'Deutsch' },
{ value: 'lv', label: 'Latviešu' },
{ value: 'nl', label: 'Nederlands' },
{ value: 'pl', label: 'Polski' },
{ value: 'pt', label: 'Português' },
{ value: 'ru', label: 'Русский' },
{ value: 'uk', label: 'Українська' },
{ value: 'zh', label: '简体中文' },
];
function FlagIcon({ locale }: { locale: string }) {
const Flag = flagComponents[locale];
if (!Flag) return null;
return <Flag />;
}
export function LanguageSwitcher({ className }: { className?: string }) {
const currentLocale = useLocale();
const setLocale = useLocaleStore((state) => state.setLocale);
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const languages = [
{ value: 'en', label: '🇬🇧 English' },
{ value: 'fr', label: '🇫🇷 Français' },
{ value: 'ja', label: '🇯🇵 日本語' },
{ value: 'ko', label: '🇰🇷 한국어' },
{ value: 'es', label: '🇪🇸 Español' },
{ value: 'it', label: '🇮🇹 Italiano' },
{ value: 'de', label: '🇩🇪 Deutsch' },
{ value: 'lv', label: '🇱🇻 Latviešu' },
{ value: 'nl', label: '🇳🇱 Nederlands' },
{ value: 'pl', label: '🇵🇱 Polski' },
{ value: 'pt', label: '🇧🇷 Português' },
{ value: 'ru', label: '🇷🇺 Русский' },
{ value: 'uk', label: '🇺🇦 Українська' },
{ value: 'zh', label: '🇨🇳 简体中文' }
];
const current = languages.find((l) => l.value === currentLocale) ?? languages[0];
// Close on outside click
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
// Close on Escape
useEffect(() => {
if (!open) return;
function handleKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [open]);
return (
<div className={className}>
<Select
value={currentLocale}
onChange={setLocale}
options={languages}
/>
<div ref={containerRef} className={cn("relative", className)}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-2 px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground hover:border-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 cursor-pointer w-full"
aria-haspopup="listbox"
aria-expanded={open}
>
<FlagIcon locale={current.value} />
<span className="flex-1 text-left">{current.label}</span>
<ChevronDown className={cn("h-3.5 w-3.5 text-muted-foreground transition-transform duration-150", open && "rotate-180")} />
</button>
{open && (
<ul
ref={listRef}
role="listbox"
aria-activedescendant={`lang-${currentLocale}`}
className="absolute z-50 mt-1 w-full max-h-60 overflow-auto rounded-md border border-border bg-background shadow-lg py-1"
>
{languages.map((lang) => (
<li
key={lang.value}
id={`lang-${lang.value}`}
role="option"
aria-selected={lang.value === currentLocale}
onClick={() => {
setLocale(lang.value);
setOpen(false);
}}
className={cn(
"flex items-center gap-2 px-3 py-1.5 text-sm cursor-pointer transition-colors duration-100",
lang.value === currentLocale
? "bg-accent text-accent-foreground font-medium"
: "text-foreground hover:bg-accent/50"
)}
>
<FlagIcon locale={lang.value} />
<span>{lang.label}</span>
</li>
))}
</ul>
)}
</div>
);
}
+30
View File
@@ -39,6 +39,9 @@ export interface Email {
// S/MIME support
blobId?: string;
bodyStructure?: EmailBodyPart;
// Unified mailbox support — set when displaying emails from multiple accounts
accountId?: string;
accountLabel?: string;
}
export interface AuthenticationResults {
@@ -725,3 +728,30 @@ export interface FileNodeFilter {
name?: string;
type?: string;
}
// Unified mailbox virtual IDs and types
export const UNIFIED_INBOX = '__unified_inbox__';
export const UNIFIED_SENT = '__unified_sent__';
export const UNIFIED_DRAFTS = '__unified_drafts__';
export const UNIFIED_TRASH = '__unified_trash__';
export const UNIFIED_ARCHIVE = '__unified_archive__';
export const UNIFIED_JUNK = '__unified_junk__';
export type UnifiedMailboxRole = 'inbox' | 'sent' | 'drafts' | 'trash' | 'archive' | 'junk';
export const UNIFIED_MAILBOX_IDS: Record<UnifiedMailboxRole, string> = {
inbox: UNIFIED_INBOX,
sent: UNIFIED_SENT,
drafts: UNIFIED_DRAFTS,
trash: UNIFIED_TRASH,
archive: UNIFIED_ARCHIVE,
junk: UNIFIED_JUNK,
};
export const UNIFIED_ROLE_BY_ID: Record<string, UnifiedMailboxRole> = Object.fromEntries(
Object.entries(UNIFIED_MAILBOX_IDS).map(([role, id]) => [id, role as UnifiedMailboxRole])
) as Record<string, UnifiedMailboxRole>;
export function isUnifiedMailboxId(id: string): boolean {
return id in UNIFIED_ROLE_BY_ID;
}
+170
View File
@@ -0,0 +1,170 @@
import type { Email, Mailbox, UnifiedMailboxRole } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
export interface UnifiedAccountClient {
accountId: string;
accountLabel: string;
client: IJMAPClient;
mailboxes: Mailbox[];
}
export interface UnifiedFetchResult {
emails: Email[];
total: number;
hasMore: boolean;
errors: Map<string, string>; // accountId -> error message
}
export interface UnifiedMailboxCounts {
role: UnifiedMailboxRole;
unreadEmails: number;
totalEmails: number;
}
const ALL_UNIFIED_ROLES: UnifiedMailboxRole[] = [
'inbox', 'sent', 'drafts', 'trash', 'archive', 'junk',
];
/**
* Finds the first mailbox matching the given role.
*/
export function findMailboxByRole(
mailboxes: Mailbox[],
role: UnifiedMailboxRole,
): Mailbox | undefined {
return mailboxes.find((m) => m.role === role);
}
/**
* Fetches emails from all accounts for a given unified role, merges and sorts
* them by receivedAt descending. Per-account failures are collected in the
* errors map while successful results are still returned.
*/
export async function fetchUnifiedEmails(
accounts: UnifiedAccountClient[],
role: UnifiedMailboxRole,
limit: number,
position: number,
): Promise<UnifiedFetchResult> {
const errors = new Map<string, string>();
// Build one fetch task per account, wrapping each in a catch so we can
// track per-account errors while still using Promise.allSettled.
type AccountResult = {
account: UnifiedAccountClient;
result: { emails: Email[]; total: number; hasMore: boolean };
} | null;
const promises = accounts.map(
async (account): Promise<AccountResult> => {
const mailbox = findMailboxByRole(account.mailboxes, role);
if (!mailbox) return null;
try {
const result = await account.client.getEmails(
mailbox.id,
undefined,
limit,
position,
);
return { account, result };
} catch (err) {
errors.set(
account.accountId,
err instanceof Error ? err.message : String(err),
);
return null;
}
},
);
const results = await Promise.allSettled(promises);
let mergedEmails: Email[] = [];
let totalSum = 0;
let anyHasMore = false;
for (const outcome of results) {
if (outcome.status !== 'fulfilled' || outcome.value === null) continue;
const { account, result } = outcome.value;
// Decorate each email with the source account info.
for (const email of result.emails) {
email.accountId = account.accountId;
email.accountLabel = account.accountLabel;
}
mergedEmails = mergedEmails.concat(result.emails);
totalSum += result.total;
if (result.hasMore) {
anyHasMore = true;
}
}
// Sort merged emails by receivedAt descending.
mergedEmails.sort((a, b) => {
const dateA = new Date(a.receivedAt).getTime();
const dateB = new Date(b.receivedAt).getTime();
return dateB - dateA;
});
return {
emails: mergedEmails,
total: totalSum,
hasMore: anyHasMore,
errors,
};
}
/**
* Aggregates unread and total email counts across all accounts for each
* unified mailbox role. Only includes roles that exist in at least one account.
*/
export function fetchUnifiedMailboxCounts(
accounts: UnifiedAccountClient[],
): UnifiedMailboxCounts[] {
const counts: UnifiedMailboxCounts[] = [];
for (const role of ALL_UNIFIED_ROLES) {
let unreadEmails = 0;
let totalEmails = 0;
let found = false;
for (const account of accounts) {
const mailbox = findMailboxByRole(account.mailboxes, role);
if (mailbox) {
found = true;
unreadEmails += mailbox.unreadEmails;
totalEmails += mailbox.totalEmails;
}
}
if (found) {
counts.push({ role, unreadEmails, totalEmails });
}
}
return counts;
}
/**
* Returns the list of unified roles that exist in at least one account's
* mailboxes.
*/
export function getUnifiedRoles(
accounts: UnifiedAccountClient[],
): UnifiedMailboxRole[] {
const roles: UnifiedMailboxRole[] = [];
for (const role of ALL_UNIFIED_ROLES) {
for (const account of accounts) {
if (findMailboxByRole(account.mailboxes, role)) {
roles.push(role);
break;
}
}
}
return roles;
}
+35 -1
View File
@@ -1,6 +1,7 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { Mailbox } from "./jmap/types";
import { Mailbox, UNIFIED_MAILBOX_IDS } from "./jmap/types";
import type { UnifiedMailboxRole } from "./jmap/types";
import { debug } from "./debug";
export function cn(...inputs: ClassValue[]) {
@@ -380,6 +381,39 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
return rootMailboxes;
}
/**
* Builds virtual MailboxNode entries for unified mailbox roles with aggregated counts.
*/
export function buildUnifiedMailboxNodes(
counts: Array<{ role: UnifiedMailboxRole; unreadEmails: number; totalEmails: number }>,
): MailboxNode[] {
return counts.map((count) => ({
id: UNIFIED_MAILBOX_IDS[count.role],
name: count.role, // Display name is handled by i18n in the component
role: count.role,
parentId: undefined,
sortOrder: 0,
totalEmails: count.totalEmails,
unreadEmails: count.unreadEmails,
totalThreads: 0,
unreadThreads: 0,
myRights: {
mayReadItems: true,
mayAddItems: false,
mayRemoveItems: false,
maySetSeen: true,
maySetKeywords: true,
mayCreateChild: false,
mayRename: false,
mayDelete: false,
maySubmit: false,
},
isSubscribed: true,
children: [],
depth: 0,
}));
}
// Flatten a mailbox tree for rendering with proper depth info
export function flattenMailboxTree(nodes: MailboxNode[]): MailboxNode[] {
const result: MailboxNode[] = [];
+14
View File
@@ -104,6 +104,13 @@
"spam": "Spam",
"important": "Wichtig"
},
"unified_inbox": "Gemeinsamer Posteingang",
"unified_sent": "Alle Gesendet",
"unified_drafts": "Alle Entwürfe",
"unified_trash": "Alle Papierkörbe",
"unified_archive": "Alle Archive",
"unified_junk": "Alle Spam",
"all_accounts": "Alle Konten",
"expand": "Erweitern",
"collapse": "Einklappen",
"expand_tooltip": "Erweitern",
@@ -722,6 +729,10 @@
"show_rail_account_list": {
"label": "Konto-Avatare in der Navigationsleiste anzeigen",
"description": "Individuelle Kontokreise am unteren Rand der Navigationsleiste für schnelles Umschalten anzeigen, mit einer Abmelde-Schaltfläche darunter."
},
"unified_mailbox": {
"label": "Gemeinsames Postfach",
"description": "Kombinierte Ordner (Posteingang, Gesendet usw.) für alle verbundenen Konten anzeigen"
}
},
"keywords": {
@@ -2533,5 +2544,8 @@
"demo_banner_desc": "Sie sind im Demo-Modus — alles bleibt in Ihrem Browser. Klicken Sie jederzeit auf 'Demo zurücksetzen'.",
"quota_title": "Speichernutzung",
"quota_desc": "Verfolgen Sie Ihre Postfachgröße hier. Der Kreis füllt sich mit zunehmendem Verbrauch."
},
"unified_mailbox": {
"search_unavailable": "Die Suche ist in der vereinheitlichten Ansicht nicht verfügbar"
}
}
+19
View File
@@ -104,6 +104,13 @@
"spam": "Spam",
"important": "Important"
},
"unified_inbox": "Unified Inbox",
"unified_sent": "All Sent",
"unified_drafts": "All Drafts",
"unified_trash": "All Trash",
"unified_archive": "All Archive",
"unified_junk": "All Junk",
"all_accounts": "All Accounts",
"expand": "Expand",
"collapse": "Collapse",
"expand_tooltip": "Expand",
@@ -120,6 +127,7 @@
"demo_tour": "Tour",
"tags": "Tags",
"folders": "Folders",
"shared": "Shared",
"mail": "Mail",
"nav_label": "Navigation",
"add_app": "Apps"
@@ -722,6 +730,14 @@
"show_rail_account_list": {
"label": "Show Account Avatars on Navigation Rail",
"description": "Display individual account circles at the bottom of the navigation rail for quick switching, with a sign-out button below."
},
"unified_mailbox": {
"label": "Unified Mailbox",
"description": "Show combined folders (Inbox, Sent, etc.) across all connected accounts"
},
"colorful_sidebar_icons": {
"label": "Colorful Sidebar Icons",
"description": "Tint folder and tag icons by type (blue Inbox, red Junk, green Sent, etc.). Disable for a monochrome sidebar."
}
},
"keywords": {
@@ -2533,5 +2549,8 @@
"demo_banner_desc": "You're in demo mode — everything stays in your browser. Hit 'Reset Demo' anytime to start fresh with clean sample data.",
"quota_title": "Storage usage",
"quota_desc": "Track your mailbox size here. The circle fills up as you use more space."
},
"unified_mailbox": {
"search_unavailable": "Search is not available in the unified view"
}
}
+14
View File
@@ -104,6 +104,13 @@
"spam": "Spam",
"important": "Importante"
},
"unified_inbox": "Bandeja unificada",
"unified_sent": "Todos los enviados",
"unified_drafts": "Todos los borradores",
"unified_trash": "Todas las papeleras",
"unified_archive": "Todos los archivos",
"unified_junk": "Todo el spam",
"all_accounts": "Todas las cuentas",
"expand": "Expandir",
"collapse": "Contraer",
"expand_tooltip": "Expandir",
@@ -722,6 +729,10 @@
"show_rail_account_list": {
"label": "Mostrar avatares de cuentas en la barra de navegación",
"description": "Mostrar círculos de cuentas individuales en la parte inferior de la barra de navegación para un cambio rápido, con un botón de cerrar sesión debajo."
},
"unified_mailbox": {
"label": "Buzón unificado",
"description": "Mostrar carpetas combinadas (Entrada, Enviados, etc.) de todas las cuentas conectadas"
}
},
"keywords": {
@@ -2533,5 +2544,8 @@
"demo_banner_desc": "Estás en modo demo — todo permanece en tu navegador. Haz clic en 'Restablecer demo' en cualquier momento.",
"quota_title": "Uso de almacenamiento",
"quota_desc": "Controla el tamaño de tu buzón aquí. El círculo se llena a medida que usas más espacio."
},
"unified_mailbox": {
"search_unavailable": "La búsqueda no está disponible en la vista unificada"
}
}
+14
View File
@@ -104,6 +104,13 @@
"spam": "Spam",
"important": "Important"
},
"unified_inbox": "Boîte de réception unifiée",
"unified_sent": "Tous les envoyés",
"unified_drafts": "Tous les brouillons",
"unified_trash": "Toutes les corbeilles",
"unified_archive": "Toutes les archives",
"unified_junk": "Tous les indésirables",
"all_accounts": "Tous les comptes",
"expand": "Développer",
"collapse": "Réduire",
"expand_tooltip": "Développer",
@@ -722,6 +729,10 @@
"show_rail_account_list": {
"label": "Afficher les avatars de compte sur la barre de navigation",
"description": "Afficher les cercles de comptes individuels en bas de la barre de navigation pour un changement rapide, avec un bouton de déconnexion en dessous."
},
"unified_mailbox": {
"label": "Boîte aux lettres unifiée",
"description": "Afficher les dossiers combinés (Réception, Envoyés, etc.) de tous les comptes connectés"
}
},
"keywords": {
@@ -2533,5 +2544,8 @@
"demo_banner_desc": "Vous êtes en mode démo — tout reste dans votre navigateur. Cliquez sur 'Réinitialiser la démo' à tout moment pour repartir avec des données fraîches.",
"quota_title": "Utilisation du stockage",
"quota_desc": "Suivez la taille de votre boîte mail ici. Le cercle se remplit au fur et à mesure que vous utilisez plus d'espace."
},
"unified_mailbox": {
"search_unavailable": "La recherche n'est pas disponible dans la vue unifiée"
}
}
+14
View File
@@ -104,6 +104,13 @@
"spam": "Spam",
"important": "Importanti"
},
"unified_inbox": "Posta in arrivo unificata",
"unified_sent": "Tutti gli inviati",
"unified_drafts": "Tutte le bozze",
"unified_trash": "Tutti i cestini",
"unified_archive": "Tutti gli archivi",
"unified_junk": "Tutto lo spam",
"all_accounts": "Tutti gli account",
"expand": "Espandi",
"collapse": "Comprimi",
"expand_tooltip": "Espandi",
@@ -722,6 +729,10 @@
"show_rail_account_list": {
"label": "Mostra avatar account nella barra di navigazione",
"description": "Visualizza i cerchi degli account individuali nella parte inferiore della barra di navigazione per un cambio rapido, con un pulsante di disconnessione sotto."
},
"unified_mailbox": {
"label": "Casella di posta unificata",
"description": "Mostra le cartelle combinate (Posta in arrivo, Inviati, ecc.) di tutti gli account collegati"
}
},
"keywords": {
@@ -2533,5 +2544,8 @@
"demo_banner_desc": "Sei in modalità demo — tutto rimane nel tuo browser. Premi 'Reimposta Demo' in qualsiasi momento per ricominciare con dati puliti.",
"quota_title": "Utilizzo dello spazio",
"quota_desc": "Monitora le dimensioni della tua casella qui. Il cerchio si riempie man mano che utilizzi più spazio."
},
"unified_mailbox": {
"search_unavailable": "La ricerca non è disponibile nella vista unificata"
}
}
+14
View File
@@ -104,6 +104,13 @@
"spam": "迷惑メール",
"important": "重要"
},
"unified_inbox": "統合受信トレイ",
"unified_sent": "すべての送信済み",
"unified_drafts": "すべての下書き",
"unified_trash": "すべてのゴミ箱",
"unified_archive": "すべてのアーカイブ",
"unified_junk": "すべての迷惑メール",
"all_accounts": "すべてのアカウント",
"expand": "展開",
"collapse": "折りたたむ",
"expand_tooltip": "展開",
@@ -722,6 +729,10 @@
"show_rail_account_list": {
"label": "ナビゲーションレールにアカウントアバターを表示",
"description": "ナビゲーションレールの下部に個々のアカウントの丸を表示して素早く切り替えできるようにし、その下にサインアウトボタンを配置します。"
},
"unified_mailbox": {
"label": "統合メールボックス",
"description": "接続されたすべてのアカウントの統合フォルダ(受信トレイ、送信済みなど)を表示"
}
},
"keywords": {
@@ -2533,5 +2544,8 @@
"demo_banner_desc": "デモモードです。すべてブラウザ内に保存されます。「デモをリセット」をクリックすると、いつでもクリーンなサンプルデータで再開できます。",
"quota_title": "ストレージ使用量",
"quota_desc": "メールボックスのサイズをここで確認できます。使用量が増えるとサークルが満たされます。"
},
"unified_mailbox": {
"search_unavailable": "統合ビューでは検索を利用できません"
}
}
+14
View File
@@ -104,6 +104,13 @@
"spam": "스팸함",
"important": "중요 편지함"
},
"unified_inbox": "통합 받은편지함",
"unified_sent": "모든 보낸편지함",
"unified_drafts": "모든 임시보관함",
"unified_trash": "모든 휴지통",
"unified_archive": "모든 보관함",
"unified_junk": "모든 스팸함",
"all_accounts": "모든 계정",
"expand": "펼치기",
"collapse": "접기",
"expand_tooltip": "펼치기",
@@ -722,6 +729,10 @@
"show_rail_account_list": {
"label": "내비게이션 바에 계정 아바타 표시",
"description": "내비게이션 바 아래에 계정 프로필을 표시해서 빠르게 전환할 수 있어요."
},
"unified_mailbox": {
"label": "통합 메일함",
"description": "연결된 모든 계정의 통합 폴더(받은편지함, 보낸편지함 등)를 표시합니다"
}
},
"keywords": {
@@ -2533,5 +2544,8 @@
"demo_banner_desc": "현재 데모 모드예요. 모든 작업은 브라우저 안에서만 이뤄집니다. 언제든 '초기화'를 누르면 처음의 깨끗한 샘플 데이터로 돌아가요.",
"quota_title": "저장 공간 사용량",
"quota_desc": "편지함 용량을 여기서 확인하세요. 공간을 많이 쓸수록 원이 점점 채워질 거예요."
},
"unified_mailbox": {
"search_unavailable": "통합 보기에서는 검색을 사용할 수 없습니다"
}
}
+14
View File
@@ -104,6 +104,13 @@
"spam": "Mēstules",
"important": "Svarīgi"
},
"unified_inbox": "Apvienotā iesūtne",
"unified_sent": "Visi nosūtītie",
"unified_drafts": "Visi melnraksti",
"unified_trash": "Visas mēstules",
"unified_archive": "Visi arhīvi",
"unified_junk": "Viss mēstules",
"all_accounts": "Visi konti",
"expand": "Izvērst",
"collapse": "Sairt",
"expand_tooltip": "Izvērst",
@@ -722,6 +729,10 @@
"show_rail_account_list": {
"label": "Rādīt kontu avatarus navigācijas joslā",
"description": "Rādīt kontu apļus navigācijas joslas apakšā ātrai pārslēgšanai."
},
"unified_mailbox": {
"label": "Apvienotā pastkaste",
"description": "Rādīt apvienotās mapes (Iesūtne, Nosūtītie u.c.) no visiem pievienotajiem kontiem"
}
},
"keywords": {
@@ -2533,5 +2544,8 @@
"demo_banner_desc": "Jūs esat demo režīmā — visi dati paliek jūsu pārlūkā. Nospiediet «Atiestatīt demo», lai sāktu no jauna.",
"quota_title": "Krātuves izmantošana",
"quota_desc": "Sekojiet līdzi savas pastkastes aizpildījumam šeit."
},
"unified_mailbox": {
"search_unavailable": "Meklēšana nav pieejama apvienotajā skatā"
}
}
+14
View File
@@ -104,6 +104,13 @@
"spam": "Spam",
"important": "Belangrijk"
},
"unified_inbox": "Gecombineerd postvak IN",
"unified_sent": "Alle verzonden",
"unified_drafts": "Alle concepten",
"unified_trash": "Alle prullenbakken",
"unified_archive": "Alle archieven",
"unified_junk": "Alle spam",
"all_accounts": "Alle accounts",
"expand": "Uitklappen",
"collapse": "Inklappen",
"expand_tooltip": "Uitklappen",
@@ -722,6 +729,10 @@
"show_rail_account_list": {
"label": "Accountavatars tonen op navigatiebalk",
"description": "Toon individuele accountcirkels onderaan de navigatiebalk voor snel wisselen, met een afmeldknop eronder."
},
"unified_mailbox": {
"label": "Gecombineerd postvak",
"description": "Gecombineerde mappen (Postvak IN, Verzonden, enz.) van alle verbonden accounts weergeven"
}
},
"keywords": {
@@ -2533,5 +2544,8 @@
"demo_banner_desc": "U bent in demomodus — alles blijft in uw browser. Klik op 'Demo resetten' om opnieuw te beginnen met schone voorbeeldgegevens.",
"quota_title": "Opslaggebruik",
"quota_desc": "Volg de grootte van uw mailbox hier. De cirkel vult zich naarmate u meer ruimte gebruikt."
},
"unified_mailbox": {
"search_unavailable": "Zoeken is niet beschikbaar in de gecombineerde weergave"
}
}
+14
View File
@@ -104,6 +104,13 @@
"spam": "Spam",
"important": "Ważne"
},
"unified_inbox": "Wspólne odebrane",
"unified_sent": "Wszystkie wysłane",
"unified_drafts": "Wszystkie szkice",
"unified_trash": "Wszystkie kosze",
"unified_archive": "Wszystkie archiwa",
"unified_junk": "Wszystkie spam",
"all_accounts": "Wszystkie konta",
"expand": "Rozwiń",
"collapse": "Zwiń",
"expand_tooltip": "Rozwiń",
@@ -722,6 +729,10 @@
"show_rail_account_list": {
"label": "Pokazuj awatary kont na pasku nawigacyjnym",
"description": "Wyświetlaj osobne ikony kont na dole paska nawigacyjnego, aby szybko się przełączać, z przyciskiem wylogowania poniżej."
},
"unified_mailbox": {
"label": "Wspólna skrzynka",
"description": "Wyświetlaj połączone foldery (Odebrane, Wysłane itp.) ze wszystkich połączonych kont"
}
},
"keywords": {
@@ -2533,5 +2544,8 @@
"demo_banner_desc": "Jesteś w trybie demo — wszystko pozostaje w Twojej przeglądarce. W każdej chwili kliknij „Reset Demo”, aby zacząć od nowa z czystymi danymi przykładowymi.",
"quota_title": "Wykorzystanie miejsca",
"quota_desc": "Tutaj możesz śledzić rozmiar swojej skrzynki pocztowej. Okrąg wypełnia się wraz ze wzrostem użycia przestrzeni."
},
"unified_mailbox": {
"search_unavailable": "Wyszukiwanie jest niedostępne w widoku ujednoliconym"
}
}
+14
View File
@@ -104,6 +104,13 @@
"spam": "Spam",
"important": "Importante"
},
"unified_inbox": "Caixa de entrada unificada",
"unified_sent": "Todos os enviados",
"unified_drafts": "Todos os rascunhos",
"unified_trash": "Todas as lixeiras",
"unified_archive": "Todos os arquivos",
"unified_junk": "Todo o spam",
"all_accounts": "Todas as contas",
"expand": "Expandir",
"collapse": "Recolher",
"expand_tooltip": "Expandir",
@@ -722,6 +729,10 @@
"show_rail_account_list": {
"label": "Mostrar avatares de conta na barra de navegação",
"description": "Exibir círculos de contas individuais na parte inferior da barra de navegação para troca rápida, com um botão de sair abaixo."
},
"unified_mailbox": {
"label": "Caixa de correio unificada",
"description": "Mostrar pastas combinadas (Entrada, Enviados, etc.) de todas as contas conectadas"
}
},
"keywords": {
@@ -2533,5 +2544,8 @@
"demo_banner_desc": "Está no modo de demonstração — tudo permanece no seu navegador. Clique em 'Repor Demonstração' a qualquer momento para recomeçar com dados limpos.",
"quota_title": "Utilização do armazenamento",
"quota_desc": "Acompanhe o tamanho da sua caixa de correio aqui. O círculo preenche-se à medida que utiliza mais espaço."
},
"unified_mailbox": {
"search_unavailable": "A pesquisa não está disponível na vista unificada"
}
}
+14
View File
@@ -104,6 +104,13 @@
"spam": "Спам",
"important": "Важные"
},
"unified_inbox": "Общие входящие",
"unified_sent": "Все отправленные",
"unified_drafts": "Все черновики",
"unified_trash": "Все корзины",
"unified_archive": "Все архивы",
"unified_junk": "Весь спам",
"all_accounts": "Все аккаунты",
"expand": "Развернуть",
"collapse": "Свернуть",
"expand_tooltip": "Развернуть",
@@ -722,6 +729,10 @@
"show_rail_account_list": {
"label": "Показать аватары аккаунтов на панели навигации",
"description": "Отображать отдельные круги аккаунтов в нижней части панели навигации для быстрого переключения, с кнопкой выхода ниже."
},
"unified_mailbox": {
"label": "Общий почтовый ящик",
"description": "Показывать объединённые папки (Входящие, Отправленные и др.) для всех подключённых аккаунтов"
}
},
"keywords": {
@@ -2533,5 +2544,8 @@
"demo_banner_desc": "Вы в демо-режиме — всё остаётся в вашем браузере. Нажмите «Сбросить демо» в любое время, чтобы начать заново с чистыми данными.",
"quota_title": "Использование хранилища",
"quota_desc": "Отслеживайте размер вашего почтового ящика здесь. Круг заполняется по мере использования пространства."
},
"unified_mailbox": {
"search_unavailable": "Поиск недоступен в объединённом представлении"
}
}
+14
View File
@@ -104,6 +104,13 @@
"spam": "Спам",
"important": "важливо"
},
"unified_inbox": "Спільні вхідні",
"unified_sent": "Усі надіслані",
"unified_drafts": "Усі чернетки",
"unified_trash": "Усі кошики",
"unified_archive": "Усі архіви",
"unified_junk": "Весь спам",
"all_accounts": "Усі облікові записи",
"expand": "Розгорнути",
"collapse": "Згорнути",
"expand_tooltip": "Розгорнути",
@@ -722,6 +729,10 @@
"show_rail_account_list": {
"label": "Показувати аватари облікових записів на панелі навігації",
"description": "Відображати кола окремих облікових записів у нижній частині панелі навігації для швидкого перемикання з кнопкою виходу внизу."
},
"unified_mailbox": {
"label": "Спільна поштова скринька",
"description": "Показувати об'єднані папки (Вхідні, Надіслані тощо) для всіх підключених облікових записів"
}
},
"keywords": {
@@ -2533,5 +2544,8 @@
"demo_banner_desc": "Ви в демонстраційному режимі — все залишається у вашому браузері. Будь-коли натисніть «Скинути демонстрацію», щоб почати заново з чистими зразками даних.",
"quota_title": "Використання сховища",
"quota_desc": "Відстежуйте розмір своєї поштової скриньки тут. Коло заповнюється, коли ви використовуєте більше місця."
},
"unified_mailbox": {
"search_unavailable": "Пошук недоступний в об'єднаному перегляді"
}
}
+14
View File
@@ -104,6 +104,13 @@
"spam": "垃圾邮件",
"important": "重要"
},
"unified_inbox": "统一收件箱",
"unified_sent": "所有已发送",
"unified_drafts": "所有草稿",
"unified_trash": "所有已删除",
"unified_archive": "所有归档",
"unified_junk": "所有垃圾邮件",
"all_accounts": "所有账户",
"expand": "展开",
"collapse": "收起",
"expand_tooltip": "展开",
@@ -722,6 +729,10 @@
"show_rail_account_list": {
"label": "在导航导轨上显示账户头像",
"description": "在导航栏底部显示账户头像,方便快速切换;下方会保留退出按钮。"
},
"unified_mailbox": {
"label": "统一邮箱",
"description": "显示所有已连接账户的合并文件夹(收件箱、已发送等)"
}
},
"keywords": {
@@ -2533,5 +2544,8 @@
"demo_banner_desc": "当前为演示模式,所有数据仅保存在浏览器中。随时点击\"重置演示\"即可恢复初始示例数据。",
"quota_title": "存储使用情况",
"quota_desc": "在这里查看邮箱的存储使用情况。随着空间使用增加,进度圆环会逐渐填满。"
},
"unified_mailbox": {
"search_unavailable": "统一视图中无法使用搜索"
}
}
+5
View File
@@ -49,6 +49,7 @@ interface AuthState {
syncIdentities: () => void;
refreshIdentities: () => Promise<void>;
getClientForAccount: (accountId: string) => JMAPClient | undefined;
getAllConnectedClients: () => Map<string, JMAPClient>;
}
const ERROR_PATTERNS: Array<{ key: string; matches: string[] }> = [
@@ -1529,6 +1530,10 @@ export const useAuthStore = create<AuthState>()(
getClientForAccount: (accountId: string) => {
return clients.get(accountId);
},
getAllConnectedClients: () => {
return new Map(clients);
},
}),
{
name: 'auth-storage',
+206 -6
View File
@@ -1,10 +1,14 @@
import { create } from "zustand";
import { Email, Mailbox, StateChange } from "@/lib/jmap/types";
import { Email, Mailbox, StateChange, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/lib/jmap/types";
import type { UnifiedMailboxRole } from "@/lib/jmap/types";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import { useSettingsStore } from "@/stores/settings-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
import { emailHooks } from "@/lib/plugin-hooks";
import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
interface EmailStore {
emails: Email[];
@@ -39,6 +43,12 @@ interface EmailStore {
isAdvancedSearchOpen: boolean;
searchAbortController: AbortController | null;
// Unified mailbox state
isUnifiedView: boolean;
unifiedRole: UnifiedMailboxRole | null;
unifiedErrors: Map<string, string>; // accountId -> error message
unifiedCounts: UnifiedMailboxCounts[];
setEmails: (emails: Email[]) => void;
setMailboxes: (mailboxes: Mailbox[]) => void;
selectEmail: (email: Email | null) => void;
@@ -107,6 +117,12 @@ interface EmailStore {
setMailboxRole: (client: IJMAPClient, mailboxId: string, role: string | null) => Promise<void>;
emptyMailbox: (client: IJMAPClient, mailboxId: string) => Promise<void>;
// Unified mailbox operations
fetchUnifiedEmails: (accounts: UnifiedAccountClient[], role: UnifiedMailboxRole) => Promise<void>;
loadMoreUnifiedEmails: (accounts: UnifiedAccountClient[]) => Promise<void>;
refreshUnifiedCounts: (accounts: UnifiedAccountClient[]) => Promise<void>;
exitUnifiedView: () => void;
// Mock data for demo
loadMockData: () => void;
}
@@ -175,6 +191,12 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
isAdvancedSearchOpen: false,
searchAbortController: null,
// Unified mailbox state
isUnifiedView: false,
unifiedRole: null,
unifiedErrors: new Map(),
unifiedCounts: [],
// Spam undo cache
spamUndoCache: new Map(),
@@ -334,11 +356,52 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
},
loadMoreEmails: async (client) => {
const { isLoadingMore, hasMoreEmails, emails, selectedMailbox, searchQuery, selectedKeyword } = get();
const { isLoadingMore, hasMoreEmails, emails, selectedMailbox, searchQuery, selectedKeyword, isUnifiedView, unifiedRole } = get();
// Don't load if already loading or no more emails
if (isLoadingMore || !hasMoreEmails) return;
// Unified view uses a different fan-out loader. Rebuild the per-account
// client list from auth/account stores and delegate.
if (isUnifiedView && unifiedRole) {
set({ isLoadingMore: true, error: null });
try {
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const position = emails.length;
const authAccounts = useAccountStore.getState().accounts.filter(a => a.isConnected);
const allClients = useAuthStore.getState().getAllConnectedClients();
const built: UnifiedAccountClient[] = [];
for (const a of authAccounts) {
const c = allClients.get(a.id);
if (!c) continue;
try {
const mailboxes = await c.getMailboxes();
built.push({ accountId: a.id, accountLabel: a.label || a.email, client: c, mailboxes });
} catch {
/* skip account on mailbox fetch failure */
}
}
const result = await fetchUnifiedEmails(built, unifiedRole, emailsPerPage, position);
const currentEmails = get().emails;
const existingIds = new Set(currentEmails.map(e => e.id));
const newEmails = result.emails.filter(e => !existingIds.has(e.id));
set({
emails: [...currentEmails, ...newEmails],
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoadingMore: false,
unifiedErrors: result.errors,
});
} catch (error) {
console.error('Failed to load more unified emails:', error);
set({
error: error instanceof Error ? error.message : "Failed to load more emails",
isLoadingMore: false,
});
}
return;
}
set({ isLoadingMore: true, error: null });
try {
// Get emails per page from settings
@@ -919,7 +982,26 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
set({ isLoading: true, error: null });
try {
const emailIdsArray = Array.from(selectedEmailIds);
await client.batchMarkAsRead(emailIdsArray, read);
if (get().isUnifiedView) {
// Group emails by accountId for cross-account operations
const emailsByAccount = new Map<string, string[]>();
for (const emailId of emailIdsArray) {
const email = emails.find(e => e.id === emailId);
const acctId = email?.accountId || '__default__';
if (!emailsByAccount.has(acctId)) emailsByAccount.set(acctId, []);
emailsByAccount.get(acctId)!.push(emailId);
}
const promises = Array.from(emailsByAccount.entries()).map(async ([acctId, ids]) => {
const acctClient = acctId === '__default__' ? client : useAuthStore.getState().getClientForAccount(acctId);
if (!acctClient) return;
await acctClient.batchMarkAsRead(ids, read);
});
await Promise.allSettled(promises);
} else {
await client.batchMarkAsRead(emailIdsArray, read);
}
// Update local state
const updatedEmails = emails.map(email =>
@@ -969,7 +1051,26 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
set({ isLoading: true, error: null });
try {
const emailIdsArray = Array.from(selectedEmailIds);
await client.batchDeleteEmails(emailIdsArray);
if (get().isUnifiedView) {
// Group emails by accountId for cross-account operations
const emailsByAccount = new Map<string, string[]>();
for (const emailId of emailIdsArray) {
const email = emails.find(e => e.id === emailId);
const acctId = email?.accountId || '__default__';
if (!emailsByAccount.has(acctId)) emailsByAccount.set(acctId, []);
emailsByAccount.get(acctId)!.push(emailId);
}
const promises = Array.from(emailsByAccount.entries()).map(async ([acctId, ids]) => {
const acctClient = acctId === '__default__' ? client : useAuthStore.getState().getClientForAccount(acctId);
if (!acctClient) return;
await acctClient.batchDeleteEmails(ids);
});
await Promise.allSettled(promises);
} else {
await client.batchDeleteEmails(emailIdsArray);
}
// Remove deleted emails from local state
const remainingEmails = emails.filter(e => !selectedEmailIds.has(e.id));
@@ -1020,7 +1121,26 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
set({ isLoading: true, error: null });
try {
const emailIdsArray = Array.from(selectedEmailIds);
await client.batchMoveEmails(emailIdsArray, toMailboxId);
if (get().isUnifiedView) {
// Group emails by accountId for cross-account operations
const emailsByAccount = new Map<string, string[]>();
for (const emailId of emailIdsArray) {
const email = emails.find(e => e.id === emailId);
const acctId = email?.accountId || '__default__';
if (!emailsByAccount.has(acctId)) emailsByAccount.set(acctId, []);
emailsByAccount.get(acctId)!.push(emailId);
}
const promises = Array.from(emailsByAccount.entries()).map(async ([acctId, ids]) => {
const acctClient = acctId === '__default__' ? client : useAuthStore.getState().getClientForAccount(acctId);
if (!acctClient) return;
await acctClient.batchMoveEmails(ids, toMailboxId);
});
await Promise.allSettled(promises);
} else {
await client.batchMoveEmails(emailIdsArray, toMailboxId);
}
// Update local state - remove from current view since they moved
const remainingEmails = emails.filter(e => !selectedEmailIds.has(e.id));
@@ -1032,7 +1152,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
});
// Refresh emails to get updated list
await get().fetchEmails(client, get().selectedMailbox);
if (!get().isUnifiedView) {
await get().fetchEmails(client, get().selectedMailbox);
}
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to move emails",
@@ -1481,6 +1603,84 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
},
// Unified mailbox operations
fetchUnifiedEmails: async (accounts, role) => {
set({
isLoading: true,
error: null,
isUnifiedView: true,
unifiedRole: role,
selectedKeyword: null,
});
try {
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const result = await fetchUnifiedEmails(accounts, role, emailsPerPage, 0);
set({
emails: result.emails,
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoading: false,
unifiedErrors: result.errors,
});
} catch (error) {
console.error('Failed to fetch unified emails:', error);
set({
error: error instanceof Error ? error.message : "Failed to fetch unified emails",
isLoading: false,
emails: [],
hasMoreEmails: false,
totalEmails: 0,
});
}
},
loadMoreUnifiedEmails: async (accounts) => {
const { isLoadingMore, hasMoreEmails, emails, unifiedRole } = get();
if (isLoadingMore || !hasMoreEmails || !unifiedRole) return;
set({ isLoadingMore: true, error: null });
try {
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const position = emails.length;
const result = await fetchUnifiedEmails(accounts, unifiedRole, emailsPerPage, position);
const currentEmails = get().emails;
const existingIds = new Set(currentEmails.map(e => e.id));
const newEmails = result.emails.filter(e => !existingIds.has(e.id));
set({
emails: [...currentEmails, ...newEmails],
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoadingMore: false,
unifiedErrors: result.errors,
});
} catch (error) {
console.error('Failed to load more unified emails:', error);
set({
error: error instanceof Error ? error.message : "Failed to load more unified emails",
isLoadingMore: false,
});
}
},
refreshUnifiedCounts: async (accounts) => {
try {
const counts = fetchUnifiedMailboxCounts(accounts);
set({ unifiedCounts: counts });
} catch (error) {
console.error('Failed to refresh unified counts:', error);
}
},
exitUnifiedView: () => {
set({
isUnifiedView: false,
unifiedRole: null,
unifiedErrors: new Map(),
});
},
loadMockData: () => {
const mockEmails: Email[] = [
{
+14
View File
@@ -175,12 +175,18 @@ interface SettingsState {
hideAccountSwitcher: boolean;
showRailAccountList: boolean;
// Unified Mailbox
enableUnifiedMailbox: boolean;
// Email Display
disableThreading: boolean; // Show emails as individual messages instead of grouped by conversation
// Experimental
senderFavicons: boolean;
// Sidebar
colorfulSidebarIcons: boolean; // Tint folder icons by role (inbox blue, junk red, etc.)
// Folders
folderIcons: Record<string, string>; // mailboxId -> icon name
@@ -309,12 +315,18 @@ const DEFAULT_SETTINGS = {
hideAccountSwitcher: false,
showRailAccountList: false,
// Unified Mailbox
enableUnifiedMailbox: false,
// Email Display
disableThreading: false,
// Experimental
senderFavicons: true,
// Sidebar
colorfulSidebarIcons: true,
// Folders
folderIcons: {} as Record<string, string>,
@@ -447,7 +459,9 @@ export const useSettingsStore = create<SettingsState>()(
toolbarPosition: state.toolbarPosition,
hideAccountSwitcher: state.hideAccountSwitcher,
showRailAccountList: state.showRailAccountList,
enableUnifiedMailbox: state.enableUnifiedMailbox,
senderFavicons: state.senderFavicons,
colorfulSidebarIcons: state.colorfulSidebarIcons,
folderIcons: state.folderIcons,
emailKeywords: state.emailKeywords,
attachmentReminderEnabled: state.attachmentReminderEnabled,