Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions apps/mail/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,39 @@ 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';
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<AppRouter>({
links: [
httpBatchLink({
maxItems: 1,
url: getUrl(),
transformer: superjson,
headers: req.headers,
}),
],
});

export const meta: MetaFunction = () => {
return [
{ title: siteConfig.title },
Expand All @@ -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<typeof loader>();

return (
<html lang={getLocale()} suppressHydrationWarning>
<head>
Expand All @@ -52,7 +80,7 @@ export function Layout({ children }: PropsWithChildren) {
<Links />
</head>
<body className="antialiased">
<ServerProviders>
<ServerProviders connectionId={connectionId}>
<ClientProviders>{children}</ClientProviders>
</ServerProviders>
<ScrollRestoration />
Expand Down
32 changes: 16 additions & 16 deletions apps/mail/components/mail/mail-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
36 changes: 26 additions & 10 deletions apps/mail/components/mail/select-all-checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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<string[] | null>(null);

const checkboxRef = useRef<HTMLButtonElement>(null);

const loadedIds = useMemo(() => loadedThreads.map((t) => t.id), [loadedThreads]);
Expand All @@ -31,7 +33,7 @@ export default function SelectAllCheckbox({ className }: { className?: string })
const fetchAllMatchingThreadIds = useCallback(async (): Promise<string[]> => {
const ids: string[] = [];
let cursor = '';
const MAX_PER_PAGE = 100;
const MAX_PER_PAGE = 500;

try {
while (true) {
Expand All @@ -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) {
Expand All @@ -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 (
<Checkbox
Expand Down
2 changes: 1 addition & 1 deletion apps/mail/components/party.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export const NotificationProvider = () => {
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);
Expand Down
32 changes: 28 additions & 4 deletions apps/mail/hooks/use-optimistic-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ActionType, (params: any) => 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();
Expand Down Expand Up @@ -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<void>;
undo: () => void;
Expand All @@ -92,7 +110,7 @@ export function useOptimisticActions() {
optimisticActionsManager.pendingActionsByType.get(type)?.size,
);

const pendingAction: PendingAction = {
const pendingAction = {
id: pendingActionId,
type,
threadIds,
Expand All @@ -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;
Expand All @@ -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) {
Expand Down
15 changes: 12 additions & 3 deletions apps/mail/lib/optimistic-actions-manager.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
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<string, PendingAction> = new Map();
pendingActionsByType: Map<string, Set<string>> = new Map();
Expand Down
25 changes: 16 additions & 9 deletions apps/mail/providers/query-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand All @@ -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: {
Expand All @@ -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;
}
Expand Down Expand Up @@ -103,9 +104,15 @@ export const trpcClient = createTRPCClient<AppRouter>({

type TrpcHook = ReturnType<typeof useTRPC>;

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 (
<PersistQueryClientProvider
Expand Down
7 changes: 5 additions & 2 deletions apps/mail/providers/server-providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ import { QueryProvider } from './query-provider';
import { AutumnProvider } from 'autumn-js/react';
import type { PropsWithChildren } from 'react';

export function ServerProviders({ children }: PropsWithChildren) {
export function ServerProviders({
children,
connectionId,
}: PropsWithChildren<{ connectionId: string | null }>) {
return (
<AutumnProvider backendUrl={import.meta.env.VITE_PUBLIC_BACKEND_URL}>
<QueryProvider>{children}</QueryProvider>
<QueryProvider connectionId={connectionId}>{children}</QueryProvider>
</AutumnProvider>
);
}
20 changes: 0 additions & 20 deletions apps/server/src/lib/driver/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,26 +23,6 @@ export const deleteActiveConnection = async () => {
}
};

export const getActiveDriver = async () => {
const c = getContext<HonoContext>();
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) =>
Expand Down
Loading