Merge branch 'main' into feature/scheduled-send
# Conflicts: # app/(main)/[locale]/page.tsx # components/layout/sidebar.tsx # stores/email-store.ts # stores/settings-store.ts
This commit is contained in:
@@ -8,7 +8,7 @@ import { createContext, useContext } from "react";
|
||||
* read this to hide their own NavigationRail and let the shell own the
|
||||
* chrome.
|
||||
*
|
||||
* Provided via context by the Pro shell — no URL coupling, no iframe.
|
||||
* Provided via context by the Pro shell - no URL coupling, no iframe.
|
||||
*/
|
||||
export const EmbeddedContext = createContext<boolean>(false);
|
||||
|
||||
|
||||
+79
-13
@@ -1,13 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState, DragEvent } from "react";
|
||||
import { Mailbox } from "@/lib/jmap/types";
|
||||
import { Mailbox, Email } from "@/lib/jmap/types";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useDragDropContext } from "@/contexts/drag-drop-context";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { getMailboxPath } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Returns the source accountId for an email being dragged. In unified view
|
||||
* each email carries its own `accountId`; otherwise everything in the view
|
||||
* belongs to whichever account is currently being viewed (Pro shell's
|
||||
* Thunderbird-style sidebar) or the globally-active account.
|
||||
*/
|
||||
function resolveSourceAccountId(email: Email | undefined): string | null {
|
||||
if (email?.accountId) return email.accountId;
|
||||
const viewingId = useEmailStore.getState().viewingAccountId;
|
||||
if (viewingId) return viewingId;
|
||||
return useAuthStore.getState().activeAccountId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the local accountId ("user@host") that owns the destination
|
||||
* mailbox. `mailbox.accountId` is the JMAP server's opaque account id, but
|
||||
* `clients`, `activeAccountId`, and `email.accountId` all live in the local
|
||||
* namespace. We map back by matching the JMAP id against each connected
|
||||
* client's `getAccountId()`. Falls back to the viewing/active account so
|
||||
* single-account flows (no connected clients map entry yet, in-memory edits,
|
||||
* etc.) still resolve correctly.
|
||||
*/
|
||||
function resolveDestAccountId(mailbox: Mailbox): string | null {
|
||||
const jmapId = mailbox.accountId;
|
||||
if (jmapId) {
|
||||
const clients = useAuthStore.getState().getAllConnectedClients();
|
||||
for (const [localId, client] of clients) {
|
||||
if (client.getAccountId() === jmapId) return localId;
|
||||
}
|
||||
}
|
||||
return useEmailStore.getState().viewingAccountId
|
||||
?? useAuthStore.getState().activeAccountId;
|
||||
}
|
||||
|
||||
interface UseMailboxDropOptions {
|
||||
mailbox: Mailbox;
|
||||
onDropComplete?: () => void;
|
||||
@@ -31,7 +65,7 @@ interface UseMailboxDropReturn {
|
||||
export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: UseMailboxDropOptions): UseMailboxDropReturn {
|
||||
const [isOver, setIsOver] = useState(false);
|
||||
const { client } = useAuthStore();
|
||||
const { moveEmailsToMailbox, selectedEmailIds, clearSelection, refreshCurrentMailbox, mailboxes } = useEmailStore();
|
||||
const { moveEmailsToMailbox, crossAccountMoveEmails, selectedEmailIds, clearSelection, refreshCurrentMailbox, mailboxes } = useEmailStore();
|
||||
const { isDragging, sourceMailboxId, draggedEmails, endDrag } = useDragDropContext();
|
||||
|
||||
// Determine if this is a valid drop target
|
||||
@@ -47,13 +81,13 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
|
||||
// Virtual nodes (shared folder headers) cannot be drop targets
|
||||
if (mailbox.id.startsWith("shared-")) return false;
|
||||
|
||||
// For shared mailboxes, check account compatibility
|
||||
// Shared (delegated) mailboxes still require the source to belong to the
|
||||
// same delegating account. Real cross-account moves between primary
|
||||
// accounts go through the cross-account path further down, but the
|
||||
// shared-folder semantics here are about ACLs rather than transport, so
|
||||
// they remain disallowed.
|
||||
if (mailbox.isShared && draggedEmails[0]) {
|
||||
// Get the source mailbox's account ID from the store
|
||||
const mailboxes = useEmailStore.getState().mailboxes;
|
||||
const sourceMb = mailboxes.find(mb => mb.id === sourceMailboxId);
|
||||
|
||||
// Cross-account moves are not supported
|
||||
const sourceMb = useEmailStore.getState().mailboxes.find(mb => mb.id === sourceMailboxId);
|
||||
if (sourceMb?.accountId !== mailbox.accountId) {
|
||||
return false;
|
||||
}
|
||||
@@ -107,16 +141,48 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
|
||||
|
||||
const emailIds: string[] = JSON.parse(emailIdsJson);
|
||||
|
||||
// Move in a single bulk JMAP request (store handles counter updates).
|
||||
await moveEmailsToMailbox(client, emailIds, mailbox.id);
|
||||
// Group dragged emails by source account. In single-account flows this
|
||||
// collapses to one bucket; in unified view or the Pro multi-account
|
||||
// sidebar a single drag can mix sources.
|
||||
const destAccountId = resolveDestAccountId(mailbox);
|
||||
const idToEmail = new Map(draggedEmails.map((em) => [em.id, em]));
|
||||
const bySource = new Map<string, string[]>();
|
||||
for (const id of emailIds) {
|
||||
const srcAccountId = resolveSourceAccountId(idToEmail.get(id));
|
||||
if (!srcAccountId) continue;
|
||||
if (!bySource.has(srcAccountId)) bySource.set(srcAccountId, []);
|
||||
bySource.get(srcAccountId)!.push(id);
|
||||
}
|
||||
|
||||
const sourceAccountIds = Array.from(bySource.keys());
|
||||
const isCrossAccount =
|
||||
!!destAccountId &&
|
||||
!mailbox.isShared &&
|
||||
sourceAccountIds.some((src) => src !== destAccountId);
|
||||
|
||||
if (isCrossAccount) {
|
||||
// JMAP can't natively move an email between primary accounts, so the
|
||||
// store reuploads each source blob into the destination account and
|
||||
// then deletes the original.
|
||||
const jmapDestId = mailbox.originalId || mailbox.id;
|
||||
await crossAccountMoveEmails(bySource, destAccountId, jmapDestId);
|
||||
} else {
|
||||
// Single-account or same-account-shared move: bulk JMAP request.
|
||||
await moveEmailsToMailbox(client, emailIds, mailbox.id);
|
||||
}
|
||||
|
||||
// Clear selection if any selected emails were moved
|
||||
if (emailIds.some(id => selectedEmailIds.has(id))) {
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
// Refresh the current mailbox view (honors active search/filters)
|
||||
await refreshCurrentMailbox(client);
|
||||
// Refresh the current mailbox view (honors active search/filters).
|
||||
// Skip for cross-account moves: the store already dropped the moved
|
||||
// rows from the in-memory list and refreshed both accounts' folder
|
||||
// caches in the background.
|
||||
if (!isCrossAccount) {
|
||||
await refreshCurrentMailbox(client);
|
||||
}
|
||||
|
||||
const mailboxPath = getMailboxPath(mailbox, mailboxes);
|
||||
|
||||
@@ -144,7 +210,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
|
||||
} finally {
|
||||
endDrag();
|
||||
}
|
||||
}, [client, mailbox, mailboxes, isValidTarget, moveEmailsToMailbox, selectedEmailIds, clearSelection, refreshCurrentMailbox, endDrag, onDropComplete, onSuccess, onError]);
|
||||
}, [client, mailbox, mailboxes, isValidTarget, moveEmailsToMailbox, crossAccountMoveEmails, draggedEmails, selectedEmailIds, clearSelection, refreshCurrentMailbox, endDrag, onDropComplete, onSuccess, onError]);
|
||||
|
||||
const valid = isValidTarget();
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ export function useMediaQuery(query: string): boolean {
|
||||
/**
|
||||
* When the Pro shell renders a page inside a (possibly split) pane, that pane
|
||||
* publishes its measured width via `PaneSizeContext`. Inner pages should
|
||||
* branch their layout against the pane width — not the full viewport — so a
|
||||
* branch their layout against the pane width - not the full viewport - so a
|
||||
* narrow pane gets the mobile/tablet layout instead of overflowing.
|
||||
*
|
||||
* Returns `null` when no pane size is published, signalling the caller to
|
||||
@@ -63,7 +63,7 @@ function classifyPane(paneWidth: number | null) {
|
||||
*
|
||||
* When invoked inside a Pro pane, the returned values reflect the pane's
|
||||
* width instead of the window's. The global UI store is NOT updated in that
|
||||
* case — two split panes would otherwise fight to write conflicting values,
|
||||
* case - two split panes would otherwise fight to write conflicting values,
|
||||
* and the store is meant to mirror the actual viewport for callers that read
|
||||
* it directly (mobile navigation helpers etc.).
|
||||
*/
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createContext, useContext } from "react";
|
||||
|
||||
/**
|
||||
* Width of the pane that's hosting the current subtree, in CSS pixels.
|
||||
* `null` means "no pane is providing a size" — fall back to viewport-based
|
||||
* `null` means "no pane is providing a size" - fall back to viewport-based
|
||||
* media queries. Set by the Pro shell on each split pane via ResizeObserver.
|
||||
*/
|
||||
export const PaneSizeContext = createContext<number | null>(null);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useCalendarStore, type CalendarAccountClient } from "@/stores/calendar-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||
|
||||
/**
|
||||
* When the Pro shell is the active interface, aggregate calendars from
|
||||
* every connected account so the calendar sidebar lists them all - the
|
||||
* same way [[use-pro-multi-account-mailboxes]] does for mail folders.
|
||||
*
|
||||
* Returns the resolved list of `{ localAccountId, client }` pairs so the
|
||||
* caller (calendar page) can fetch events the same way without
|
||||
* re-deriving the set.
|
||||
*/
|
||||
export function useProMultiAccountCalendars(start: string | null, end: string | null): {
|
||||
enabled: boolean;
|
||||
accountClients: CalendarAccountClient[];
|
||||
} {
|
||||
const isEmbedded = useIsEmbedded();
|
||||
const proInterface = useSettingsStore((s) => s.proInterface);
|
||||
const accounts = useAccountStore((s) => s.accounts);
|
||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||
const fetchAllAccountsCalendars = useCalendarStore((s) => s.fetchAllAccountsCalendars);
|
||||
const fetchAllAccountsEvents = useCalendarStore((s) => s.fetchAllAccountsEvents);
|
||||
|
||||
const enabled = proInterface || isEmbedded;
|
||||
|
||||
const accountClients = useMemo(() => {
|
||||
if (!enabled) return [];
|
||||
const getClientForAccount = useAuthStore.getState().getClientForAccount;
|
||||
const pairs: CalendarAccountClient[] = [];
|
||||
for (const account of accounts) {
|
||||
if (!account.isConnected) continue;
|
||||
const client = getClientForAccount(account.id);
|
||||
if (!client || !client.supportsCalendars()) continue;
|
||||
pairs.push({ localAccountId: account.id, client });
|
||||
}
|
||||
return pairs;
|
||||
// accounts identity changes whenever the connected set or login states
|
||||
// change, so this is the only dependency we need.
|
||||
}, [enabled, accounts]);
|
||||
|
||||
// Fetch calendars whenever the set of connected calendar-capable accounts
|
||||
// changes. Skips when there isn't an active account yet (auth still
|
||||
// bootstrapping).
|
||||
useEffect(() => {
|
||||
if (!enabled || !activeAccountId || accountClients.length === 0) return;
|
||||
void fetchAllAccountsCalendars(accountClients, activeAccountId);
|
||||
}, [enabled, activeAccountId, accountClients, fetchAllAccountsCalendars]);
|
||||
|
||||
// Fetch events for the current visible date range across all accounts.
|
||||
useEffect(() => {
|
||||
if (!enabled || !activeAccountId || accountClients.length === 0) return;
|
||||
if (!start || !end) return;
|
||||
void fetchAllAccountsEvents(accountClients, activeAccountId, start, end);
|
||||
}, [enabled, activeAccountId, accountClients, start, end, fetchAllAccountsEvents]);
|
||||
|
||||
return { enabled, accountClients };
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useContactStore, type ContactAccountClient } from "@/stores/contact-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||
|
||||
/**
|
||||
* Pro-shell counterpart to [[useProMultiAccountCalendars]] - aggregates
|
||||
* contacts and address books from every connected JMAP account so the
|
||||
* contacts sidebar lists them all, grouped by local account.
|
||||
*/
|
||||
export function useProMultiAccountContacts(): {
|
||||
enabled: boolean;
|
||||
accountClients: ContactAccountClient[];
|
||||
} {
|
||||
const isEmbedded = useIsEmbedded();
|
||||
const proInterface = useSettingsStore((s) => s.proInterface);
|
||||
const accounts = useAccountStore((s) => s.accounts);
|
||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||
const fetchAllAccountsAddressBooks = useContactStore((s) => s.fetchAllAccountsAddressBooks);
|
||||
const fetchAllAccountsContacts = useContactStore((s) => s.fetchAllAccountsContacts);
|
||||
|
||||
const enabled = proInterface || isEmbedded;
|
||||
|
||||
const accountClients = useMemo(() => {
|
||||
if (!enabled) return [];
|
||||
const getClientForAccount = useAuthStore.getState().getClientForAccount;
|
||||
const pairs: ContactAccountClient[] = [];
|
||||
for (const account of accounts) {
|
||||
if (!account.isConnected) continue;
|
||||
const client = getClientForAccount(account.id);
|
||||
if (!client || !client.supportsContacts()) continue;
|
||||
pairs.push({ localAccountId: account.id, client });
|
||||
}
|
||||
return pairs;
|
||||
}, [enabled, accounts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !activeAccountId || accountClients.length === 0) return;
|
||||
void fetchAllAccountsAddressBooks(accountClients, activeAccountId);
|
||||
void fetchAllAccountsContacts(accountClients, activeAccountId);
|
||||
}, [enabled, activeAccountId, accountClients, fetchAllAccountsAddressBooks, fetchAllAccountsContacts]);
|
||||
|
||||
return { enabled, accountClients };
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useIdentityStore } from "@/stores/identity-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||
import type { Identity } from "@/lib/jmap/types";
|
||||
|
||||
interface AccountIdentityGroup {
|
||||
localAccountId: string;
|
||||
accountLabel: string;
|
||||
identities: Identity[];
|
||||
}
|
||||
|
||||
const CROSS_ACCOUNT_IDENTITY_DELIMITER = '::';
|
||||
|
||||
/** Cross-account identity IDs are namespaced to avoid collisions between
|
||||
* JMAP servers that happen to issue the same opaque ID. The active
|
||||
* account's IDs are left untouched so existing single-account code paths
|
||||
* (reply-identity resolution, S/MIME bindings) keep working unchanged.
|
||||
*/
|
||||
export function isCrossAccountIdentityId(id: string): boolean {
|
||||
return id.includes(CROSS_ACCOUNT_IDENTITY_DELIMITER);
|
||||
}
|
||||
|
||||
export function stripCrossAccountIdentityPrefix(id: string): { localAccountId: string | null; rawId: string } {
|
||||
const idx = id.indexOf(CROSS_ACCOUNT_IDENTITY_DELIMITER);
|
||||
if (idx < 0) return { localAccountId: null, rawId: id };
|
||||
return {
|
||||
localAccountId: id.slice(0, idx),
|
||||
rawId: id.slice(idx + CROSS_ACCOUNT_IDENTITY_DELIMITER.length),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pro shell only: load identities from every connected account and group
|
||||
* them by local account so the composer's From dropdown can render an
|
||||
* <optgroup> per account - mirrors [[useProMultiAccountCalendars]] and
|
||||
* [[useProMultiAccountContacts]].
|
||||
*
|
||||
* Outside Pro / embedded mode the hook returns `enabled: false` and the
|
||||
* caller falls back to the active account's identities from
|
||||
* [[useIdentityStore]].
|
||||
*/
|
||||
export function useProMultiAccountIdentities(): {
|
||||
enabled: boolean;
|
||||
groups: AccountIdentityGroup[];
|
||||
/** Flat list across all accounts, useful for lookup-by-id. */
|
||||
allIdentities: Identity[];
|
||||
} {
|
||||
const isEmbedded = useIsEmbedded();
|
||||
const proInterface = useSettingsStore((s) => s.proInterface);
|
||||
const accounts = useAccountStore((s) => s.accounts);
|
||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||
const activeIdentities = useIdentityStore((s) => s.identities);
|
||||
|
||||
const enabled = (proInterface || isEmbedded) && accounts.filter(a => a.isConnected).length > 1;
|
||||
|
||||
const [remoteIdentities, setRemoteIdentities] = useState<Record<string, Identity[]>>({});
|
||||
|
||||
// Cache identities fetched per non-active account. Active account's
|
||||
// identities come live from useIdentityStore so signature/alias edits
|
||||
// there are reflected immediately without an extra round-trip.
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setRemoteIdentities({});
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const getClientForAccount = useAuthStore.getState().getClientForAccount;
|
||||
(async () => {
|
||||
const next: Record<string, Identity[]> = {};
|
||||
await Promise.all(
|
||||
accounts
|
||||
.filter((a) => a.isConnected && a.id !== activeAccountId)
|
||||
.map(async (account) => {
|
||||
const client = getClientForAccount(account.id);
|
||||
if (!client) return;
|
||||
try {
|
||||
const list = await client.getIdentities();
|
||||
if (!cancelled) next[account.id] = list;
|
||||
} catch {
|
||||
// Skip accounts that fail to load identities - one bad
|
||||
// account shouldn't blank the whole dropdown.
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (!cancelled) setRemoteIdentities(next);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [enabled, accounts, activeAccountId]);
|
||||
|
||||
const groups = useMemo<AccountIdentityGroup[]>(() => {
|
||||
if (!enabled) return [];
|
||||
const out: AccountIdentityGroup[] = [];
|
||||
if (activeAccountId) {
|
||||
const active = accounts.find((a) => a.id === activeAccountId);
|
||||
const label = active?.label || active?.email || active?.username || activeAccountId;
|
||||
out.push({
|
||||
localAccountId: activeAccountId,
|
||||
accountLabel: label,
|
||||
identities: activeIdentities.map((id) => ({
|
||||
...id,
|
||||
localAccountId: activeAccountId,
|
||||
accountName: label,
|
||||
})),
|
||||
});
|
||||
}
|
||||
for (const account of accounts) {
|
||||
if (!account.isConnected || account.id === activeAccountId) continue;
|
||||
const list = remoteIdentities[account.id];
|
||||
if (!list || list.length === 0) continue;
|
||||
const label = account.label || account.email || account.username;
|
||||
out.push({
|
||||
localAccountId: account.id,
|
||||
accountLabel: label,
|
||||
identities: list.map((id) => ({
|
||||
...id,
|
||||
id: `${account.id}${CROSS_ACCOUNT_IDENTITY_DELIMITER}${id.id}`,
|
||||
localAccountId: account.id,
|
||||
accountName: label,
|
||||
})),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}, [enabled, accounts, activeAccountId, activeIdentities, remoteIdentities]);
|
||||
|
||||
const allIdentities = useMemo(() => groups.flatMap((g) => g.identities), [groups]);
|
||||
|
||||
return { enabled, groups, allIdentities };
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||
|
||||
/**
|
||||
* Keeps `useEmailStore.accountMailboxes` populated with one entry per
|
||||
* connected account while the Pro shell is the active interface. The Pro
|
||||
* sidebar reads this cache to render a Thunderbird-style per-account folder
|
||||
* tree (see [[project_pro_mode]]). Outside Pro the cache stays empty.
|
||||
*
|
||||
* Refetches whenever the set of connected accounts changes, so adding or
|
||||
* removing an account in another tab is reflected without a reload.
|
||||
*/
|
||||
export function useProMultiAccountMailboxes(): void {
|
||||
const isEmbedded = useIsEmbedded();
|
||||
const proInterface = useSettingsStore((s) => s.proInterface);
|
||||
const accounts = useAccountStore((s) => s.accounts);
|
||||
|
||||
useEffect(() => {
|
||||
if (!proInterface && !isEmbedded) return;
|
||||
|
||||
const connected = accounts.filter((a) => a.isConnected);
|
||||
if (connected.length === 0) return;
|
||||
|
||||
const fetchAccountMailboxes = useEmailStore.getState().fetchAccountMailboxes;
|
||||
const getClientForAccount = useAuthStore.getState().getClientForAccount;
|
||||
|
||||
for (const account of connected) {
|
||||
const client = getClientForAccount(account.id);
|
||||
if (!client) continue;
|
||||
void fetchAccountMailboxes(client, account.id);
|
||||
}
|
||||
}, [proInterface, isEmbedded, accounts]);
|
||||
}
|
||||
Reference in New Issue
Block a user