Skip to content
Open
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
81 changes: 75 additions & 6 deletions components/layout/account-switcher.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
"use client";

import { useState, useRef, useEffect, useCallback } from "react";
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { createPortal } from "react-dom";
import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle } from "lucide-react";
import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle, GripVertical, X } from "lucide-react";
import { useTranslations } from "next-intl";
import { useAccountStore, type AccountEntry } from "@/stores/account-store";
import { useAuthStore } from "@/stores/auth-store";
import { getMaxAccounts } from "@/lib/account-utils";
import { getMaxAccounts, sortDefaultFirst, reorderNonDefaultIds } from "@/lib/account-utils";
import { cn } from "@/lib/utils";
import { useRouter } from "@/i18n/navigation";
import { Avatar } from "@/components/ui/avatar";
Expand Down Expand Up @@ -40,13 +40,15 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher

const accounts = useAccountStore((s) => s.accounts);
const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount);
const reorderAccounts = useAccountStore((s) => s.reorderAccounts);
// Read activeAccountId from authStore so the selector matches the actually-loaded
// session (primaryIdentity, JMAP client). accountStore.activeAccountId is a separate
// persisted copy that can drift out of sync across hydration / partial persist writes.
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const activeAccount = accounts.find((a) => a.id === activeAccountId);
const switchAccount = useAuthStore((s) => s.switchAccount);
const logout = useAuthStore((s) => s.logout);
const removeAccount = useAuthStore((s) => s.removeAccount);
const logoutAll = useAuthStore((s) => s.logoutAll);

const updatePosition = useCallback(() => {
Expand Down Expand Up @@ -99,6 +101,13 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
router.push(`/login?mode=add-account` as never);
};

const handleRemove = (e: React.MouseEvent, account: AccountEntry) => {
e.stopPropagation();
const label = account.email || account.username;
if (!window.confirm(t("remove_account_confirm", { account: label }))) return;
removeAccount(account.id);
};

const handleLogout = () => {
setOpen(false);
logout();
Expand All @@ -113,6 +122,32 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
setDefaultAccount(accountId);
};

// Display order: default account pinned to the top, the rest reorderable.
const displayAccounts = useMemo(() => sortDefaultFirst(accounts), [accounts]);

// Drag-to-rearrange (non-default accounts only; the default stays pinned).
const [dragId, setDragId] = useState<string | null>(null);
const [dragOverId, setDragOverId] = useState<string | null>(null);
const resetDrag = () => { setDragId(null); setDragOverId(null); };

const handleDragStart = (e: React.DragEvent, id: string) => {
setDragId(id);
e.dataTransfer.effectAllowed = "move";
};
const handleDragOver = (e: React.DragEvent, overId: string) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (overId !== dragOverId) setDragOverId(overId);
};
const handleDrop = (e: React.DragEvent, overId: string) => {
e.preventDefault();
if (dragId) {
const next = reorderNonDefaultIds(accounts, dragId, overId);
if (next) reorderAccounts(next);
}
resetDrag();
};

// Show the account's own identity, not the preferred sending identity -
// primaryIdentity can be an alias (e.g. info@korazo.net) that differs from
// the actually logged-in account (info@linusrath.de).
Expand Down Expand Up @@ -167,15 +202,29 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
>
{/* Account List */}
<div className="py-1 max-h-64 overflow-y-auto">
{accounts.map((account) => {
{displayAccounts.map((account) => {
const isActive = account.id === activeAccountId;
const isDraggable = !account.isDefault && accounts.length > 2;
return (
<button
<div
key={account.id}
draggable={isDraggable}
onDragStart={isDraggable ? (e) => handleDragStart(e, account.id) : undefined}
onDragOver={isDraggable ? (e) => handleDragOver(e, account.id) : undefined}
onDrop={isDraggable ? (e) => handleDrop(e, account.id) : undefined}
onDragEnd={isDraggable ? resetDrag : undefined}
className={cn(
"group/acct relative",
dragId === account.id && "opacity-50",
dragOverId === account.id && dragId !== account.id && "border-t-2 border-primary"
)}
>
<button
onClick={() => handleSwitch(account.id)}
className={cn(
"w-full flex items-start gap-3 px-3 py-2.5 text-left transition-colors",
isActive ? "bg-accent/50" : "hover:bg-muted"
isActive ? "bg-accent/50" : "hover:bg-muted",
(!isActive && !account.isDefault) ? (isDraggable ? "pe-14" : "pe-8") : (isDraggable && "pe-7")
)}
role="menuitem"
disabled={isActive}
Expand Down Expand Up @@ -215,6 +264,26 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
</div>
</div>
</button>
{isDraggable && (
<span
aria-hidden
className="pointer-events-none absolute end-7 top-1/2 -translate-y-1/2 text-muted-foreground/50 opacity-0 transition-opacity group-hover/acct:opacity-100"
>
<GripVertical className="w-4 h-4" />
</span>
)}
{!isActive && !account.isDefault && (
<button
type="button"
onClick={(e) => handleRemove(e, account)}
aria-label={t("remove_account")}
title={t("remove_account")}
className="absolute end-1.5 top-1/2 -translate-y-1/2 p-1 rounded-md text-muted-foreground/60 opacity-0 transition-opacity group-hover/acct:opacity-100 hover:bg-destructive/10 hover:text-destructive focus:opacity-100 focus:outline-none focus:ring-1 focus:ring-destructive"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
);
})}
</div>
Expand Down
45 changes: 45 additions & 0 deletions lib/__tests__/account-ordering.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest';
import { sortDefaultFirst, reorderNonDefaultIds, type OrderableAccount } from '../account-utils';

const acct = (id: string, isDefault = false): OrderableAccount => ({ id, isDefault });

describe('sortDefaultFirst', () => {
it('pins the default account to the front, preserving the rest order', () => {
const accounts = [acct('a'), acct('b', true), acct('c')];
expect(sortDefaultFirst(accounts).map((a) => a.id)).toEqual(['b', 'a', 'c']);
});

it('is a no-op shape when the default is already first', () => {
const accounts = [acct('b', true), acct('a'), acct('c')];
expect(sortDefaultFirst(accounts).map((a) => a.id)).toEqual(['b', 'a', 'c']);
});

it('does not mutate the input array', () => {
const accounts = [acct('a'), acct('b', true)];
const snapshot = accounts.map((a) => a.id);
sortDefaultFirst(accounts);
expect(accounts.map((a) => a.id)).toEqual(snapshot);
});
});

describe('reorderNonDefaultIds', () => {
// default 'd' stays index 0; non-defaults are a, b, c
const accounts = [acct('d', true), acct('a'), acct('b'), acct('c')];

it('moves a non-default onto a later position, keeping default pinned', () => {
expect(reorderNonDefaultIds(accounts, 'a', 'c')).toEqual(['d', 'b', 'c', 'a']);
});

it('moves a non-default earlier', () => {
expect(reorderNonDefaultIds(accounts, 'c', 'a')).toEqual(['d', 'c', 'a', 'b']);
});

it('returns null for a no-op (same id)', () => {
expect(reorderNonDefaultIds(accounts, 'a', 'a')).toBeNull();
});

it('returns null when the default is dragged or targeted', () => {
expect(reorderNonDefaultIds(accounts, 'd', 'a')).toBeNull();
expect(reorderNonDefaultIds(accounts, 'a', 'd')).toBeNull();
});
});
38 changes: 38 additions & 0 deletions lib/account-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,41 @@ export function isHttp2Available(): boolean {
export function getMaxAccounts(): number {
return isHttp2Available() ? MAX_ACCOUNT_SLOTS : MAX_ACCOUNTS_HTTP1;
}

/** Minimal shape needed to order accounts (structural — avoids importing AccountEntry). */
export interface OrderableAccount {
id: string;
isDefault: boolean;
}

/**
* Display order for the account switcher: the default account first, then the
* remaining accounts in their stored order. Pure — does not mutate the input.
*/
export function sortDefaultFirst<T extends OrderableAccount>(accounts: T[]): T[] {
const defaults = accounts.filter((a) => a.isDefault);
const rest = accounts.filter((a) => !a.isDefault);
return [...defaults, ...rest];
}

/**
* Compute the new full account-id order after dragging `dragId` onto `overId`.
* Default account(s) stay pinned to the front; only non-default accounts are
* reordered (`dragId` is inserted at `overId`'s position among them).
* Returns null when the move is a no-op or invalid (e.g. a default is involved).
*/
export function reorderNonDefaultIds(
accounts: OrderableAccount[],
dragId: string,
overId: string,
): string[] | null {
if (dragId === overId) return null;
const defaults = accounts.filter((a) => a.isDefault).map((a) => a.id);
const nonDefault = accounts.filter((a) => !a.isDefault).map((a) => a.id);
const from = nonDefault.indexOf(dragId);
const to = nonDefault.indexOf(overId);
if (from < 0 || to < 0) return null;
const [moved] = nonDefault.splice(from, 1);
nonDefault.splice(to, 0, moved);
return [...defaults, ...nonDefault];
}
4 changes: 3 additions & 1 deletion locales/cs/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,9 @@
"scheduled": "Naplánováno",
"unified_all_unread": "Vše nepřečtené",
"unified_all_starred": "Vše s hvězdičkou",
"unified_all_mail": "Veškerá pošta"
"unified_all_mail": "Veškerá pošta",
"remove_account": "Remove account",
"remove_account_confirm": "Remove {account} from this device? You can add it back later."
},
"protocol_handlers": {
"title": "Výchozí aplikace",
Expand Down
4 changes: 3 additions & 1 deletion locales/da/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,9 @@
"scheduled": "Planlagt",
"unified_all_unread": "Alle ulæste",
"unified_all_starred": "Alle med stjerne",
"unified_all_mail": "Al post"
"unified_all_mail": "Al post",
"remove_account": "Remove account",
"remove_account_confirm": "Remove {account} from this device? You can add it back later."
},
"protocol_handlers": {
"title": "Standardapps",
Expand Down
4 changes: 3 additions & 1 deletion locales/de/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,9 @@
"scheduled": "Geplant",
"unified_all_unread": "Alle ungelesenen",
"unified_all_starred": "Alle markierten",
"unified_all_mail": "Alle Mails"
"unified_all_mail": "Alle Mails",
"remove_account": "Remove account",
"remove_account_confirm": "Remove {account} from this device? You can add it back later."
},
"protocol_handlers": {
"title": "Standard-Apps",
Expand Down
4 changes: 3 additions & 1 deletion locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,9 @@
"scheduled": "Scheduled",
"unified_all_unread": "All unread",
"unified_all_starred": "All starred",
"unified_all_mail": "All mail"
"unified_all_mail": "All mail",
"remove_account": "Remove account",
"remove_account_confirm": "Remove {account} from this device? You can add it back later."
},
"protocol_handlers": {
"title": "Default apps",
Expand Down
4 changes: 3 additions & 1 deletion locales/es/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,9 @@
"scheduled": "Programados",
"unified_all_unread": "Todo no leído",
"unified_all_starred": "Todo destacado",
"unified_all_mail": "Todo el correo"
"unified_all_mail": "Todo el correo",
"remove_account": "Remove account",
"remove_account_confirm": "Remove {account} from this device? You can add it back later."
},
"protocol_handlers": {
"title": "Aplicaciones predeterminadas",
Expand Down
4 changes: 3 additions & 1 deletion locales/fa/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,9 @@
"scheduled": "زمان‌بندی شده",
"unified_all_mail": "همه ایمیل‌ها",
"unified_all_starred": "همه ستاره‌دارها",
"unified_all_unread": "همه خوانده‌نشده‌ها"
"unified_all_unread": "همه خوانده‌نشده‌ها",
"remove_account": "Remove account",
"remove_account_confirm": "Remove {account} from this device? You can add it back later."
},
"protocol_handlers": {
"title": "برنامه‌های پیش‌فرض",
Expand Down
4 changes: 3 additions & 1 deletion locales/fr/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,9 @@
"scheduled": "Planifiés",
"unified_all_unread": "Tous les non lus",
"unified_all_starred": "Tous les favoris",
"unified_all_mail": "Tout le courrier"
"unified_all_mail": "Tout le courrier",
"remove_account": "Remove account",
"remove_account_confirm": "Remove {account} from this device? You can add it back later."
},
"protocol_handlers": {
"title": "Applications par défaut",
Expand Down
4 changes: 3 additions & 1 deletion locales/hu/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,9 @@
"scheduled": "Ütemezett",
"unified_all_unread": "Összes olvasatlan",
"unified_all_starred": "Összes csillagozott",
"unified_all_mail": "Összes levél"
"unified_all_mail": "Összes levél",
"remove_account": "Remove account",
"remove_account_confirm": "Remove {account} from this device? You can add it back later."
},
"protocol_handlers": {
"title": "Alapértelmezett alkalmazások",
Expand Down
4 changes: 3 additions & 1 deletion locales/it/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,9 @@
"scheduled": "Programmate",
"unified_all_unread": "Tutti i non letti",
"unified_all_starred": "Tutti gli speciali",
"unified_all_mail": "Tutta la posta"
"unified_all_mail": "Tutta la posta",
"remove_account": "Remove account",
"remove_account_confirm": "Remove {account} from this device? You can add it back later."
},
"protocol_handlers": {
"title": "App predefinite",
Expand Down
4 changes: 3 additions & 1 deletion locales/ja/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,9 @@
"scheduled": "予約済み",
"unified_all_unread": "すべての未読",
"unified_all_starred": "すべてのスター付き",
"unified_all_mail": "すべてのメール"
"unified_all_mail": "すべてのメール",
"remove_account": "Remove account",
"remove_account_confirm": "Remove {account} from this device? You can add it back later."
},
"protocol_handlers": {
"title": "既定のアプリ",
Expand Down
4 changes: 3 additions & 1 deletion locales/ko/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,9 @@
"scheduled": "예약됨",
"unified_all_unread": "모든 읽지 않음",
"unified_all_starred": "모든 별표",
"unified_all_mail": "모든 메일"
"unified_all_mail": "모든 메일",
"remove_account": "Remove account",
"remove_account_confirm": "Remove {account} from this device? You can add it back later."
},
"protocol_handlers": {
"title": "기본 앱",
Expand Down
4 changes: 3 additions & 1 deletion locales/lv/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,9 @@
"scheduled": "Ieplānots",
"unified_all_unread": "Visi nelasītie",
"unified_all_starred": "Visi ar zvaigzni",
"unified_all_mail": "Visas vēstules"
"unified_all_mail": "Visas vēstules",
"remove_account": "Remove account",
"remove_account_confirm": "Remove {account} from this device? You can add it back later."
},
"protocol_handlers": {
"title": "Noklusējuma lietotnes",
Expand Down
4 changes: 3 additions & 1 deletion locales/nl/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,9 @@
"scheduled": "Gepland",
"unified_all_unread": "Alle ongelezen",
"unified_all_starred": "Alle met ster",
"unified_all_mail": "Alle e-mail"
"unified_all_mail": "Alle e-mail",
"remove_account": "Remove account",
"remove_account_confirm": "Remove {account} from this device? You can add it back later."
},
"protocol_handlers": {
"title": "Standaardapps",
Expand Down
4 changes: 3 additions & 1 deletion locales/pl/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,9 @@
"scheduled": "Zaplanowane",
"unified_all_unread": "Wszystkie nieprzeczytane",
"unified_all_starred": "Wszystkie oznaczone gwiazdką",
"unified_all_mail": "Cała poczta"
"unified_all_mail": "Cała poczta",
"remove_account": "Remove account",
"remove_account_confirm": "Remove {account} from this device? You can add it back later."
},
"protocol_handlers": {
"title": "Aplikacje domyślne",
Expand Down
4 changes: 3 additions & 1 deletion locales/pt/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,9 @@
"scheduled": "Agendados",
"unified_all_unread": "Tudo não lido",
"unified_all_starred": "Tudo com estrela",
"unified_all_mail": "Todo o correio"
"unified_all_mail": "Todo o correio",
"remove_account": "Remove account",
"remove_account_confirm": "Remove {account} from this device? You can add it back later."
},
"protocol_handlers": {
"title": "Aplicativos padrão",
Expand Down
Loading