diff --git a/web/src/store/compose.ts b/web/src/store/compose.ts index a5cc58a..8330874 100644 --- a/web/src/store/compose.ts +++ b/web/src/store/compose.ts @@ -25,6 +25,15 @@ export interface ComposeAttachment { abort?: AbortController; } +/** A file in Files, enough of it to attach. */ +export interface AttachableFile { + accountId: Id; + name: string; + type: string | null; + size: number | null; + blobId: Id; +} + export type Priority = "high" | "normal" | "low"; export interface Draft { @@ -76,6 +85,8 @@ interface ComposeState { close(key: string, opts?: { discard?: boolean }): Promise; focus(key: string): void; addFiles(key: string, files: File[]): void; + /** Attach files already in Files, by reference where the account allows it. */ + addFromFiles(key: string, nodes: AttachableFile[]): Promise; removeAttachment(key: string, attId: string): void; saveDraft(key: string, opts?: { silent?: boolean }): Promise; send(key: string): Promise; @@ -361,6 +372,47 @@ export const useCompose = create((set, get) => ({ } }, + /* + * Attach something already in Files. + * + * A blob the account can already see needs no upload: an attachment carrying + * a `blobId` is exactly what a forward produces, so the send path already + * knows what to do with one. Attaching a 20 MB file the server is holding + * anyway then costs nothing and takes no time. + * + * A file in an account somebody *shared* is a different matter. Blobs belong + * to the account they were uploaded to, so a draft in your account cannot + * reference one in theirs; it is fetched and uploaded to yours. Slower, and + * unavoidable, but it happens without the reader having to know any of this. + */ + async addFromFiles(key, nodes) { + const accountId = useMail.getState().accountId; + if (!accountId || !nodes.length) return; + const max = client.maxSizeUpload; + const atts: ComposeAttachment[] = nodes.map((n) => ({ + id: uid("a"), + name: n.name, + type: n.type || "application/octet-stream", + size: n.size ?? 0, + blobId: n.accountId === accountId ? n.blobId : null, + progress: n.accountId === accountId ? 100 : 0, + error: (n.size ?? 0) > max ? `Larger than ${Math.round(max / 1048576)} MB limit` : null, + })); + get().update(key, { attachments: [...(get().drafts.find((d) => d.key === key)?.attachments ?? []), ...atts] }); + + for (const [i, a] of atts.entries()) { + if (a.error || a.blobId) continue; + const node = nodes[i]!; + try { + const blob = await client.fetchBlob(node.accountId, node.blobId, a.type); + const up = await client.upload(accountId, blob, { type: a.type }); + patchAtt(key, a.id, { blobId: up.blobId, progress: 100, size: up.size || a.size }, set); + } catch (err) { + patchAtt(key, a.id, { error: (err as Error).message || "Could not attach" }, set); + } + } + }, + removeAttachment(key, attId) { const d = get().drafts.find((x) => x.key === key); const a = d?.attachments.find((x) => x.id === attId); diff --git a/web/src/views/compose/Composer.tsx b/web/src/views/compose/Composer.tsx index 4126d9d..bb368a3 100644 --- a/web/src/views/compose/Composer.tsx +++ b/web/src/views/compose/Composer.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { AlertTriangle, ChevronDown, FileText, Maximize2, Minimize2, Minus, MoreVertical, Paperclip, Send, Trash2, X, Type, Clock, CheckCheck, ChevronsDown } from "lucide-react"; +import { AlertTriangle, ChevronDown, FileText, FolderOpen, Maximize2, Minimize2, Minus, MoreVertical, Paperclip, Send, Trash2, X, Type, Clock, CheckCheck, ChevronsDown } from "lucide-react"; import { useCompose, type Draft } from "@/store/compose"; import { useMail } from "@/store/mail"; import { useSettings } from "@/store/settings"; @@ -12,6 +12,8 @@ import { formatSize, formatRelative } from "@/lib/format"; import { htmlToText, textToHtml } from "@/lib/text"; import { isValidEmail } from "@/lib/address"; import { attachmentIcon } from "../mail/MessageView"; +import { FilePicker } from "./FilePicker"; +import { useFiles } from "@/store/files"; import { keyboard } from "@/lib/keyboard"; import { useIsMobile } from "@/ui/misc"; import { toast } from "@/ui/toast"; @@ -25,6 +27,9 @@ export function Composer({ draft }: { draft: Draft }) { const send = useCompose((s) => s.send); const saveDraft = useCompose((s) => s.saveDraft); const addFiles = useCompose((s) => s.addFiles); + const addFromFiles = useCompose((s) => s.addFromFiles); + const filesAvailable = useFiles((s) => s.available); + const [pickerOpen, setPickerOpen] = useState(false); const removeAttachment = useCompose((s) => s.removeAttachment); const setIdentity = useCompose((s) => s.setIdentity); const insertTemplate = useCompose((s) => s.insertTemplate); @@ -239,11 +244,13 @@ export function Composer({ draft }: { draft: Draft }) { } label={`Undo window: ${settings.undoSendSeconds}s`} onClick={() => updateSettings({ undoSendSeconds: settings.undoSendSeconds >= 30 ? 0 : settings.undoSendSeconds + 5 })} /> {canSchedule && { sendMenu.close(); setScheduleOpen(true); }} />} + {pickerOpen && void addFromFiles(key, picked)} onClose={() => setPickerOpen(false)} />} {canSchedule && scheduleOpen && ( setScheduleOpen(false)} onPick={scheduleFor} /> )} + {filesAvailable && } { const files = Array.from(e.target.files ?? []); if (files.length) addFiles(key, files); e.target.value = ""; }} /> {d.format === "html" && } {settings.templates.length > 0 && } diff --git a/web/src/views/compose/FilePicker.tsx b/web/src/views/compose/FilePicker.tsx new file mode 100644 index 0000000..c1d96d3 --- /dev/null +++ b/web/src/views/compose/FilePicker.tsx @@ -0,0 +1,138 @@ +import { useEffect, useState } from "react"; +import { ChevronRight, File as FileIcon, Folder, HardDrive, Users } from "lucide-react"; +import { Dialog } from "@/ui/dialog"; +import { Spinner } from "@/ui/misc"; +import { useFiles } from "@/store/files"; +import type { AttachableFile } from "@/store/compose"; +import type { FileNode } from "@/jmap/types"; +import { formatSize } from "@/lib/format"; + +/** + * Pick something already in Files to attach. + * + * Browsing is the store's, so this shows the same folders the Files view does, + * shared accounts included -- a file somebody shared with you is a file you can + * send on, and having to download it first only to upload it again would be + * the sort of detour the rest of this avoids. + * + * It borrows the Files store rather than keeping its own copy, which means + * opening the picker moves where Files is browsing. Closing it puts that back: + * a detour through somebody's shared folder to find an attachment should not + * leave the file manager somewhere else afterwards. + */ +export function FilePicker({ onPick, onClose }: { onPick: (files: AttachableFile[]) => void; onClose: () => void }) { + const files = useFiles(); + const [cur, setCur] = useState(null); + const [picked, setPicked] = useState>({}); + const [returnTo] = useState(() => files.accountId); + + useEffect(() => { + void files.loadChildren(cur); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cur, files.accountId]); + + const close = () => { + if (files.accountId !== returnTo) files.openAccount(returnTo); + onClose(); + }; + + const openAccount = (accountId: string | null) => { + files.openAccount(accountId); + setCur(null); + setPicked({}); + }; + + const nodes = (files.children[cur ?? "root"] ?? []).map((id) => files.nodes[id]).filter((n): n is FileNode => Boolean(n)); + const path = files.pathTo(cur); + const chosen = Object.values(picked); + const viewingShare = files.accountId !== files.ownAccountId; + + return ( + + + + + } + > + {files.sharedAccounts.length > 0 && ( +
+ + {files.sharedAccounts.map((a) => ( + + ))} +
+ )} + +
+ + {path.map((n) => ( + + + + + ))} +
+ + {files.loading && !nodes.length ? ( + + ) : !nodes.length ? ( +

This folder is empty.

+ ) : ( + nodes.map((n) => + n.nodeType === "directory" ? ( + + ) : ( + + ), + ) + )} + + {viewingShare && chosen.length > 0 && ( + // Blobs belong to the account holding them, so one from a share has to + // be copied into yours before a draft can reference it. Worth saying, + // because it is the difference between instant and a wait. +

Shared files are copied to your account when attached.

+ )} +
+ ); +}