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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## 0.1.27

- Show a private Drafts destination in desktop and mobile navigation only when the signed-in user
has saved drafts.
- List saved drafts with mailbox and search filtering, and reopen the exact new-message, reply, or
forward draft with its attachments and conversation context.
- Keep the Drafts count synchronized when a composer creates, sends, or discards a draft.

## 0.1.26

- Expand the OAuth-protected MCP server with conversation, thread, and attachment retrieval plus
Expand Down
48 changes: 45 additions & 3 deletions app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { Toaster } from "@/components/ui/sonner";
import { getCurrentUser } from "@/features/auth/api";
import { LoginPage } from "@/features/auth/login-page";
import type { CurrentUser } from "@/features/auth/types";
import { DraftsPage } from "@/features/drafts/drafts-page";
import { useDrafts } from "@/features/drafts/use-drafts";
import { InboxPage } from "@/features/inbox/inbox-page";
import { listMailboxes } from "@/features/mailboxes/api";
import type { Mailbox } from "@/features/mailboxes/types";
Expand All @@ -22,6 +24,11 @@ import { useAppRoute } from "@/lib/use-app-route";
const ComposeDialog = React.lazy(() =>
import("@/features/compose/compose-dialog").then((module) => ({ default: module.ComposeDialog }))
);
const DraftComposeDialog = React.lazy(() =>
import("@/features/drafts/draft-compose-dialog").then((module) => ({
default: module.DraftComposeDialog
}))
);

export function App(): React.ReactElement {
const [setup, setSetup] = React.useState<SetupStatus | null>(null);
Expand All @@ -33,10 +40,17 @@ export function App(): React.ReactElement {
const [composeOpen, setComposeOpen] = React.useState(false);
const [isLoading, setIsLoading] = React.useState(true);
const { navigate, route } = useAppRoute(setup?.isComplete);
const activeFolder: FolderId = route.kind === "settings" ? "settings" : route.folder;
const activeFolder: FolderId =
route.kind === "settings" ? "settings" : route.kind === "drafts" ? "drafts" : route.folder;
const selectedId = route.kind === "mail" ? route.messageId : null;
const selectedDraftId = route.kind === "drafts" ? route.draftId : null;
const settingsTab: SettingsTabId = route.kind === "settings" ? route.tab : "mailboxes";
const currentUserId = user?.id ?? null;
const draftState = useDrafts(currentUserId);
const selectedDraft =
selectedDraftId === null
? null
: (draftState.drafts.find((draft) => draft.id === selectedDraftId) ?? null);
const contentMailboxes = React.useMemo(
() => mailboxes.filter((mailbox) => mailbox.accessLevel !== null),
[mailboxes]
Expand Down Expand Up @@ -121,7 +135,8 @@ export function App(): React.ReactElement {
<>
<AppShell
activeFolder={activeFolder}
immersiveOnCompact={activeFolder !== "settings" && selectedId !== null}
draftCount={draftState.drafts.length}
immersiveOnCompact={route.kind === "mail" && selectedId !== null}
mailboxId={mailboxId}
mailboxes={contentMailboxes}
unread={mailSync.notifications.unread}
Expand All @@ -139,7 +154,9 @@ export function App(): React.ReactElement {
navigate(
folder === "settings"
? { kind: "settings", tab: "mailboxes" }
: { kind: "mail", folder, messageId: null }
: folder === "drafts"
? { kind: "drafts", draftId: null }
: { kind: "mail", folder, messageId: null }
);
}}
onMailboxChange={setMailboxId}
Expand All @@ -165,12 +182,23 @@ export function App(): React.ReactElement {
updateProgress={updateMonitor.progress}
updateStatus={updateMonitor.status}
/>
) : activeFolder === "drafts" ? (
<DraftsPage
drafts={draftState.drafts}
isLoading={draftState.isLoading}
mailboxId={mailboxId}
search={search}
selectedId={selectedDraftId}
onBack={() => navigate({ kind: "drafts", draftId: null })}
onSelect={(draftId) => navigate({ kind: "drafts", draftId })}
/>
) : (
<InboxPage
activeFolder={activeFolder as MailFolderId}
conversations={mailSync.conversations}
mailboxes={contentMailboxes}
selectedId={selectedId}
onDraftsChange={() => void draftState.refresh().catch(() => undefined)}
onRefresh={() => void mailSync.refresh().catch(() => undefined)}
onMessageRouteChange={(folder, messageId) =>
navigate({ kind: "mail", folder, messageId })
Expand All @@ -190,6 +218,20 @@ export function App(): React.ReactElement {
mode="new"
open={composeOpen}
onOpenChange={setComposeOpen}
onDraftsChange={() => void draftState.refresh().catch(() => undefined)}
onSent={() => void mailSync.refresh().catch(() => undefined)}
/>
</React.Suspense>
) : null}
{selectedDraft ? (
<React.Suspense fallback={null}>
<DraftComposeDialog
draft={selectedDraft}
mailboxes={contentMailboxes}
onDraftsChange={() => void draftState.refresh().catch(() => undefined)}
onOpenChange={(open) => {
if (!open) navigate({ kind: "drafts", draftId: null });
}}
onSent={() => void mailSync.refresh().catch(() => undefined)}
/>
</React.Suspense>
Expand Down
3 changes: 3 additions & 0 deletions app/components/layout/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type AppShellProps = {
updateInProgress: boolean;
updateStatus: UpdateStatus | null;
unread: UnreadCounts;
draftCount: number;
onCompose: () => void;
onFolderChange: (folder: FolderId) => void;
onMailboxChange: (mailboxId: string) => void;
Expand All @@ -33,6 +34,7 @@ export function AppShell(props: AppShellProps): React.ReactElement {
<div className="flex h-screen h-[100dvh] overflow-hidden bg-background pt-[env(safe-area-inset-top)] text-foreground">
<Sidebar
activeFolder={props.activeFolder}
draftCount={props.draftCount}
unread={props.unread}
user={props.user}
onFolderChange={props.onFolderChange}
Expand All @@ -42,6 +44,7 @@ export function AppShell(props: AppShellProps): React.ReactElement {
<div className={cn(props.immersiveOnCompact && "hidden lg:contents")}>
<TopBar
activeFolder={props.activeFolder}
draftCount={props.draftCount}
unread={props.unread}
mailboxId={props.mailboxId}
mailboxes={props.mailboxes}
Expand Down
3 changes: 3 additions & 0 deletions app/components/layout/mobile-navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Sidebar } from "./sidebar";

type MobileNavigationProps = {
activeFolder: FolderId;
draftCount: number;
user: CurrentUser;
unread: UnreadCounts;
onFolderChange: (folder: FolderId) => void;
Expand All @@ -19,6 +20,7 @@ type MobileNavigationProps = {

export function MobileNavigation({
activeFolder,
draftCount,
unread,
user,
onFolderChange,
Expand Down Expand Up @@ -64,6 +66,7 @@ export function MobileNavigation({
<SheetTitle className="sr-only">Navigation</SheetTitle>
<Sidebar
activeFolder={activeFolder}
draftCount={draftCount}
unread={unread}
drawerAction={
<Button
Expand Down
40 changes: 33 additions & 7 deletions app/components/layout/sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import type { LucideIcon } from "lucide-react";
import { Archive, Inbox, Send, Settings, Star, Trash2, TriangleAlert } from "lucide-react";
import {
Archive,
FilePenLine,
Inbox,
Send,
Settings,
Star,
Trash2,
TriangleAlert
} from "lucide-react";
import type * as React from "react";

import { Button } from "@/components/ui/button";
Expand All @@ -8,11 +17,12 @@ import type { UnreadCounts } from "@/features/notifications/types";
import { ThemeSwitcher } from "@/features/theme/theme-switcher";
import { cn } from "@/lib/cn";
import type { FolderId } from "@/lib/routes";
import { appRoutePath, mailFolders } from "@/lib/routes";
import { appRoutePath, draftFolder, mailFolders } from "@/lib/routes";
import { AccountMenu } from "./account-menu";

type SidebarProps = {
activeFolder: FolderId;
draftCount?: number;
drawerAction?: React.ReactNode;
user: CurrentUser;
unread: UnreadCounts;
Expand All @@ -24,6 +34,7 @@ type SidebarProps = {
const icons: Record<FolderId, LucideIcon> = {
inbox: Inbox,
sent: Send,
drafts: FilePenLine,
starred: Star,
archived: Archive,
trash: Trash2,
Expand All @@ -33,6 +44,7 @@ const icons: Record<FolderId, LucideIcon> = {

export function Sidebar({
activeFolder,
draftCount = 0,
drawerAction,
unread,
user,
Expand All @@ -41,6 +53,13 @@ export function Sidebar({
variant = "desktop"
}: SidebarProps): React.ReactElement {
const isDrawer = variant === "drawer";
const navigationFolders: Array<(typeof mailFolders)[number] | typeof draftFolder> = [];
for (const folder of mailFolders) {
navigationFolders.push(folder);
if (folder.id === "sent" && (draftCount > 0 || activeFolder === "drafts")) {
navigationFolders.push(draftFolder);
}
}

return (
<aside
Expand All @@ -56,10 +75,11 @@ export function Sidebar({
<span className="text-sm font-medium tracking-tight">HQBase</span>
</div>
<nav className="flex flex-1 flex-col gap-0.5">
{mailFolders.map((folder) => {
{navigationFolders.map((folder) => {
const Icon = icons[folder.id];
const unreadCount =
folder.id === "inbox" ? unread.inbox : folder.id === "catchall" ? unread.catchall : 0;
const count = folder.id === "drafts" ? draftCount : unreadCount;
return (
<Button
asChild
Expand All @@ -74,7 +94,11 @@ export function Sidebar({
<a
aria-current={activeFolder === folder.id ? "page" : undefined}
data-navigation-item
href={appRoutePath({ kind: "mail", folder: folder.id, messageId: null })}
href={appRoutePath(
folder.id === "drafts"
? { kind: "drafts", draftId: null }
: { kind: "mail", folder: folder.id, messageId: null }
)}
onClick={(event) => {
if (isModifiedNavigation(event)) return;
event.preventDefault();
Expand All @@ -83,10 +107,12 @@ export function Sidebar({
>
<Icon />
<span className="min-w-0 flex-1 truncate">{folder.label}</span>
{unreadCount > 0 ? (
{count > 0 ? (
<span className="ml-auto font-mono text-[11px] text-foreground">
<span className="sr-only">{unreadCount} unread</span>
<span aria-hidden="true">{unreadCount.toLocaleString()}</span>
<span className="sr-only">
{folder.id === "drafts" ? `${count} drafts` : `${count} unread`}
</span>
<span aria-hidden="true">{count.toLocaleString()}</span>
</span>
) : null}
</a>
Expand Down
3 changes: 3 additions & 0 deletions app/components/layout/top-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { MobileNavigation } from "./mobile-navigation";

type TopBarProps = {
activeFolder: FolderId;
draftCount: number;
user: CurrentUser;
mailboxes: Mailbox[];
mailboxId: string;
Expand All @@ -34,6 +35,7 @@ type TopBarProps = {

export function TopBar({
activeFolder,
draftCount,
user,
mailboxes,
mailboxId,
Expand All @@ -52,6 +54,7 @@ export function TopBar({
<header className="flex h-14 w-full shrink-0 items-center gap-2 border-b bg-background px-3 md:px-4">
<MobileNavigation
activeFolder={activeFolder}
draftCount={draftCount}
unread={unread}
user={user}
onFolderChange={onFolderChange}
Expand Down
55 changes: 1 addition & 54 deletions app/features/compose/api.ts
Original file line number Diff line number Diff line change
@@ -1,58 +1,5 @@
import type { MessageSummary } from "@/features/messages/types";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api-client";

export type DraftAttachment = {
id: string;
filename: string;
contentType: string;
sizeBytes: number;
};
export type Draft = {
id: string;
mailboxId: string | null;
replyToMessageId: string | null;
forwardOfMessageId: string | null;
from: string;
to: string[];
cc: string[];
bcc: string[];
subject: string;
text: string;
html: string;
version: number;
updatedAt: string;
attachments: DraftAttachment[];
};
export type DraftInput = Omit<Draft, "id" | "version" | "updatedAt" | "attachments"> & {
id?: string;
version?: number;
};

export const listDrafts = () => apiGet<Draft[]>("/api/drafts");
export const createDraft = (input: DraftInput) => apiPost<Draft>("/api/drafts", input);
export const updateDraft = (id: string, input: DraftInput) =>
apiPatch<Draft>(`/api/drafts/${id}`, input);
export const deleteDraft = (id: string) => apiDelete(`/api/drafts/${id}`);
export const deleteDraftAttachment = (draftId: string, id: string) =>
apiDelete(`/api/drafts/${draftId}/attachments/${id}`);
export async function uploadDraftAttachment(draftId: string, file: File): Promise<DraftAttachment> {
const form = new FormData();
form.set("file", file);
const response = await fetch(`/api/drafts/${draftId}/attachments`, {
method: "POST",
body: form,
credentials: "include"
});
const body = (await response.json().catch(() => null)) as
| DraftAttachment
| { error?: { message?: string } }
| null;
if (!response.ok)
throw new Error(
body && "error" in body ? (body.error?.message ?? "Upload failed.") : "Upload failed."
);
return body as DraftAttachment;
}
import { apiPost } from "@/lib/api-client";

export async function sendMessage(input: {
from: string;
Expand Down
2 changes: 1 addition & 1 deletion app/features/compose/attachment-list.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Paperclip, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { DraftAttachment } from "./api";
import type { DraftAttachment } from "@/features/drafts/types";
export function AttachmentList({
attachments,
onRemove
Expand Down
Loading