diff --git a/apps/mail/app/root.tsx b/apps/mail/app/root.tsx index 056e1a2f60..22acfdacd1 100644 --- a/apps/mail/app/root.tsx +++ b/apps/mail/app/root.tsx @@ -7,13 +7,15 @@ import { ScrollRestoration, useLoaderData, useNavigate, + type LoaderFunctionArgs, type MetaFunction, } from 'react-router'; import { ServerProviders } from '@/providers/server-providers'; import { ClientProviders } from '@/providers/client-providers'; +import { createTRPCClient, httpBatchLink } from '@trpc/client'; import { useEffect, type PropsWithChildren } from 'react'; import { AlertCircle, Loader2 } from 'lucide-react'; -import { getServerTrpc } from '@/lib/trpc.server'; +import type { AppRouter } from '@zero/server/trpc'; import { Button } from '@/components/ui/button'; import { getLocale } from '@/paraglide/runtime'; import { siteConfig } from '@/lib/site-config'; @@ -21,8 +23,23 @@ import { signOut } from '@/lib/auth-client'; import type { Route } from './+types/root'; import { m } from '@/paraglide/messages'; import { ArrowLeft } from 'lucide-react'; +import superjson from 'superjson'; import './globals.css'; +const getUrl = () => import.meta.env.VITE_PUBLIC_BACKEND_URL + '/api/trpc'; + +export const getServerTrpc = (req: Request) => + createTRPCClient({ + links: [ + httpBatchLink({ + maxItems: 1, + url: getUrl(), + transformer: superjson, + headers: req.headers, + }), + ], + }); + export const meta: MetaFunction = () => { return [ { title: siteConfig.title }, @@ -36,7 +53,18 @@ export const meta: MetaFunction = () => { ]; }; +export async function loader({ request }: LoaderFunctionArgs) { + const trpc = getServerTrpc(request); + const defaultConnection = await trpc.connections.getDefault + .query() + .then((res) => (res?.id as string) ?? null) + .catch(() => null); + return { connectionId: defaultConnection }; +} + export function Layout({ children }: PropsWithChildren) { + const { connectionId } = useLoaderData(); + return ( @@ -52,7 +80,7 @@ export function Layout({ children }: PropsWithChildren) { - + {children} diff --git a/apps/mail/components/mail/mail-list.tsx b/apps/mail/components/mail/mail-list.tsx index f91b2c5d71..ff53f96e97 100644 --- a/apps/mail/components/mail/mail-list.tsx +++ b/apps/mail/components/mail/mail-list.tsx @@ -79,22 +79,22 @@ const Thread = memo( const [, setActiveReplyId] = useQueryState('activeReplyId'); const [focusedIndex, setFocusedIndex] = useAtom(focusedIndexAtom); - const latestReceivedMessage = useMemo(() => { - if (!getThreadData?.messages) return getThreadData?.latest; - - const nonDraftMessages = getThreadData.messages.filter((msg) => !msg.isDraft); - if (nonDraftMessages.length === 0) return getThreadData?.latest; - - return ( - nonDraftMessages.sort((a, b) => { - const dateA = new Date(a.receivedOn).getTime(); - const dateB = new Date(b.receivedOn).getTime(); - return dateB - dateA; - })[0] || getThreadData?.latest - ); - }, [getThreadData?.messages, getThreadData?.latest]); - - const latestMessage = latestReceivedMessage; + // const latestReceivedMessage = useMemo(() => { + // if (!getThreadData?.messages) return getThreadData?.latest; + + // const nonDraftMessages = getThreadData.messages.filter((msg) => !msg.isDraft); + // if (nonDraftMessages.length === 0) return getThreadData?.latest; + + // return ( + // nonDraftMessages.sort((a, b) => { + // const dateA = new Date(a.receivedOn).getTime(); + // const dateB = new Date(b.receivedOn).getTime(); + // return dateB - dateA; + // })[0] || getThreadData?.latest + // ); + // }, [getThreadData?.messages, getThreadData?.latest]); + + const latestMessage = getThreadData?.latest; const idToUse = useMemo(() => latestMessage?.threadId ?? latestMessage?.id, [latestMessage]); const { data: settingsData } = useSettings(); const queryClient = useQueryClient(); diff --git a/apps/mail/components/mail/select-all-checkbox.tsx b/apps/mail/components/mail/select-all-checkbox.tsx index 7d69802121..d347844fda 100644 --- a/apps/mail/components/mail/select-all-checkbox.tsx +++ b/apps/mail/components/mail/select-all-checkbox.tsx @@ -6,7 +6,7 @@ import { trpcClient } from '@/providers/query-provider'; import { cn } from '@/lib/utils'; import { useParams } from 'react-router'; import { toast } from 'sonner'; -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useMemo, useRef, useState, useEffect } from 'react'; export default function SelectAllCheckbox({ className }: { className?: string }) { const [mail, setMail] = useMail(); @@ -15,6 +15,8 @@ export default function SelectAllCheckbox({ className }: { className?: string }) const { folder = 'inbox' } = useParams<{ folder: string }>() ?? {}; const [isFetchingIds, setIsFetchingIds] = useState(false); + const allIdsCache = useRef(null); + const checkboxRef = useRef(null); const loadedIds = useMemo(() => loadedThreads.map((t) => t.id), [loadedThreads]); @@ -31,7 +33,7 @@ export default function SelectAllCheckbox({ className }: { className?: string }) const fetchAllMatchingThreadIds = useCallback(async (): Promise => { const ids: string[] = []; let cursor = ''; - const MAX_PER_PAGE = 100; + const MAX_PER_PAGE = 500; try { while (true) { @@ -55,7 +57,7 @@ export default function SelectAllCheckbox({ className }: { className?: string }) return ids; }, [folder, query]); - const handleToggle = useCallback(async () => { + const handleToggle = useCallback(() => { if (isFetchingIds) return; if (mail.bulkSelected.length) { @@ -64,21 +66,35 @@ export default function SelectAllCheckbox({ className }: { className?: string }) } setMail((prev) => ({ ...prev, bulkSelected: loadedIds })); - - setIsFetchingIds(true); - const allIds = await fetchAllMatchingThreadIds(); - setIsFetchingIds(false); + toast( `${loadedIds.length} conversation${loadedIds.length !== 1 ? 's' : ''} on this page selected.`, { action: { - label: `Select all ${allIds.length} conversation${allIds.length !== 1 ? 's' : ''}`, - onClick: () => setMail((prev) => ({ ...prev, bulkSelected: allIds })), + label: 'Select all conversations', + onClick: async () => { + try { + if (!allIdsCache.current) { + setIsFetchingIds(true); + allIdsCache.current = await fetchAllMatchingThreadIds(); + setIsFetchingIds(false); + } + const allIds = allIdsCache.current ?? []; + setMail((prev) => ({ ...prev, bulkSelected: allIds })); + } catch (err) { + setIsFetchingIds(false); + toast.error('Failed to select all conversations'); + } + }, }, className: '!w-auto whitespace-nowrap', }, ); - }, [mail.bulkSelected.length, loadedIds, fetchAllMatchingThreadIds, isFetchingIds, setMail]); + }, [isFetchingIds, mail.bulkSelected.length, loadedIds, fetchAllMatchingThreadIds, setMail]); + + useEffect(() => { + allIdsCache.current = null; + }, [folder, query]); return ( { const { threadIds, type } = JSON.parse(message.data); if (type === IncomingMessageType.Mail_Get) { const { threadId, result } = JSON.parse(message.data); - queryClient.setQueryData(trpc.mail.get.queryKey({ id: threadId }), result); + // queryClient.setQueryData(trpc.mail.get.queryKey({ id: threadId }), result); } } catch (error) { console.error('error parsing party message', error); diff --git a/apps/mail/hooks/use-optimistic-actions.ts b/apps/mail/hooks/use-optimistic-actions.ts index 693dff5d28..f4dc08f4fb 100644 --- a/apps/mail/hooks/use-optimistic-actions.ts +++ b/apps/mail/hooks/use-optimistic-actions.ts @@ -10,9 +10,27 @@ import { moveThreadsTo } from '@/lib/thread-actions'; import { useCallback, useRef } from 'react'; import { m } from '@/paraglide/messages'; import { useQueryState } from 'nuqs'; +import posthog from 'posthog-js'; import { useAtom } from 'jotai'; import { toast } from 'sonner'; +enum ActionType { + MOVE = 'MOVE', + STAR = 'STAR', + READ = 'READ', + LABEL = 'LABEL', + IMPORTANT = 'IMPORTANT', +} + +const actionEventNames: Record string> = { + [ActionType.MOVE]: () => 'email_moved', + [ActionType.STAR]: (params) => (params.starred ? 'email_starred' : 'email_unstarred'), + [ActionType.READ]: (params) => (params.read ? 'email_marked_read' : 'email_marked_unread'), + [ActionType.IMPORTANT]: (params) => + params.important ? 'email_marked_important' : 'email_unmarked_important', + [ActionType.LABEL]: (params) => (params.add ? 'email_label_added' : 'email_label_removed'), +}; + export function useOptimisticActions() { const trpc = useTRPC(); const queryClient = useQueryClient(); @@ -66,9 +84,9 @@ export function useOptimisticActions() { toastMessage, folders, }: { - type: 'MOVE' | 'STAR' | 'READ' | 'LABEL' | 'IMPORTANT'; + type: keyof typeof ActionType; threadIds: string[]; - params: any; + params: PendingAction['params']; optimisticId: string; execute: () => Promise; undo: () => void; @@ -92,7 +110,7 @@ export function useOptimisticActions() { optimisticActionsManager.pendingActionsByType.get(type)?.size, ); - const pendingAction: PendingAction = { + const pendingAction = { id: pendingActionId, type, threadIds, @@ -102,7 +120,7 @@ export function useOptimisticActions() { undo, }; - optimisticActionsManager.pendingActions.set(pendingActionId, pendingAction); + optimisticActionsManager.pendingActions.set(pendingActionId, pendingAction as PendingAction); const itemCount = threadIds.length; const bulkActionMessage = itemCount > 1 ? `${toastMessage} (${itemCount} items)` : toastMessage; @@ -116,6 +134,12 @@ export function useOptimisticActions() { pendingActionsRef: optimisticActionsManager.pendingActions.size, typeActions: typeActions?.size, }); + + const eventName = actionEventNames[type]?.(params); + if (eventName) { + posthog.capture(eventName); + } + optimisticActionsManager.pendingActions.delete(pendingActionId); optimisticActionsManager.pendingActionsByType.get(type)?.delete(pendingActionId); if (typeActions?.size === 1) { diff --git a/apps/mail/lib/optimistic-actions-manager.ts b/apps/mail/lib/optimistic-actions-manager.ts index 22262762d8..b1b180307f 100644 --- a/apps/mail/lib/optimistic-actions-manager.ts +++ b/apps/mail/lib/optimistic-actions-manager.ts @@ -1,14 +1,23 @@ -export type PendingAction = { +import type { ThreadDestination } from '@/lib/thread-actions'; + +type BasePendingAction = { id: string; - type: 'MOVE' | 'STAR' | 'READ' | 'LABEL' | 'IMPORTANT'; threadIds: string[]; - params: any; optimisticId: string; execute: () => Promise; undo: () => void; toastId?: string | number; }; +export type PendingAction = BasePendingAction & + ( + | { type: 'MOVE'; params: { currentFolder: string; destination: ThreadDestination } } + | { type: 'STAR'; params: { starred: boolean } } + | { type: 'READ'; params: { read: boolean } } + | { type: 'LABEL'; params: { labelId: string; add: boolean } } + | { type: 'IMPORTANT'; params: { important: boolean } } + ); + class OptimisticActionsManager { pendingActions: Map = new Map(); pendingActionsByType: Map> = new Map(); diff --git a/apps/mail/providers/query-provider.tsx b/apps/mail/providers/query-provider.tsx index c9a8e3c48d..2c0f429955 100644 --- a/apps/mail/providers/query-provider.tsx +++ b/apps/mail/providers/query-provider.tsx @@ -28,7 +28,7 @@ function createIDBPersister(idbValidKey: IDBValidKey = 'zero-query-cache') { } satisfies Persister; } -export const makeQueryClient = () => +export const makeQueryClient = (connectionId: string | null) => new QueryClient({ queryCache: new QueryCache({ onError: (err, { meta }) => { @@ -50,7 +50,7 @@ export const makeQueryClient = () => queries: { retry: false, refetchOnWindowFocus: false, - queryKeyHashFn: (queryKey) => hashKey([{ connectionId: 'default' }, ...queryKey]), + queryKeyHashFn: (queryKey) => hashKey([{ connectionId }, ...queryKey]), gcTime: 1000 * 60 * 60 * 24, }, mutations: { @@ -67,12 +67,13 @@ let browserQueryClient = { activeConnectionId: string | null; }; -const getQueryClient = () => { +const getQueryClient = (connectionId: string | null) => { if (typeof window === 'undefined') { - return makeQueryClient(); + return makeQueryClient(connectionId); } else { - if (!browserQueryClient.queryClient) { - browserQueryClient.queryClient = makeQueryClient(); + if (!browserQueryClient.queryClient || browserQueryClient.activeConnectionId !== connectionId) { + browserQueryClient.queryClient = makeQueryClient(connectionId); + browserQueryClient.activeConnectionId = connectionId; } return browserQueryClient.queryClient; } @@ -103,9 +104,15 @@ export const trpcClient = createTRPCClient({ type TrpcHook = ReturnType; -export function QueryProvider({ children }: PropsWithChildren) { - const persister = useMemo(() => createIDBPersister(`zero-query-cache-default`), []); - const queryClient = useMemo(() => getQueryClient(), []); +export function QueryProvider({ + children, + connectionId, +}: PropsWithChildren<{ connectionId: string | null }>) { + const persister = useMemo( + () => createIDBPersister(`zero-query-cache-${connectionId ?? 'default'}`), + [connectionId], + ); + const queryClient = useMemo(() => getQueryClient(connectionId), [connectionId]); return ( ) { return ( - {children} + {children} ); } diff --git a/apps/server/src/lib/driver/utils.ts b/apps/server/src/lib/driver/utils.ts index 2e54384831..16f4b6e5bd 100644 --- a/apps/server/src/lib/driver/utils.ts +++ b/apps/server/src/lib/driver/utils.ts @@ -23,26 +23,6 @@ export const deleteActiveConnection = async () => { } }; -export const getActiveDriver = async () => { - const c = getContext(); - const session = await c.var.auth.api.getSession({ headers: c.req.raw.headers }); - if (!session) throw new Error('Invalid session'); - const activeConnection = await getActiveConnection(); - if (!activeConnection) throw new Error('Invalid connection'); - - if (!activeConnection || !activeConnection.accessToken || !activeConnection.refreshToken) - throw new Error('Invalid connection'); - - return createDriver(activeConnection.providerId, { - auth: { - accessToken: activeConnection.accessToken, - refreshToken: activeConnection.refreshToken, - userId: activeConnection.userId, - email: activeConnection.email, - }, - }); -}; - export const fromBase64Url = (str: string) => str.replace(/-/g, '+').replace(/_/g, '/'); export const fromBinary = (str: string) => diff --git a/apps/server/src/lib/server-utils.ts b/apps/server/src/lib/server-utils.ts index bc4f078081..97662a697f 100644 --- a/apps/server/src/lib/server-utils.ts +++ b/apps/server/src/lib/server-utils.ts @@ -44,7 +44,7 @@ export const getActiveConnection = async () => { export const connectionToDriver = (activeConnection: typeof connection.$inferSelect) => { if (!activeConnection.accessToken || !activeConnection.refreshToken) { - throw new Error('Invalid connection'); + throw new Error(`Invalid connection ${JSON.stringify(activeConnection?.id)}`); } return createDriver(activeConnection.providerId, { diff --git a/apps/server/wrangler.jsonc b/apps/server/wrangler.jsonc index f65421b09e..87fbeda9b2 100644 --- a/apps/server/wrangler.jsonc +++ b/apps/server/wrangler.jsonc @@ -337,7 +337,7 @@ "hyperdrive": [ { "binding": "HYPERDRIVE", - "id": "27171042364248898fc8672c0fc532a0", + "id": "b1be316b45fb439a9e54b74ecc20aa21", "localConnectionString": "postgresql://postgres:postgres@localhost:5432/zerodotemail", }, ],