Files
Zero/apps/mail/hooks/use-threads.ts
Adam 5b2d76a5d4 prefetch email html (#1719)
# Email Content Prefetching and Processing Optimization

## Description

This PR improves email loading performance by implementing prefetching and caching of processed email HTML content. It splits the email processing logic into two parts:

1. Server-side preprocessing that handles sanitization and structure
2. Client-side processing that applies theme-specific styling and image loading preferences

The changes also add prefetching of the latest message in a thread to improve perceived loading speed when users open emails.

---

## Type of Change

- [x]  Performance improvement
- [x] 🎨 UI/UX improvement

## Areas Affected

- [x] Email Integration (Gmail, IMAP, etc.)
- [x] User Interface/Experience
- [x] Performance Optimization

## Testing Done

- [x] Manual testing performed
- [x] Cross-browser testing (if UI changes)

## Checklist

- [x] I have performed a self-review of my code
- [x] My changes generate no new warnings
- [x] My code follows the project's style guidelines

## Additional Notes

The email processing logic has been refactored to:
1. Separate heavy sanitization work (which can be done once) from theme/preference application
2. Cache processed content with a 30-minute stale time and 1-hour garbage collection time
3. Respect user preferences for external image loading and trusted senders
4. Apply theme-specific styling based on user settings or system preference

This should significantly improve the perceived performance when opening emails, especially for threads with complex HTML content.

---

_By submitting this pull request, I confirm that my contribution is made under the terms of the project's license._

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Improved email thread view by displaying the latest non-draft message.
  * Enhanced email content processing to apply user settings and theme preferences, including external image loading and dark/light mode support.

* **Bug Fixes**
  * More accurate handling of external images and theme styling in emails based on user preferences.

* **Chores**
  * Updated internal configuration for local development environment.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-11 15:57:19 -07:00

169 lines
5.3 KiB
TypeScript

import { backgroundQueueAtom, isThreadInBackgroundQueueAtom } from '@/store/backgroundQueue';
import { useInfiniteQuery, useQuery, useMutation } from '@tanstack/react-query';
import type { IGetThreadResponse } from '../../server/src/lib/driver/types';
import { useSearchValue } from '@/hooks/use-search-value';
import { useTRPC } from '@/providers/query-provider';
import useSearchLabels from './use-labels-search';
import { useSession } from '@/lib/auth-client';
import { useAtom, useAtomValue } from 'jotai';
import { useSettings } from './use-settings';
import { useParams } from 'react-router';
import { useTheme } from 'next-themes';
import { useQueryState } from 'nuqs';
import { useMemo } from 'react';
export const useThreads = () => {
const { folder } = useParams<{ folder: string }>();
const [searchValue] = useSearchValue();
const [backgroundQueue] = useAtom(backgroundQueueAtom);
const isInQueue = useAtomValue(isThreadInBackgroundQueueAtom);
const trpc = useTRPC();
const { labels } = useSearchLabels();
const threadsQuery = useInfiniteQuery(
trpc.mail.listThreads.infiniteQueryOptions(
{
q: searchValue.value,
folder,
labelIds: labels,
},
{
initialCursor: '',
getNextPageParam: (lastPage) => lastPage?.nextPageToken ?? null,
staleTime: 60 * 1000 * 1, // 1 minute
refetchOnMount: true,
refetchIntervalInBackground: true,
},
),
);
// Flatten threads from all pages and sort by receivedOn date (newest first)
const threads = useMemo(() => {
return threadsQuery.data
? threadsQuery.data.pages
.flatMap((e) => e.threads)
.filter(Boolean)
.filter((e) => !isInQueue(`thread:${e.id}`))
: [];
}, [threadsQuery.data, threadsQuery.dataUpdatedAt, isInQueue, backgroundQueue]);
const isEmpty = useMemo(() => threads.length === 0, [threads]);
const isReachingEnd =
isEmpty ||
(threadsQuery.data &&
!threadsQuery.data.pages[threadsQuery.data.pages.length - 1]?.nextPageToken);
const loadMore = async () => {
if (threadsQuery.isLoading || threadsQuery.isFetching) return;
await threadsQuery.fetchNextPage();
};
return [threadsQuery, threads, isReachingEnd, loadMore] as const;
};
export const useThread = (threadId: string | null) => {
const { data: session } = useSession();
const [_threadId] = useQueryState('threadId');
const id = threadId ? threadId : _threadId;
const trpc = useTRPC();
const { data: settings } = useSettings();
const { theme: systemTheme } = useTheme();
const threadQuery = useQuery(
trpc.mail.get.queryOptions(
{
id: id!,
},
{
enabled: !!id && !!session?.user.id,
staleTime: 1000 * 60 * 60, // 1 minute
},
),
);
const { latestDraft, isGroupThread, finalData, latestMessage } = useMemo(() => {
if (!threadQuery.data) {
return {
latestDraft: undefined,
isGroupThread: false,
finalData: undefined,
latestMessage: undefined,
};
}
const latestDraft = threadQuery.data.latest?.id
? threadQuery.data.messages.findLast((e) => e.isDraft)
: undefined;
const isGroupThread = threadQuery.data.latest?.id
? (() => {
const totalRecipients = [
...(threadQuery.data.latest.to || []),
...(threadQuery.data.latest.cc || []),
...(threadQuery.data.latest.bcc || []),
].length;
return totalRecipients > 1;
})()
: false;
const nonDraftMessages = threadQuery.data.messages.filter((e) => !e.isDraft);
const latestMessage = nonDraftMessages[nonDraftMessages.length - 1];
const finalData: IGetThreadResponse = {
...threadQuery.data,
messages: nonDraftMessages,
};
return { latestDraft, isGroupThread, finalData, latestMessage };
}, [threadQuery.data]);
const { mutateAsync: processEmailContent } = useMutation(
trpc.mail.processEmailContent.mutationOptions(),
);
// Extract image loading condition to avoid duplication
const shouldLoadImages = useMemo(() => {
if (!settings?.settings || !latestMessage?.sender?.email) return false;
return settings.settings.externalImages ||
settings.settings.trustedSenders?.includes(latestMessage.sender.email) ||
false;
}, [settings?.settings, latestMessage?.sender?.email]);
// Prefetch query - intentionally unused, just for caching
useQuery({
queryKey: [
'email-content',
latestMessage?.id,
shouldLoadImages,
systemTheme,
],
queryFn: async () => {
if (!latestMessage?.decodedBody || !settings?.settings) return null;
const userTheme =
settings.settings.colorTheme === 'system' ? systemTheme : settings.settings.colorTheme;
const theme = userTheme === 'dark' ? 'dark' : 'light';
const result = await processEmailContent({
html: latestMessage.decodedBody,
shouldLoadImages,
theme,
});
return {
html: result.processedHtml,
hasBlockedImages: result.hasBlockedImages,
};
},
enabled: !!latestMessage?.decodedBody && !!settings?.settings,
staleTime: 30 * 60 * 1000, // 30 minutes
gcTime: 60 * 60 * 1000, // 1 hour
refetchOnWindowFocus: false,
refetchOnMount: false,
});
return { ...threadQuery, data: finalData, isGroupThread, latestDraft };
};