feat(mail): complete Quick Access message launches
This commit is contained in:
+1
-1
@@ -26,7 +26,7 @@
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test:mail-ui": "rm -rf .mail-test-build && mkdir -p .mail-test-build && printf '{\"type\":\"commonjs\"}\\n' > .mail-test-build/package.json && tsc -p tsconfig.mail-tests.json && node .mail-test-build/tests/mailbox-display.test.js && node .mail-test-build/tests/mailbox-folders.test.js && node .mail-test-build/tests/mail-profile-editor-model.test.js && node .mail-test-build/tests/mail-policy-validation.test.js && node scripts/test-mailbox-icon-button-structure.mjs && node scripts/test-interface-pattern-language.mjs"
|
||||
"test:mail-ui": "rm -rf .mail-test-build && mkdir -p .mail-test-build && printf '{\"type\":\"commonjs\"}\\n' > .mail-test-build/package.json && tsc -p tsconfig.mail-tests.json && node .mail-test-build/tests/mailbox-display.test.js && node .mail-test-build/tests/mailbox-folders.test.js && node .mail-test-build/tests/mailbox-launch.test.js && node .mail-test-build/tests/mail-profile-editor-model.test.js && node .mail-test-build/tests/mail-policy-validation.test.js && node scripts/test-mailbox-icon-button-structure.mjs && node scripts/test-interface-pattern-language.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
|
||||
@@ -1,27 +1,39 @@
|
||||
import { useCallback } from "react";
|
||||
import { Mail } from "lucide-react";
|
||||
import { ExternalLink, FilePenLine, Mail, Pencil } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import {
|
||||
DashboardWidgetList,
|
||||
DismissibleAlert,
|
||||
LoadingFrame,
|
||||
quickAccessLaunchState,
|
||||
useDashboardWidgetData,
|
||||
type ApiSettings
|
||||
type QuickAccessToolRenderContext
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
bootstrapMailbox,
|
||||
listMailServerProfiles,
|
||||
type MailMailboxMessageSummary
|
||||
} from "../../api/mail";
|
||||
import {
|
||||
mailboxDraftsLaunchPath,
|
||||
mailboxMessageLaunchPath
|
||||
} from "./mailboxLaunch";
|
||||
|
||||
|
||||
type MailQuickAccessData = {
|
||||
profileName?: string;
|
||||
profileId?: string;
|
||||
draftsFolder?: string | null;
|
||||
messages: MailMailboxMessageSummary[];
|
||||
available: boolean;
|
||||
};
|
||||
|
||||
export default function MailQuickAccess({ settings }: { settings: ApiSettings }) {
|
||||
type Props = Pick<
|
||||
QuickAccessToolRenderContext,
|
||||
"settings" | "launchContext" | "close"
|
||||
>;
|
||||
|
||||
export default function MailQuickAccess({ settings, launchContext, close }: Props) {
|
||||
const load = useCallback(async (): Promise<MailQuickAccessData> => {
|
||||
const profiles = await listMailServerProfiles(settings);
|
||||
const profile = profiles.find((item) => item.is_active && item.imap);
|
||||
@@ -29,6 +41,10 @@ export default function MailQuickAccess({ settings }: { settings: ApiSettings })
|
||||
const response = await bootstrapMailbox(settings, profile.id, "INBOX", 7, 0, false);
|
||||
return {
|
||||
profileName: profile.name,
|
||||
profileId: profile.id,
|
||||
draftsFolder: profile.imap?.folder_mappings?.drafts
|
||||
|| response.folders.detected_folder_mappings?.drafts
|
||||
|| null,
|
||||
messages: response.messages.messages ?? [],
|
||||
available: true
|
||||
};
|
||||
@@ -46,11 +62,33 @@ export default function MailQuickAccess({ settings }: { settings: ApiSettings })
|
||||
detail: message.from_header || data?.profileName,
|
||||
meta: formatMessageDate(message.date),
|
||||
leading: <Mail size={17} aria-hidden="true" />,
|
||||
to: "/mail"
|
||||
to: mailboxMessageLaunchPath(data!.profileId!, message),
|
||||
state: quickAccessLaunchState(launchContext),
|
||||
onClick: close
|
||||
}))}
|
||||
/>
|
||||
<div className="dashboard-contribution-footer">
|
||||
<Link className="btn btn-secondary" to="/mail">i18n:govoplan-mail.mail.92379cbb</Link>
|
||||
<a className="btn btn-secondary" href="mailto:" onClick={close}>
|
||||
<Pencil size={15} aria-hidden="true" /> i18n:govoplan-mail.compose
|
||||
</a>
|
||||
{data?.profileId && data.draftsFolder ? (
|
||||
<Link
|
||||
className="btn btn-secondary"
|
||||
to={mailboxDraftsLaunchPath(data.profileId, data.draftsFolder)}
|
||||
state={quickAccessLaunchState(launchContext)}
|
||||
onClick={close}
|
||||
>
|
||||
<FilePenLine size={15} aria-hidden="true" /> i18n:govoplan-mail.drafts.22a31d86
|
||||
</Link>
|
||||
) : null}
|
||||
<Link
|
||||
className="btn btn-secondary"
|
||||
to="/mail"
|
||||
state={quickAccessLaunchState(launchContext)}
|
||||
onClick={close}
|
||||
>
|
||||
<ExternalLink size={15} aria-hidden="true" /> i18n:govoplan-mail.open_mail
|
||||
</Link>
|
||||
</div>
|
||||
</LoadingFrame>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Activity, ChevronRight, Database, Home, Mail, MailOpen, Paperclip, RefreshCw, Search, X } from "lucide-react";
|
||||
import { useLocation } from "react-router";
|
||||
import { ToolbarGroup, ActionToolbar,
|
||||
ActionBlockerHint,
|
||||
Button,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
"../../api/mail";
|
||||
import { buildMailboxFolderTree, findFolderNodeId, folderAncestorIds, type MailFolderNode } from "./mailboxFolders";
|
||||
import { isMailboxMessageRead, mailboxSyncState, type MailboxSyncProvenance } from "./mailboxDisplay";
|
||||
import { mailboxLaunchFolder, parseMailboxLaunch, type MailboxLaunch } from "./mailboxLaunch";
|
||||
|
||||
const MAILBOX_DOCUMENTATION = {
|
||||
topicId: "mail.workflow.read-mailbox",
|
||||
@@ -38,6 +40,7 @@ const MAILBOX_DOCUMENTATION = {
|
||||
|
||||
export default function MailboxPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
|
||||
const navigate = useGuardedNavigate();
|
||||
const location = useLocation();
|
||||
const [profiles, setProfiles] = useState<MailServerProfile[]>([]);
|
||||
const [selectedProfileId, setSelectedProfileId] = useState("");
|
||||
const [folders, setFolders] = useState<MailImapFolderResponse[]>([]);
|
||||
@@ -67,6 +70,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
const messageDetailRequestRef = useRef(0);
|
||||
const mailboxPageCursorsRef = useRef<Record<string, string | null>>({});
|
||||
const skipNextMessageLoadRef = useRef(false);
|
||||
const launchRequestRef = useRef<MailboxLaunch | null>(parseMailboxLaunch(location.search));
|
||||
|
||||
const selectedProfile = profiles.find((profile) => profile.id === selectedProfileId) ?? null;
|
||||
const imapProfiles = useMemo(() => profiles.filter((profile) => profile.is_active && profile.imap), [profiles]);
|
||||
@@ -99,6 +103,21 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
: "";
|
||||
|
||||
useEffect(() => {void loadProfiles();}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
useEffect(() => {
|
||||
const request = parseMailboxLaunch(location.search);
|
||||
launchRequestRef.current = request;
|
||||
if (!profiles.length || (!request.profileId && !request.folder && !request.folderRole && !request.messageUid)) return;
|
||||
const usable = profiles.filter((profile) => profile.is_active && profile.imap);
|
||||
const targetProfileId = request.profileId && usable.some((profile) => profile.id === request.profileId)
|
||||
? request.profileId
|
||||
: selectedProfileId || usable[0]?.id || "";
|
||||
if (!targetProfileId) return;
|
||||
if (targetProfileId !== selectedProfileId) {
|
||||
selectProfile(targetProfileId);
|
||||
return;
|
||||
}
|
||||
void loadMailboxBootstrap(targetProfileId);
|
||||
}, [location.search]);
|
||||
useEffect(() => {selectedMessageKeyRef.current = selectedMessageKeyState;}, [selectedMessageKeyState]);
|
||||
useEffect(() => {if (messagePage > messagePageCount) setMessagePage(messagePageCount);}, [messagePage, messagePageCount]);
|
||||
useEffect(() => {if (selectedProfileId) void loadMailboxBootstrap(selectedProfileId);}, [selectedProfileId]);
|
||||
@@ -111,6 +130,19 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
void loadMessages(selectedProfileId, selectedFolder, messagePage, messagePageSize);
|
||||
}, [foldersReady, messagePage, messagePageSize, selectedProfileId, selectedFolder]);
|
||||
|
||||
useEffect(() => {
|
||||
const request = launchRequestRef.current;
|
||||
if (!request || !foldersReady || loadingMessages) return;
|
||||
const targetProfileMatches = !request.profileId || request.profileId === selectedProfileId;
|
||||
const targetFolder = mailboxLaunchFolder(request, selectedProfile);
|
||||
if (!targetProfileMatches || (targetFolder && targetFolder !== selectedFolder)) return;
|
||||
const target = request.messageUid
|
||||
? messages.find((message) => message.uid === request.messageUid)
|
||||
: null;
|
||||
launchRequestRef.current = null;
|
||||
if (target) void openMessage(target);
|
||||
}, [foldersReady, loadingMessages, messages, selectedFolder, selectedProfile, selectedProfileId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePreviewShortcut = (event: KeyboardEvent) => {
|
||||
if (isEditableTarget(event.target)) return;
|
||||
@@ -158,8 +190,15 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
try {
|
||||
const loaded = await listMailServerProfiles(settings);
|
||||
const usable = loaded.filter((profile) => profile.is_active && profile.imap);
|
||||
const requestedProfileId = parseMailboxLaunch(location.search).profileId;
|
||||
setProfiles(loaded);
|
||||
setSelectedProfileId((current) => current && usable.some((profile) => profile.id === current) ? current : usable[0]?.id ?? "");
|
||||
setSelectedProfileId((current) =>
|
||||
requestedProfileId && usable.some((profile) => profile.id === requestedProfileId)
|
||||
? requestedProfileId
|
||||
: current && usable.some((profile) => profile.id === current)
|
||||
? current
|
||||
: usable[0]?.id ?? ""
|
||||
);
|
||||
if (usable.length === 0) {
|
||||
setFolders([]);
|
||||
setFoldersLoadedForProfile("");
|
||||
@@ -182,9 +221,13 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
async function loadMailboxBootstrap(profileId = selectedProfileId, refresh = false) {
|
||||
if (!profileId) return;
|
||||
const profileInbox = profiles.find((profile) => profile.id === profileId)?.imap?.folder_mappings?.inbox || "";
|
||||
const requestedFolder = foldersLoadedForProfile === profileId && selectedFolder
|
||||
const requestedLaunch = launchRequestRef.current;
|
||||
const requestedLaunchFolder = requestedLaunch && (!requestedLaunch.profileId || requestedLaunch.profileId === profileId)
|
||||
? mailboxLaunchFolder(requestedLaunch, profiles.find((profile) => profile.id === profileId) ?? null)
|
||||
: null;
|
||||
const requestedFolder = requestedLaunchFolder || (foldersLoadedForProfile === profileId && selectedFolder
|
||||
? selectedFolder
|
||||
: profileInbox || "INBOX";
|
||||
: profileInbox || "INBOX");
|
||||
const folderRequestId = ++folderRequestRef.current;
|
||||
const messageRequestId = ++messageListRequestRef.current;
|
||||
messageDetailRequestRef.current += 1;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
type MessageReference = { folder: string; uid: string };
|
||||
type ProfileFolderMapping = {
|
||||
imap?: { folder_mappings?: { drafts?: string | null } | null } | null;
|
||||
};
|
||||
|
||||
export type MailboxLaunch = {
|
||||
profileId: string | null;
|
||||
folder: string | null;
|
||||
folderRole: "drafts" | null;
|
||||
messageUid: string | null;
|
||||
};
|
||||
|
||||
export function parseMailboxLaunch(search: string): MailboxLaunch {
|
||||
const params = new URLSearchParams(search);
|
||||
return {
|
||||
profileId: boundedValue(params.get("profile")),
|
||||
folder: boundedValue(params.get("folder")),
|
||||
folderRole: params.get("folderRole") === "drafts" ? "drafts" : null,
|
||||
messageUid: boundedValue(params.get("message"))
|
||||
};
|
||||
}
|
||||
|
||||
export function mailboxMessageLaunchPath(
|
||||
profileId: string,
|
||||
message: MessageReference
|
||||
): string {
|
||||
return mailPath({
|
||||
profile: profileId,
|
||||
folder: message.folder,
|
||||
message: message.uid
|
||||
});
|
||||
}
|
||||
|
||||
export function mailboxDraftsLaunchPath(profileId: string, folder?: string | null): string {
|
||||
return mailPath(folder
|
||||
? { profile: profileId, folder }
|
||||
: { profile: profileId, folderRole: "drafts" });
|
||||
}
|
||||
|
||||
export function mailboxLaunchFolder(
|
||||
launch: MailboxLaunch,
|
||||
profile: ProfileFolderMapping | null
|
||||
): string | null {
|
||||
if (launch.folder) return launch.folder;
|
||||
if (launch.folderRole === "drafts") {
|
||||
return boundedValue(profile?.imap?.folder_mappings?.drafts ?? null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function mailPath(values: Record<string, string>): string {
|
||||
const params = new URLSearchParams(values);
|
||||
return `/mail?${params.toString()}`;
|
||||
}
|
||||
|
||||
function boundedValue(value: string | null): string | null {
|
||||
const clean = value?.trim() ?? "";
|
||||
return clean && clean.length <= 500 ? clean : null;
|
||||
}
|
||||
@@ -95,6 +95,8 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-mail.mail_profile_policy.f2ac4b92": "Mail profile policy",
|
||||
"i18n:govoplan-mail.mail_server_profiles.b1726682": "Mail server profiles",
|
||||
"i18n:govoplan-mail.mail.92379cbb": "Mail",
|
||||
"i18n:govoplan-mail.compose": "Compose",
|
||||
"i18n:govoplan-mail.open_mail": "Open Mail",
|
||||
"i18n:govoplan-mail.quick_access_description": "Recent mailbox messages and mail actions.",
|
||||
"i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e": "Mailbox folders could not be loaded.",
|
||||
"i18n:govoplan-mail.mailbox_folders.c92f6de4": "Mailbox folders",
|
||||
@@ -293,6 +295,8 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-mail.mail_profile_policy.f2ac4b92": "Mail profile policy",
|
||||
"i18n:govoplan-mail.mail_server_profiles.b1726682": "Mail server profiles",
|
||||
"i18n:govoplan-mail.mail.92379cbb": "Mail",
|
||||
"i18n:govoplan-mail.compose": "Verfassen",
|
||||
"i18n:govoplan-mail.open_mail": "Mail öffnen",
|
||||
"i18n:govoplan-mail.quick_access_description": "Aktuelle Posteingangsnachrichten und Mail-Aktionen.",
|
||||
"i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e": "Mailbox folders could not be loaded.",
|
||||
"i18n:govoplan-mail.mailbox_folders.c92f6de4": "Mailbox folders",
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ const mailQuickAccessTools: QuickAccessToolsUiCapability = {
|
||||
tools: [
|
||||
{
|
||||
id: "mail.messages",
|
||||
render: ({ settings }) => createElement(MailQuickAccess, { settings })
|
||||
render: (context) => createElement(MailQuickAccess, context)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
function assertEqual(actual: unknown, expected: unknown): void {
|
||||
if (actual !== expected) throw new Error(`expected ${String(expected)}, got ${String(actual)}`);
|
||||
}
|
||||
|
||||
function assertDeepEqual(actual: unknown, expected: unknown): void {
|
||||
const actualJson = JSON.stringify(actual);
|
||||
const expectedJson = JSON.stringify(expected);
|
||||
if (actualJson !== expectedJson) throw new Error(`expected ${expectedJson}, got ${actualJson}`);
|
||||
}
|
||||
import {
|
||||
mailboxDraftsLaunchPath,
|
||||
mailboxLaunchFolder,
|
||||
mailboxMessageLaunchPath,
|
||||
parseMailboxLaunch
|
||||
} from "../src/features/mail/mailboxLaunch";
|
||||
|
||||
assertDeepEqual(
|
||||
parseMailboxLaunch("?profile=profile-1&folder=INBOX%2FTeam&message=42"),
|
||||
{
|
||||
profileId: "profile-1",
|
||||
folder: "INBOX/Team",
|
||||
folderRole: null,
|
||||
messageUid: "42"
|
||||
}
|
||||
);
|
||||
|
||||
assertEqual(
|
||||
mailboxMessageLaunchPath("profile 1", { folder: "INBOX/Team", uid: "42" }),
|
||||
"/mail?profile=profile+1&folder=INBOX%2FTeam&message=42"
|
||||
);
|
||||
assertEqual(
|
||||
mailboxDraftsLaunchPath("profile-1"),
|
||||
"/mail?profile=profile-1&folderRole=drafts"
|
||||
);
|
||||
assertEqual(
|
||||
mailboxDraftsLaunchPath("profile-1", "Entwürfe"),
|
||||
"/mail?profile=profile-1&folder=Entw%C3%BCrfe"
|
||||
);
|
||||
assertEqual(
|
||||
mailboxLaunchFolder(parseMailboxLaunch("?folderRole=drafts"), {
|
||||
imap: { folder_mappings: { drafts: "Entwürfe" } }
|
||||
}),
|
||||
"Entwürfe"
|
||||
);
|
||||
|
||||
console.log("mailbox launch tests passed");
|
||||
@@ -19,10 +19,12 @@
|
||||
"include": [
|
||||
"tests/mailbox-display.test.ts",
|
||||
"tests/mailbox-folders.test.ts",
|
||||
"tests/mailbox-launch.test.ts",
|
||||
"tests/mail-profile-editor-model.test.ts",
|
||||
"tests/mail-policy-validation.test.ts",
|
||||
"src/features/mail/mailboxDisplay.ts",
|
||||
"src/features/mail/mailboxFolders.ts",
|
||||
"src/features/mail/mailboxLaunch.ts",
|
||||
"src/features/mail/mailProfileEditorModel.ts",
|
||||
"src/features/mail/mailPolicyValidation.ts"
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user