mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 08:12:43 +00:00
# Conflicts: # components/contacts/contacts-app.tsx # components/email/__tests__/composer-discard.test.tsx # components/email/calendar-invitation-banner.tsx # components/email/email-composer.tsx # components/mail/mail-app.tsx # components/pro/pro-compose-tab-body.tsx # components/pro/pro-email-tab-body.tsx # components/ui/__tests__/avatar.test.tsx # components/ui/avatar.tsx # hooks/use-mailbox-drop.ts # lib/__tests__/calendar-alerts.test.ts # lib/__tests__/jmap-calendar-tasks.test.ts # lib/__tests__/jmap-send-threading.test.ts # lib/demo/demo-client.ts # lib/demo/fixtures/calendars.ts # lib/jmap/client-interface.ts # lib/jmap/client.ts # lib/web-push.ts # stores/__tests__/contact-store.test.ts # stores/__tests__/email-store-thread-key.test.ts # stores/calendar-store.ts # stores/contact-store.ts # stores/email-store.ts
313 lines
14 KiB
TypeScript
313 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useTranslations } from "next-intl";
|
|
import { Folder, Loader2, Paperclip, RefreshCw, Star } from "@/components/icons";
|
|
import { cn, cleanPreview, formatDate } from "@/lib/utils";
|
|
import { emailDisplayDate } from "@/lib/email-date";
|
|
import { localizeMailboxName } from "@/lib/mailbox-label";
|
|
import { EmailViewer } from "@/components/email/email-viewer";
|
|
import { ProEmailView } from "@/components/pro/pro-email-tab-body";
|
|
import { useAuthStore } from "@/stores/auth-store";
|
|
import { useEmailStore } from "@/stores/email-store";
|
|
import { getMessageListOrderFor, useSettingsStore } from "@/stores/settings-store";
|
|
import { useProTabStore, type ProFolderTabData } from "@/stores/pro-tab-store";
|
|
import { useDeviceDetection } from "@/hooks/use-media-query";
|
|
import { usePaneId } from "@/hooks/use-pane-context";
|
|
import type { Email, Mailbox } from "@/lib/jmap/types";
|
|
import { emailHooks } from "@/lib/plugin-hooks";
|
|
import { scheduleAutoMarkAsRead } from "@/lib/mark-as-read-delay";
|
|
|
|
interface ProFolderTabBodyProps {
|
|
tabId: string;
|
|
data: ProFolderTabData;
|
|
}
|
|
|
|
/**
|
|
* A mailbox opened in its own Pro tab (sidebar folder dragged onto the tab
|
|
* strip). Self-sufficient like `ProEmailTabBody`: it queries its folder
|
|
* directly through the owning account's JMAP client instead of touching the
|
|
* email-store's single global list, so it never fights the Mail tab (or
|
|
* another folder tab) over `selectedMailbox`.
|
|
*
|
|
* Wide panes render list + reading pane; a phone-narrow pane renders just the
|
|
* list and opens messages as dedicated email tabs.
|
|
*/
|
|
export function ProFolderTabBody({ tabId, data }: ProFolderTabBodyProps) {
|
|
const t = useTranslations();
|
|
const tList = useTranslations('email_list');
|
|
const tSidebar = useTranslations('sidebar');
|
|
|
|
const activeClient = useAuthStore((s) => s.client);
|
|
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
|
const getClientForAccount = useAuthStore((s) => s.getClientForAccount);
|
|
const activeMailboxes = useEmailStore((s) => s.mailboxes);
|
|
const accountMailboxes = useEmailStore((s) => s.accountMailboxes);
|
|
const emailsPerPage = useSettingsStore((s) => s.emailsPerPage);
|
|
const updateTabTitle = useProTabStore((s) => s.updateTabTitle);
|
|
const paneId = usePaneId();
|
|
// Pane-scoped: in a phone-narrow pane there is no room for a reading pane.
|
|
const { isMobile } = useDeviceDetection();
|
|
|
|
const client = useMemo(
|
|
() => (data.accountId ? getClientForAccount(data.accountId) ?? activeClient : activeClient),
|
|
[data.accountId, getClientForAccount, activeClient],
|
|
);
|
|
// The login `client` belongs to. A message of this folder is read again
|
|
// through it, and a draft reopened from it is saved over and removed
|
|
// through it: the same ids name other messages on another server.
|
|
const clientLogin = (data.accountId && getClientForAccount(data.accountId) ? data.accountId : activeAccountId) ?? undefined;
|
|
|
|
// The folder's metadata, resolved from whichever account's mailbox list it
|
|
// was dragged from. Shared folders are namespaced in the store, so JMAP
|
|
// calls need originalId + the owner accountId (mirrors fetchEmails).
|
|
const mailbox: Mailbox | undefined = useMemo(() => {
|
|
const list = data.accountId ? accountMailboxes[data.accountId] ?? [] : activeMailboxes;
|
|
return list.find((m) => m.id === data.mailboxId);
|
|
}, [data.accountId, data.mailboxId, accountMailboxes, activeMailboxes]);
|
|
const jmapMailboxId = mailbox?.originalId || data.mailboxId;
|
|
const jmapAccountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
|
|
|
const [emails, setEmails] = useState<Email[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [hasMore, setHasMore] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
const [selectedEmailId, setSelectedEmailId] = useState<string | null>(null);
|
|
const fetchSeqRef = useRef(0);
|
|
|
|
const folderLabel = mailbox
|
|
? localizeMailboxName(mailbox.role, mailbox.name, (k) => tSidebar(`mailboxes.${k}`))
|
|
: data.title;
|
|
|
|
// Keep the tab title in sync with the folder's (possibly localized) name.
|
|
useEffect(() => {
|
|
if (folderLabel && folderLabel !== data.title) updateTabTitle(tabId, folderLabel);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [folderLabel, tabId, updateTabTitle]);
|
|
|
|
// The configured list order (#718) depends on the folder's role (Inbox-only
|
|
// by default), and every page of the tab must ask for the same order.
|
|
const mailboxRole = mailbox?.role;
|
|
const loadPage = useCallback(async (position: number) => {
|
|
if (!client) return;
|
|
const seq = ++fetchSeqRef.current;
|
|
if (position === 0) setIsLoading(true); else setIsLoadingMore(true);
|
|
try {
|
|
const result = await client.getEmails(jmapMailboxId, jmapAccountId, emailsPerPage, position, undefined, true, undefined, getMessageListOrderFor(mailboxRole));
|
|
if (seq !== fetchSeqRef.current) return;
|
|
setEmails((prev) => {
|
|
if (position === 0) return result.emails;
|
|
const known = new Set(prev.map((e) => e.id));
|
|
return [...prev, ...result.emails.filter((e) => !known.has(e.id))];
|
|
});
|
|
setTotal(result.total);
|
|
setHasMore(result.hasMore);
|
|
} catch (error) {
|
|
// Keep what is on screen: a failed page is not an empty folder.
|
|
console.error('Failed to load folder tab:', error);
|
|
} finally {
|
|
if (seq === fetchSeqRef.current) {
|
|
if (position === 0) setIsLoading(false); else setIsLoadingMore(false);
|
|
}
|
|
}
|
|
}, [client, jmapMailboxId, jmapAccountId, emailsPerPage, mailboxRole]);
|
|
|
|
useEffect(() => {
|
|
setSelectedEmailId(null);
|
|
void loadPage(0);
|
|
}, [loadPage]);
|
|
|
|
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
|
if (!hasMore || isLoadingMore || isLoading) return;
|
|
const el = e.currentTarget;
|
|
if (el.scrollHeight - el.scrollTop - el.clientHeight < 200) {
|
|
void loadPage(emails.length);
|
|
}
|
|
}, [hasMore, isLoadingMore, isLoading, loadPage, emails.length]);
|
|
|
|
const markRowAsRead = useCallback(async (email: Email) => {
|
|
if (!client || email.keywords?.$seen) return;
|
|
// Straight to the client: the store's markAsRead only serves emails in
|
|
// its own (global) list, which this tab's rows are not part of. So the
|
|
// plugin hooks the store action fires are fired here.
|
|
if (emailHooks.onBeforeEmailReadStateChange.size > 0
|
|
&& !(await emailHooks.onBeforeEmailReadStateChange.intercept({ emailIds: [email.id], read: true }))) {
|
|
return;
|
|
}
|
|
setEmails((prev) => prev.map((e) => e.id === email.id
|
|
? { ...e, keywords: { ...e.keywords, $seen: true } }
|
|
: e));
|
|
try {
|
|
await client.markAsRead(email.id, true, jmapAccountId);
|
|
void emailHooks.onEmailReadStateChange.emit({ emailIds: [email.id], read: true });
|
|
} catch (err) {
|
|
console.error('Mark as read failed:', err);
|
|
}
|
|
}, [client, jmapAccountId]);
|
|
|
|
const openAsEmailTab = useCallback((email: Email, reuseReader: boolean) => {
|
|
useProTabStore.getState().openEmailTab(
|
|
{
|
|
accountId: jmapAccountId ?? '',
|
|
clientAccountId: clientLogin,
|
|
emailId: email.id,
|
|
mailboxId: data.mailboxId,
|
|
title: email.subject?.trim() || t('email_composer.new_message'),
|
|
},
|
|
{ pane: paneId ?? 'main', reuseReader },
|
|
);
|
|
}, [jmapAccountId, clientLogin, data.mailboxId, paneId, t]);
|
|
|
|
// Opening a row marks it read on the user's markAsReadDelay, like the
|
|
// reading pane does; a pending delayed mark is dropped when another row is
|
|
// opened or the tab goes away.
|
|
const cancelRowReadRef = useRef<(() => void) | null>(null);
|
|
useEffect(() => () => cancelRowReadRef.current?.(), []);
|
|
|
|
const handleRowClick = useCallback((email: Email) => {
|
|
cancelRowReadRef.current?.();
|
|
cancelRowReadRef.current = scheduleAutoMarkAsRead(
|
|
useSettingsStore.getState().markAsReadDelay,
|
|
() => void markRowAsRead(email),
|
|
);
|
|
if (isMobile) {
|
|
// No room for a reading pane - promote to a dedicated email tab.
|
|
openAsEmailTab(email, true);
|
|
return;
|
|
}
|
|
setSelectedEmailId(email.id);
|
|
}, [markRowAsRead, isMobile, openAsEmailTab]);
|
|
|
|
// The selected message left the folder (deleted / archived / moved /
|
|
// reopened as a draft): drop its row and clear the reading pane.
|
|
const handleViewerClose = useCallback(() => {
|
|
setSelectedEmailId((current) => {
|
|
if (current) setEmails((prev) => prev.filter((e) => e.id !== current));
|
|
return null;
|
|
});
|
|
}, []);
|
|
|
|
// Sent/Drafts show the recipient - the sender is always "me" there.
|
|
const showRecipient = mailbox?.role === 'sent' || mailbox?.role === 'drafts';
|
|
|
|
const list = (
|
|
<div className={cn("flex h-full flex-col bg-background", isMobile ? "w-full" : "w-96 flex-shrink-0 border-e border-border")}>
|
|
<div className="flex h-10 flex-shrink-0 items-center gap-2 border-b border-border px-3">
|
|
<Folder className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
|
<span className="min-w-0 truncate text-sm font-medium">{folderLabel}</span>
|
|
{total > 0 && (
|
|
<span className="text-xs tabular-nums text-muted-foreground">{total}</span>
|
|
)}
|
|
<button
|
|
onClick={() => void loadPage(0)}
|
|
className="ms-auto flex h-7 w-7 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
title={t('common.refresh')}
|
|
aria-label={t('common.refresh')}
|
|
>
|
|
<RefreshCw className={cn("h-3.5 w-3.5", isLoading && "animate-spin")} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto" onScroll={handleScroll}>
|
|
{isLoading && emails.length === 0 ? (
|
|
<div className="flex items-center justify-center py-10">
|
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : emails.length === 0 ? (
|
|
<div className="px-4 py-10 text-center text-sm text-muted-foreground">
|
|
{tList('no_emails')}
|
|
</div>
|
|
) : (
|
|
<>
|
|
{emails.map((email) => {
|
|
const isUnread = !email.keywords?.$seen;
|
|
const isStarred = email.keywords?.$flagged === true;
|
|
const counterpart = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
|
const who = counterpart?.name || counterpart?.email || '';
|
|
return (
|
|
<div
|
|
key={email.id}
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={() => handleRowClick(email)}
|
|
onDoubleClick={() => openAsEmailTab(email, false)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
handleRowClick(email);
|
|
}
|
|
}}
|
|
className={cn(
|
|
"cursor-pointer border-b border-border/50 px-3 py-2 transition-colors",
|
|
email.id === selectedEmailId
|
|
? "bg-accent text-accent-foreground"
|
|
: "hover:bg-muted/50",
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
{isUnread && (
|
|
<span className="h-2 w-2 flex-shrink-0 rounded-full bg-primary" aria-hidden="true" />
|
|
)}
|
|
<span className={cn("min-w-0 flex-1 truncate text-sm", isUnread ? "font-semibold" : "text-foreground/90")}>
|
|
{who}
|
|
</span>
|
|
<span className="flex-shrink-0 text-xs text-muted-foreground">
|
|
{formatDate(emailDisplayDate(email))}
|
|
</span>
|
|
</div>
|
|
<div className="mt-0.5 flex items-center gap-1.5">
|
|
<span className={cn("min-w-0 flex-1 truncate text-sm", isUnread ? "font-medium" : "text-muted-foreground")}>
|
|
{email.subject || '—'}
|
|
</span>
|
|
{email.hasAttachment && (
|
|
<Paperclip className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" aria-hidden="true" />
|
|
)}
|
|
{isStarred && (
|
|
<Star className="h-3.5 w-3.5 flex-shrink-0 fill-yellow-400 text-yellow-400" aria-hidden="true" />
|
|
)}
|
|
</div>
|
|
{cleanPreview(email.preview) && (
|
|
<div className="mt-0.5 truncate text-xs text-muted-foreground">
|
|
{cleanPreview(email.preview)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
{isLoadingMore && (
|
|
<div className="flex items-center justify-center gap-2 py-3 text-xs text-muted-foreground">
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
{tList('loading_more')}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
if (isMobile) {
|
|
return list;
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full w-full bg-background">
|
|
{list}
|
|
{selectedEmailId ? (
|
|
<ProEmailView
|
|
key={selectedEmailId}
|
|
emailId={selectedEmailId}
|
|
client={client}
|
|
clientAccountId={clientLogin}
|
|
accountId={jmapAccountId}
|
|
onClose={handleViewerClose}
|
|
className="min-w-0 flex-1"
|
|
/>
|
|
) : (
|
|
<EmailViewer email={null} isLoading={false} className="min-w-0 flex-1" />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|