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
3 changes: 2 additions & 1 deletion apps/web/src/desktop-cache.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { invoke } from "@tauri-apps/api/core";
import { isDesktopRuntime } from "./desktop-runtime";

export type DesktopCacheKind = "navigation" | "inbox" | "search" | "conversation";
Expand All @@ -9,6 +8,7 @@ const desktopOfflineKey = "mailflow.desktop.offline";
async function invokeDesktop<T>(command: string, arguments_: Record<string, unknown> = {}) {
if (!isDesktopRuntime()) return undefined;
try {
const { invoke } = await import("@tauri-apps/api/core");
return await invoke<T>(command, arguments_);
} catch {
return undefined;
Expand Down Expand Up @@ -68,6 +68,7 @@ export async function writeDesktopCache(

export async function removeDesktopAccount(accountId: string) {
if (!isDesktopRuntime()) return;
const { invoke } = await import("@tauri-apps/api/core");
await invoke("offline_cache_remove_account", { accountId });
}

Expand Down
20 changes: 12 additions & 8 deletions apps/web/src/desktop-session.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { invoke } from "@tauri-apps/api/core";
import { isDesktopRuntime } from "./desktop-runtime";
import type { Locale } from "./i18n";

Expand All @@ -9,6 +8,11 @@ type NativeSession = {

let cachedAccessToken: { token: string; expiresAt: number } | null = null;

async function invokeDesktop<T>(command: string, arguments_?: Record<string, unknown>) {
const { invoke } = await import("@tauri-apps/api/core");
return arguments_ ? invoke<T>(command, arguments_) : invoke<T>(command);
}

function cacheToken(session: NativeSession) {
const encoded = session.accessToken.split(".")[1];
if (!encoded) return;
Expand All @@ -28,22 +32,22 @@ function cacheToken(session: NativeSession) {

export async function nativeRequestHeaders(): Promise<Record<string, string>> {
if (!isDesktopRuntime()) return {};
const installation = await invoke<string>("native_session_identity");
const installation = await invokeDesktop<string>("native_session_identity");
return { "x-mailflow-native": "1", "x-mailflow-installation": installation };
}

export async function activateDesktopSession(response: Response): Promise<NativeSession | null> {
if (!isDesktopRuntime()) return null;
const sessionToken = response.headers.get("set-auth-token");
if (!sessionToken) throw new Error("native_session_missing");
const session = await invoke<NativeSession>("native_session_activate", { sessionToken });
const session = await invokeDesktop<NativeSession>("native_session_activate", { sessionToken });
cacheToken(session);
return session;
}

export async function currentDesktopSession(): Promise<NativeSession | null> {
if (!isDesktopRuntime()) return null;
const session = await invoke<NativeSession | null>("native_session_current");
const session = await invokeDesktop<NativeSession | null>("native_session_current");
if (session) cacheToken(session);
else cachedAccessToken = null;
return session;
Expand All @@ -60,7 +64,7 @@ export async function desktopAccessToken(): Promise<string | null> {
export async function logoutDesktopSession() {
if (!isDesktopRuntime()) return false;
try {
await invoke("native_session_logout");
await invokeDesktop("native_session_logout");
} finally {
cachedAccessToken = null;
}
Expand All @@ -70,19 +74,19 @@ export async function logoutDesktopSession() {
export async function forgetDesktopSession() {
if (!isDesktopRuntime()) return;
try {
await invoke("native_session_forget_local");
await invokeDesktop("native_session_forget_local");
} finally {
cachedAccessToken = null;
}
}

export async function beginDesktopPasskey(name: string): Promise<unknown | null> {
if (!isDesktopRuntime()) return null;
return invoke("native_passkey_begin", { name });
return invokeDesktop("native_passkey_begin", { name });
}

export async function finishDesktopPasskey(name: string, response: unknown): Promise<boolean> {
if (!isDesktopRuntime()) return false;
await invoke("native_passkey_finish", { name, response });
await invokeDesktop("native_passkey_finish", { name, response });
return true;
}
25 changes: 21 additions & 4 deletions apps/web/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,32 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { StrictMode } from "react";
import { lazy, StrictMode, Suspense } from "react";
import { createRoot } from "react-dom/client";
import { createBrowserRouter, RouterProvider } from "react-router";
import { AdminPage } from "./admin-page";
import { AuthGate } from "./auth-gate";
import { registerDesktopOfflineShell } from "./desktop-cache";
import { initializeDesktopRuntime } from "./desktop-runtime";
import { type Locale, translate } from "./i18n";
import { AccountsPage, MailPage } from "./pages";
import { initializeTelemetry } from "./telemetry";
import "./styles.css";

const AdminPage = lazy(async () => ({ default: (await import("./admin-page")).AdminPage }));

function AdminRoute({ locale }: { locale: Locale }) {
return (
<Suspense
fallback={
<main className="mail-state" role="status">
<span className="loading-spinner" />
<strong>{translate(locale, "admin.loading")}</strong>
</main>
}
>
<AdminPage locale={locale} />
</Suspense>
);
}

const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 30_000, retry: 1 },
Expand All @@ -22,10 +39,10 @@ initializeTelemetry();

const router = createBrowserRouter([
{ path: "/", element: <AuthGate renderApp={(locale) => <MailPage initialLocale={locale} />} /> },
{ path: "/admin", element: <AuthGate renderApp={(locale) => <AdminPage locale={locale} />} /> },
{ path: "/admin", element: <AuthGate renderApp={(locale) => <AdminRoute locale={locale} />} /> },
{
path: "/admin/:section",
element: <AuthGate renderApp={(locale) => <AdminPage locale={locale} />} />,
element: <AuthGate renderApp={(locale) => <AdminRoute locale={locale} />} />,
},
{
path: "/settings/accounts",
Expand Down
97 changes: 64 additions & 33 deletions apps/web/src/pages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,21 @@ import {
Trash2,
Users,
} from "lucide-react";
import { type CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
type CSSProperties,
lazy,
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { useNavigate } from "react-router";
import { registerPasskey, signOut } from "./auth-client";
import { Button } from "./components/ui/button";
import { type ComposeContext, ComposePanel } from "./composer";
import { ConversationView } from "./conversation";
import type { ComposeContext } from "./composer";
import { desktopConnectivityEvent, isDesktopCacheFallback } from "./desktop-cache";
import {
listenForNativeExperience,
Expand Down Expand Up @@ -86,6 +94,11 @@ import { type SearchSyntaxError, searchSuggestions, validateSearchSyntax } from

type Translator = (key: TranslationKey) => string;

const ComposePanel = lazy(async () => ({ default: (await import("./composer")).ComposePanel }));
const ConversationView = lazy(async () => ({
default: (await import("./conversation")).ConversationView,
}));

const inboxCategories: Array<{
id: MailCategory;
labelKey: TranslationKey;
Expand Down Expand Up @@ -1531,25 +1544,34 @@ export function MailPage({ initialLocale = "en" }: { initialLocale?: Locale }) {
/>
<main className="mail-surface">
{activeThreadId && activeAccountId ? (
<ConversationView
accountId={activeAccountId}
threadId={activeThreadId}
locale={locale}
onBack={() => setActiveThreadId(null)}
onCompose={(context) => {
setComposeContext(context);
setComposeOpen(true);
setSidebarCollapsed(true);
}}
onAction={(kind, labelId) => {
void runAction(kind, [activeThreadId], labelId);
if (kind === "archive" || kind === "move_to_trash") setActiveThreadId(null);
}}
canActions={canActions}
canCompose={canCompose}
canAttachments={canAttachments}
labelId={actionLabelId}
/>
<Suspense
fallback={
<div className="mail-state" role="status">
<span className="loading-spinner" />
<strong>{t("loadingConversation")}</strong>
</div>
}
>
<ConversationView
accountId={activeAccountId}
threadId={activeThreadId}
locale={locale}
onBack={() => setActiveThreadId(null)}
onCompose={(context) => {
setComposeContext(context);
setComposeOpen(true);
setSidebarCollapsed(true);
}}
onAction={(kind, labelId) => {
void runAction(kind, [activeThreadId], labelId);
if (kind === "archive" || kind === "move_to_trash") setActiveThreadId(null);
}}
canActions={canActions}
canCompose={canCompose}
canAttachments={canAttachments}
labelId={actionLabelId}
/>
</Suspense>
) : (
<>
<MailToolbar
Expand Down Expand Up @@ -1706,17 +1728,26 @@ export function MailPage({ initialLocale = "en" }: { initialLocale?: Locale }) {
</main>
</div>
{composeOpen && activeAccountId && canCompose && (
<ComposePanel
accountId={activeAccountId}
context={composeContext}
locale={locale}
maximized={composeMaximized}
onMaximize={() => setComposeMaximized((value) => !value)}
onClose={() => {
setComposeOpen(false);
setComposeMaximized(false);
}}
/>
<Suspense
fallback={
<div className="compose-panel" role="status">
<span className="loading-spinner" />
<strong>{t("loadingComposer")}</strong>
</div>
}
>
<ComposePanel
accountId={activeAccountId}
context={composeContext}
locale={locale}
maximized={composeMaximized}
onMaximize={() => setComposeMaximized((value) => !value)}
onClose={() => {
setComposeOpen(false);
setComposeMaximized(false);
}}
/>
</Suspense>
)}
</div>
);
Expand Down
2 changes: 2 additions & 0 deletions services/api/internal/modules/translations/catalogs.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
"attachmentFailed": "The attachment could not be downloaded.",
"displayRemoteImages": "Display remote images",
"loadingConversation": "Loading conversation…",
"loadingComposer": "Loading composer…",
"conversationFailed": "The conversation could not be loaded.",
"emptyConversation": "This conversation has no messages.",
"loadMoreMessages": "Load more messages",
Expand Down Expand Up @@ -464,6 +465,7 @@
"attachmentFailed": "No se ha podido descargar el adjunto.",
"displayRemoteImages": "Mostrar imágenes remotas",
"loadingConversation": "Cargando conversación…",
"loadingComposer": "Cargando redactor…",
"conversationFailed": "No se ha podido cargar la conversación.",
"emptyConversation": "Esta conversación no tiene mensajes.",
"loadMoreMessages": "Cargar más mensajes",
Expand Down
Loading