You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
614 lines
22 KiB
TypeScript
614 lines
22 KiB
TypeScript
// frontend/components/mail/MailClient.tsx — 메일 페이지 오케스트레이터 (Topbar 는 layout)
|
|
"use client";
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { useParams } from "next/navigation";
|
|
import useSWR, { useSWRConfig } from "swr";
|
|
import { tasksApi } from "@/lib/tasks/api";
|
|
import { mailApi } from "@/lib/mail/api";
|
|
import { useMail } from "@/lib/hooks/useMail";
|
|
import { useDialog } from "@/components/Dialog";
|
|
import type { AiReply, EmailDetail, EmailRow, ExtractKind, MailAccount, Person } from "@/lib/types";
|
|
import { MailSidebar } from "./MailSidebar";
|
|
import { MailList } from "./MailList";
|
|
import { Reader } from "./Reader";
|
|
import { Triage } from "./Triage";
|
|
import { Compose, type ComposeState } from "./Compose";
|
|
|
|
// 계정 구분 색 — 계정 tone 이 서로 겹쳐(둘 다 blue) dot 으로 구분이 안 되므로 팔레트에서 배정.
|
|
const ACC_PALETTE = ["var(--blue)", "var(--violet)", "var(--coral)", "var(--green)", "var(--amber)"];
|
|
|
|
const FOLDER_META: Record<string, string> = {
|
|
smart: "아리 정리",
|
|
inbox: "받은편지함",
|
|
others: "기타",
|
|
starred: "별표",
|
|
sent: "보낸편지함",
|
|
drafts: "임시보관함",
|
|
archive: "보관함",
|
|
};
|
|
|
|
type Toast = { id: number; msg: string; tone: string };
|
|
|
|
function toneColor(tone: string): string {
|
|
return tone === "coral"
|
|
? "var(--coral)"
|
|
: tone === "violet"
|
|
? "var(--violet)"
|
|
: tone === "green"
|
|
? "var(--green)"
|
|
: "var(--blue)";
|
|
}
|
|
|
|
export function MailClient() {
|
|
// 기본은 통합 받은편지함(전체 받은편지함). 아리 정리(smart)는 사이드바에서 선택.
|
|
// folder 는 폴더 슬러그(inbox/spam/trash/starred/cat:…/label:…/of:…) — 사이드바 트리가 지정.
|
|
const [folder, setFolder] = useState<string>("inbox");
|
|
const [acctFilter, setAcctFilter] = useState<string | null>(null);
|
|
const [query, setQuery] = useState("");
|
|
const [debouncedQuery, setDebouncedQuery] = useState("");
|
|
const [selId, setSelId] = useState<string | null>(null);
|
|
const [detail, setDetail] = useState<EmailDetail | null>(null);
|
|
const [detailLoading, setDetailLoading] = useState(false);
|
|
const [added, setAdded] = useState<Record<string, boolean>>({});
|
|
const [compose, setCompose] = useState<ComposeState | null>(null);
|
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
|
const [syncing, setSyncing] = useState(false);
|
|
const [analyzing, setAnalyzing] = useState(false);
|
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
const shellRef = useRef<HTMLDivElement>(null);
|
|
const tid = useRef(0);
|
|
const didMount = useRef(false);
|
|
|
|
// URL 로 열린 메일을 표현 — /mail/<id> 의 <id> 세그먼트(옵셔널 캐치올).
|
|
// 주소 갱신은 router 대신 history.replaceState(얕은 갱신) — router.replace 는 이 라우트를
|
|
// 리마운트시켜 folder/검색 상태가 날아가고 openMail 루프가 발생하기 때문.
|
|
const params = useParams();
|
|
const routeId = Array.isArray(params.slug) ? params.slug[0] : undefined;
|
|
|
|
// 검색은 서버측(백엔드 q 파라미터). 입력은 250ms 디바운스해 키 입력마다 호출하지 않음.
|
|
useEffect(() => {
|
|
const h = setTimeout(() => setDebouncedQuery(query), 250);
|
|
return () => clearTimeout(h);
|
|
}, [query]);
|
|
|
|
const { emails, accounts, folders, mutate } = useMail(acctFilter, folder, debouncedQuery);
|
|
const { mutate: globalMutate } = useSWRConfig();
|
|
const { confirm } = useDialog();
|
|
const { data: people } = useSWR<Person[]>("people", () => tasksApi.people(), {
|
|
revalidateOnFocus: false,
|
|
});
|
|
// 아리 정리 기준 시각(이 시각 이후 새 메일만 정리) + 새 안읽음 수(사이드바 배지).
|
|
// 배지는 어느 폴더 보기에서도 필요하므로 항상 조회한다.
|
|
const { data: baseline, mutate: mutateBaseline } = useSWR(
|
|
"mail/triage/baseline",
|
|
() => mailApi.triageBaseline(),
|
|
{ revalidateOnFocus: false, refreshInterval: 30000 },
|
|
);
|
|
|
|
const peopleMap = useMemo<Record<string, Person>>(() => {
|
|
const m: Record<string, Person> = {};
|
|
(people || []).forEach((p) => (m[p.id] = p));
|
|
return m;
|
|
}, [people]);
|
|
const accMap = useMemo<Record<string, MailAccount>>(() => {
|
|
const m: Record<string, MailAccount> = {};
|
|
(accounts || []).forEach((a) => (m[a.id] = a));
|
|
return m;
|
|
}, [accounts]);
|
|
|
|
useEffect(() => {
|
|
const el = shellRef.current;
|
|
const id = requestAnimationFrame(() => el && el.classList.add("entered"));
|
|
return () => cancelAnimationFrame(id);
|
|
}, [emails]);
|
|
|
|
const toast = (msg: string, tone: string) => {
|
|
const id = ++tid.current;
|
|
setToasts((t) => [...t, { id, msg, tone }]);
|
|
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 2600);
|
|
};
|
|
|
|
// 목록 + 사이드바 배지(계정/폴더 안읽음)까지 함께 갱신.
|
|
// 읽음/별표/보관/삭제 등은 백엔드가 즉시 반영하지만, 계정/폴더 SWR 키는 별도라
|
|
// mutate() 만으론 사이드바 숫자가 안 줄어든다 → 함께 revalidate.
|
|
const revalidateAll = async () => {
|
|
await Promise.all([
|
|
mutate(),
|
|
globalMutate("mail/accounts"),
|
|
globalMutate("mail/folders"),
|
|
globalMutate("mail/triage/baseline"), // 아리 정리 배지(새 안읽음)
|
|
]);
|
|
};
|
|
|
|
const openMail = async (id: string) => {
|
|
// 보낸편지함/임시보관함 행(ob-…)은 상세 조회 대상이 아니라 목록 표시 전용.
|
|
if (folder === "sent" || folder === "drafts") return;
|
|
setSelId(id);
|
|
setDetail(null);
|
|
setDetailLoading(true);
|
|
try {
|
|
const d = await mailApi.detail(id);
|
|
setDetail(d);
|
|
} catch {
|
|
toast("메일을 불러오지 못했어요", "coral");
|
|
} finally {
|
|
setDetailLoading(false);
|
|
revalidateAll(); // 읽음 처리 + 사이드바 배지 반영
|
|
}
|
|
};
|
|
|
|
// URL → 상세: routeId(주소의 <id>)가 바뀌면 해당 메일을 연다(직접 입력·뒤로가기·딥링크).
|
|
useEffect(() => {
|
|
if (!routeId) {
|
|
setSelId(null);
|
|
setDetail(null);
|
|
return;
|
|
}
|
|
if (routeId === selId) return; // 이미 열려 있음
|
|
openMail(routeId);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [routeId]);
|
|
|
|
// 상세 → URL: selId 가 바뀌면 주소를 맞춘다(초기 마운트는 위 effect 가 처리하므로 skip).
|
|
useEffect(() => {
|
|
if (!didMount.current) {
|
|
didMount.current = true;
|
|
return;
|
|
}
|
|
const target = selId ? `/mail/${selId}` : "/mail";
|
|
if (window.location.pathname !== target) window.history.replaceState(null, "", target);
|
|
}, [selId]);
|
|
|
|
// 사이드바 트리 선택 = (계정 범위, 폴더 슬러그) 동시 지정.
|
|
const pickScope = (acct: string | null, f: string) => {
|
|
setAcctFilter(acct);
|
|
setFolder(f);
|
|
setSelId(null);
|
|
setDetail(null);
|
|
setSelected(new Set());
|
|
};
|
|
|
|
const onStar = async (id: string) => {
|
|
const cur = emails?.find((m) => m.id === id);
|
|
const next = !(cur?.starred ?? detail?.starred ?? false);
|
|
// 낙관적 토글 → 서버 반영 → 실패 시 롤백
|
|
if (detail && detail.id === id) setDetail({ ...detail, starred: next });
|
|
mutate((list) => (list || []).map((m) => (m.id === id ? { ...m, starred: next } : m)), false);
|
|
try {
|
|
await mailApi.star(id, next);
|
|
revalidateAll();
|
|
} catch (e) {
|
|
if (detail && detail.id === id) setDetail({ ...detail, starred: !next });
|
|
revalidateAll();
|
|
toast(e instanceof Error ? e.message : "별표를 바꾸지 못했어요", "coral");
|
|
}
|
|
};
|
|
|
|
const onArchive = async (id: string) => {
|
|
setSelId(null);
|
|
setDetail(null);
|
|
mutate((list) => (list || []).filter((m) => m.id !== id), false);
|
|
try {
|
|
await mailApi.archive(id);
|
|
revalidateAll();
|
|
toast("보관함으로 옮겼어요", "green");
|
|
} catch (e) {
|
|
revalidateAll();
|
|
toast(e instanceof Error ? e.message : "보관하지 못했어요", "coral");
|
|
}
|
|
};
|
|
|
|
const onMarkUnread = async (id: string) => {
|
|
// 안 읽음 처리 → 목록 낙관적 갱신 → 리더 닫고 목록으로 복귀
|
|
mutate((list) => (list || []).map((m) => (m.id === id ? { ...m, read: false } : m)), false);
|
|
setSelId(null);
|
|
setDetail(null);
|
|
try {
|
|
await mailApi.setRead(id, false);
|
|
revalidateAll();
|
|
toast("안 읽음으로 표시했어요", "blue");
|
|
} catch (e) {
|
|
revalidateAll();
|
|
toast(e instanceof Error ? e.message : "안 읽음으로 표시하지 못했어요", "coral");
|
|
}
|
|
};
|
|
|
|
const onDelete = async (id: string) => {
|
|
const ok = await confirm({
|
|
title: "메일 삭제",
|
|
message: "이 메일을 삭제할까요?",
|
|
confirmText: "삭제",
|
|
tone: "danger",
|
|
});
|
|
if (!ok) return;
|
|
setSelId(null);
|
|
setDetail(null);
|
|
mutate((list) => (list || []).filter((m) => m.id !== id), false);
|
|
try {
|
|
await mailApi.remove(id);
|
|
revalidateAll();
|
|
toast("메일을 삭제했어요", "green");
|
|
} catch (e) {
|
|
revalidateAll();
|
|
toast(e instanceof Error ? e.message : "삭제하지 못했어요", "coral");
|
|
}
|
|
};
|
|
|
|
// ── 여러 메일 선택 + 일괄 처리 ──
|
|
const toggleSelect = (id: string) =>
|
|
setSelected((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
return next;
|
|
});
|
|
const clearSelected = () => setSelected(new Set());
|
|
const onBulk = async (action: string) => {
|
|
const ids = [...selected];
|
|
if (!ids.length) return;
|
|
if (action === "trash") {
|
|
const ok = await confirm({
|
|
title: "메일 삭제",
|
|
message: `${ids.length}개 메일을 삭제할까요?`,
|
|
confirmText: "삭제",
|
|
tone: "danger",
|
|
});
|
|
if (!ok) return;
|
|
}
|
|
// 즉시 목록에서 반영(삭제/보관은 사라지고, 읽음/별표는 토글)
|
|
mutate((list) => {
|
|
if (action === "trash" || action === "archive")
|
|
return (list || []).filter((m) => !selected.has(m.id));
|
|
return (list || []).map((m) =>
|
|
selected.has(m.id)
|
|
? {
|
|
...m,
|
|
read: action === "read" ? true : action === "unread" ? false : m.read,
|
|
starred: action === "star" ? true : action === "unstar" ? false : m.starred,
|
|
}
|
|
: m,
|
|
);
|
|
}, false);
|
|
clearSelected();
|
|
try {
|
|
const res = await mailApi.bulk(ids, action);
|
|
revalidateAll();
|
|
toast(`${res.count}개 메일을 처리했어요`, "green");
|
|
} catch (e) {
|
|
revalidateAll();
|
|
toast(e instanceof Error ? e.message : "일괄 처리에 실패했어요", "coral");
|
|
}
|
|
};
|
|
|
|
// 전체 답장 / 전달 — 기존 발송(결재) 경로 재사용해 Compose 채움.
|
|
const onReplyAll = (m: EmailDetail) => {
|
|
const sender = peopleMap[m.from];
|
|
const to = sender?.email ? `${sender.name} <${sender.email}>` : (sender?.name ?? m.from);
|
|
setCompose({
|
|
from: m.account,
|
|
to,
|
|
subject: "Re: " + m.subject,
|
|
body: "",
|
|
inReplyTo: m.id,
|
|
});
|
|
};
|
|
const onForward = (m: EmailDetail) => {
|
|
const quoted = (m.body || []).map((p) => "> " + p).join("\n");
|
|
setCompose({
|
|
from: m.account,
|
|
to: "",
|
|
subject: "Fwd: " + m.subject,
|
|
body: `\n\n---------- 전달된 메일 ----------\n${quoted}`,
|
|
});
|
|
};
|
|
|
|
// 추출(할일/일정/파일) — mailId 지정(없으면 현재 열린 메일 selId). 리더·아리비서 공용.
|
|
const doAddFor = async (mailId: string, key: string, kind: ExtractKind, index: number) => {
|
|
if (added[key]) return;
|
|
setAdded((a) => ({ ...a, [key]: true })); // 낙관적
|
|
try {
|
|
const res = await mailApi.extract(mailId, kind, index);
|
|
toast(res.message, kind === "event" ? "coral" : kind === "file" ? "violet" : "blue");
|
|
if (kind === "task") globalMutate("/api/tasks");
|
|
if (kind === "event") globalMutate("/api/calendar");
|
|
} catch {
|
|
setAdded((a) => ({ ...a, [key]: false })); // 롤백
|
|
toast("추가하지 못했어요", "coral");
|
|
}
|
|
};
|
|
const doAdd = (key: string, kind: ExtractKind, index: number) =>
|
|
doAddFor(selId!, key, kind, index);
|
|
|
|
// 아리 비서: 새 메일을 가져와(sync) 미분석분을 LLM 으로 정리. 먼저 동기화해
|
|
// 기준 시각 이후 새로 도착한 메일을 받고, 작은 배치(2통)로 나눠 반복 분석 —
|
|
// 요청을 짧게 유지하고 라운드마다 목록을 갱신해 카드를 점진적으로 노출한다.
|
|
const runAnalyze = async () => {
|
|
if (analyzing) return;
|
|
setAnalyzing(true);
|
|
let total = 0;
|
|
try {
|
|
await mailApi.sync().catch(() => {}); // 새 메일 먼저 가져오기(실패해도 분석 진행)
|
|
await revalidateAll();
|
|
for (let round = 0; round < 8; round++) {
|
|
const res = await mailApi.triageAnalyze(2);
|
|
total += res.analyzed;
|
|
await mutate(); // 라운드마다 카드 갱신
|
|
if (res.remaining === 0 || res.analyzed === 0) break;
|
|
}
|
|
toast(total > 0 ? `새 메일 ${total}통을 정리했어요` : "정리할 새 메일이 없어요", "blue");
|
|
} catch {
|
|
if (total > 0) await mutate();
|
|
toast("정리하지 못했어요 (아리 모델 확인)", "coral");
|
|
} finally {
|
|
setAnalyzing(false);
|
|
}
|
|
};
|
|
|
|
// 기준 시각 재설정 — 지금 이후 도착하는 새 메일부터 다시 정리.
|
|
const onResetBaseline = async () => {
|
|
try {
|
|
await mailApi.triageBaselineReset();
|
|
await Promise.all([mutateBaseline(), mutate()]);
|
|
toast("지금부터 도착하는 새 메일만 정리할게요", "blue");
|
|
} catch {
|
|
toast("기준 시각을 바꾸지 못했어요", "coral");
|
|
}
|
|
};
|
|
|
|
const addAll = async (m: EmailRow) => {
|
|
if (!m.ai) return;
|
|
const next = { ...added };
|
|
m.ai.tasks.forEach((_, i) => (next["t:" + m.id + ":" + i] = true));
|
|
m.ai.events.forEach((_, i) => (next["e:" + m.id + ":" + i] = true));
|
|
if (m.ai.file) next["f:" + m.id] = true;
|
|
setAdded(next);
|
|
try {
|
|
const res = await mailApi.extractAll(m.id);
|
|
toast(res.message, "green");
|
|
globalMutate("/api/tasks");
|
|
globalMutate("/api/calendar");
|
|
} catch {
|
|
toast("적용하지 못했어요", "coral");
|
|
}
|
|
};
|
|
|
|
// 답장: AriPanel/Triage 의 reply 선택 → 서버 초안(AI) + 원본 인용 → Compose 채움
|
|
const onReply = async (
|
|
m: EmailRow | EmailDetail,
|
|
replyIndex: number | null,
|
|
_r: AiReply | null,
|
|
) => {
|
|
void _r;
|
|
try {
|
|
const d = await mailApi.replyDraft(m.id, replyIndex);
|
|
// 원본 본문: 열린 상세(detail) 우선, 없으면 m 자체가 상세면 그 body 사용
|
|
const bodyLines =
|
|
detail && detail.id === m.id
|
|
? detail.body
|
|
: "body" in m && Array.isArray(m.body)
|
|
? m.body
|
|
: [];
|
|
const quoted = bodyLines.map((p) => "> " + p).join("\n");
|
|
const draft = d.body || "";
|
|
const body = quoted
|
|
? `${draft}\n\n--------\n원본 메일:\n${quoted}`
|
|
: draft;
|
|
setCompose({
|
|
from: d.from_account,
|
|
to: d.to,
|
|
subject: d.subject,
|
|
body,
|
|
inReplyTo: m.id,
|
|
});
|
|
} catch {
|
|
toast("초안을 만들지 못했어요", "coral");
|
|
}
|
|
};
|
|
|
|
const onSend = async (c: ComposeState) => {
|
|
try {
|
|
const res = await mailApi.send({
|
|
from_account: c.from,
|
|
to: c.to ?? "",
|
|
cc: c.cc ?? "",
|
|
bcc: c.bcc ?? "",
|
|
subject: c.subject ?? "",
|
|
body: c.body ?? "",
|
|
in_reply_to: c.inReplyTo ?? null,
|
|
attachments: c.attachments ?? [],
|
|
});
|
|
setCompose(null);
|
|
toast(res.message, "violet"); // "결재함에서 확인하세요"
|
|
globalMutate("/api/approvals");
|
|
} catch {
|
|
toast("보내지 못했어요", "coral");
|
|
}
|
|
};
|
|
|
|
if (!emails || !accounts || !folders) {
|
|
return (
|
|
<div className="mailpage">
|
|
<div className="mwork" aria-busy="true">
|
|
<aside className="subnav" />
|
|
<div className="mlist" />
|
|
<div className="reader" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 목록은 서버가 폴더 슬러그로 이미 필터·정렬 — 그대로 표시.
|
|
const visible = emails;
|
|
|
|
// 폴더를 계정별로 그룹화(사이드바 계정 노드 서브트리). 정렬 키대로.
|
|
const foldersByAccount: Record<string, typeof folders> = {};
|
|
folders.forEach((f) => (foldersByAccount[f.account] ||= []).push(f));
|
|
Object.values(foldersByAccount).forEach((list) =>
|
|
list.sort((a, b) => a.sort_order - b.sort_order),
|
|
);
|
|
// 통합 받은편지함 노드: 계정 공통(시스템·카테고리) 폴더만 슬러그별 합산(커스텀/라벨 제외).
|
|
const unifiedMap: Record<string, (typeof folders)[number]> = {};
|
|
folders.forEach((f) => {
|
|
// 통합 상단 목록은 공통 시스템 폴더만(받은편지함/별표/보낸/임시/보관/스팸/휴지통).
|
|
// 카테고리(Gmail 프로모션·Outlook 기타)/라벨/커스텀은 각 계정 노드에서만 표시.
|
|
if (f.kind !== "system") return;
|
|
const cur = unifiedMap[f.slug];
|
|
if (cur) {
|
|
cur.unread += f.unread;
|
|
cur.total += f.total;
|
|
} else unifiedMap[f.slug] = { ...f, account: "" };
|
|
});
|
|
const unifiedFolders = Object.values(unifiedMap).sort((a, b) => a.sort_order - b.sort_order);
|
|
|
|
// 아리 정리 배지 = 기준 시각 이후 도착한 새 메일 수(읽음 무관) — 백엔드가 baseline 으로 산출.
|
|
const smartCount = baseline?.count ?? 0;
|
|
// 사이드바 트리 배지(집중 안읽음)는 계정 목록의 unread(백엔드 산출)에서 — 현재 보기와 무관하게 정확.
|
|
const accUnread = (id: string) => accMap[id]?.unread ?? 0;
|
|
const unifiedUnread = accounts.reduce((sum, a) => sum + a.unread, 0);
|
|
// 기타(Outlook other) 안읽음 — 통합/계정별. 사이드바 기타 행·받은편지함 탭 카운트에 사용.
|
|
const unifiedOthersUnread = accounts.reduce((sum, a) => sum + (a.others ?? 0), 0);
|
|
const focusedUnread = acctFilter ? (accMap[acctFilter]?.unread ?? 0) : unifiedUnread;
|
|
const othersUnread = acctFilter ? (accMap[acctFilter]?.others ?? 0) : unifiedOthersUnread;
|
|
const accColor = (id: string) => {
|
|
const i = accounts.findIndex((a) => a.id === id);
|
|
return ACC_PALETTE[(i < 0 ? 0 : i) % ACC_PALETTE.length];
|
|
};
|
|
|
|
// 목록 제목: 폴더 슬러그 → 표시명(시스템은 META, 커스텀/카테고리/라벨은 폴더명에서).
|
|
const curFolder =
|
|
(acctFilter ? foldersByAccount[acctFilter] : unifiedFolders)?.find((f) => f.slug === folder) ||
|
|
folders.find((f) => f.slug === folder);
|
|
const listTitle = FOLDER_META[folder] ?? curFolder?.name ?? "받은편지함";
|
|
|
|
const sel = detail && detail.id === selId ? detail : null;
|
|
// 아리 비서(정리) 보기 = smart 폴더. 메일 리스트 대신 비서 다이제스트를 보여준다.
|
|
const digestView = folder === "smart";
|
|
|
|
const readerEl = (
|
|
<Reader
|
|
m={sel}
|
|
person={sel ? peopleMap[sel.from] : undefined}
|
|
account={sel ? accMap[sel.account] : undefined}
|
|
added={added}
|
|
loading={detailLoading}
|
|
onAdd={doAdd}
|
|
onReply={onReply}
|
|
onReplyAll={onReplyAll}
|
|
onForward={onForward}
|
|
onAddAll={addAll}
|
|
onArchive={onArchive}
|
|
onStar={onStar}
|
|
onMarkUnread={onMarkUnread}
|
|
onDelete={onDelete}
|
|
onOpenMsg={openMail}
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<div className="mailpage" ref={shellRef}>
|
|
<div
|
|
className={
|
|
"mwork" + (digestView ? " digest" : "") + (digestView && selId ? " reading" : "")
|
|
}
|
|
>
|
|
<MailSidebar
|
|
accounts={accounts}
|
|
folder={folder}
|
|
acctFilter={acctFilter}
|
|
smartCount={smartCount}
|
|
unifiedUnread={unifiedUnread}
|
|
accUnread={accUnread}
|
|
unifiedFolders={unifiedFolders}
|
|
foldersByAccount={foldersByAccount}
|
|
accColor={accColor}
|
|
onCompose={() => setCompose({ from: acctFilter || accounts[0]?.id || "" })}
|
|
onSelectSmart={() => pickScope(null, "smart")}
|
|
onSelectScope={pickScope}
|
|
onToast={toast}
|
|
/>
|
|
|
|
{digestView ? (
|
|
<>
|
|
<Triage
|
|
emails={emails}
|
|
people={peopleMap}
|
|
accColor={accColor}
|
|
added={added}
|
|
analyzing={analyzing}
|
|
since={baseline?.since}
|
|
onAnalyze={runAnalyze}
|
|
onResetBaseline={onResetBaseline}
|
|
onOpen={openMail}
|
|
onAdd={doAddFor}
|
|
onAddAll={addAll}
|
|
onReply={onReply}
|
|
/>
|
|
{selId && readerEl}
|
|
</>
|
|
) : (
|
|
<>
|
|
<MailList
|
|
title={listTitle}
|
|
metaLabel={acctFilter ? (accMap[acctFilter]?.name ?? "") + " · " : ""}
|
|
count={visible.length}
|
|
folder={folder}
|
|
focusedUnread={focusedUnread}
|
|
othersUnread={othersUnread}
|
|
onSelectTab={(slug) => pickScope(acctFilter, slug)}
|
|
query={query}
|
|
syncing={syncing}
|
|
emails={visible}
|
|
people={peopleMap}
|
|
accounts={accMap}
|
|
selId={selId}
|
|
hasSel={!!selId}
|
|
selected={selected}
|
|
onToggleSelect={toggleSelect}
|
|
onClearSelected={clearSelected}
|
|
onBulk={onBulk}
|
|
onQuery={setQuery}
|
|
onSync={async () => {
|
|
setSyncing(true);
|
|
try {
|
|
const res = await mailApi.sync();
|
|
await revalidateAll();
|
|
toast(
|
|
res.upserted > 0 ? `새 메일 ${res.upserted}통을 가져왔어요` : "새 메일이 없어요",
|
|
"blue",
|
|
);
|
|
} catch {
|
|
toast("메일을 가져오지 못했어요", "coral");
|
|
} finally {
|
|
setSyncing(false);
|
|
}
|
|
}}
|
|
onOpen={openMail}
|
|
onStar={onStar}
|
|
accColor={accColor}
|
|
/>
|
|
{readerEl}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{compose && (
|
|
<Compose
|
|
init={compose}
|
|
accounts={accounts}
|
|
onClose={() => setCompose(null)}
|
|
onSend={onSend}
|
|
/>
|
|
)}
|
|
|
|
<div className="toast-wrap">
|
|
{toasts.map((t) => (
|
|
<div className="toast" key={t.id}>
|
|
<span className="t-dot" style={{ background: toneColor(t.tone) }} />
|
|
{t.msg}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|