mirror of
https://github.com/hoornet/vega.git
synced 2026-04-24 06:40:01 -07:00
Add multi-account / profile switcher (roadmap #2)
- SavedAccount list persisted in localStorage (wrystr_accounts) - loginWithNsec / loginWithPubkey now upsert into the accounts list - fetchOwnProfile caches name + picture into the account entry - switchAccount: loads nsec from OS keychain, falls back to read-only - removeAccount: deletes keychain entry + removes from list; logs out if it was the active account - logout: clears active session only — keychain entries kept for instant switch-back - AccountSwitcher component in sidebar footer: shows current account, expand (▼/▲) to list all saved accounts, click to switch instantly, × to remove, "+ add account" opens LoginModal, sign-out / remove account actions inline Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
163
src/components/sidebar/AccountSwitcher.tsx
Normal file
163
src/components/sidebar/AccountSwitcher.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useState } from "react";
|
||||
import { useUserStore, SavedAccount } from "../../stores/user";
|
||||
import { useUIStore } from "../../stores/ui";
|
||||
import { LoginModal } from "../shared/LoginModal";
|
||||
import { shortenPubkey } from "../../lib/utils";
|
||||
|
||||
function Avatar({ account, size = 6 }: { account: SavedAccount; size?: number }) {
|
||||
const initial = (account.name || account.npub || "?").charAt(0).toUpperCase();
|
||||
const cls = `w-${size} h-${size} rounded-sm object-cover shrink-0`;
|
||||
if (account.picture) {
|
||||
return (
|
||||
<img
|
||||
src={account.picture}
|
||||
alt=""
|
||||
className={cls}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={`w-${size} h-${size} rounded-sm bg-accent/20 flex items-center justify-center text-accent text-[10px] shrink-0`}>
|
||||
{initial}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountSwitcher() {
|
||||
const { accounts, pubkey, switchAccount, removeAccount, logout } = useUserStore();
|
||||
const { openProfile } = useUIStore();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [showAddLogin, setShowAddLogin] = useState(false);
|
||||
|
||||
const current = accounts.find((a) => a.pubkey === pubkey) ?? null;
|
||||
const others = accounts.filter((a) => a.pubkey !== pubkey);
|
||||
|
||||
const displayName = (a: SavedAccount) =>
|
||||
a.name || shortenPubkey(a.npub);
|
||||
|
||||
const handleSwitch = async (targetPubkey: string) => {
|
||||
setOpen(false);
|
||||
await switchAccount(targetPubkey);
|
||||
};
|
||||
|
||||
const handleRemove = (e: React.MouseEvent, targetPubkey: string) => {
|
||||
e.stopPropagation();
|
||||
removeAccount(targetPubkey);
|
||||
};
|
||||
|
||||
const handleAddAccount = () => {
|
||||
setOpen(false);
|
||||
setShowAddLogin(true);
|
||||
};
|
||||
|
||||
// Not logged in
|
||||
if (!pubkey || !current) {
|
||||
return (
|
||||
<>
|
||||
<div className="border-t border-border px-3 py-2">
|
||||
{accounts.length > 0 && (
|
||||
<div className="mb-1.5">
|
||||
{accounts.map((a) => (
|
||||
<button
|
||||
key={a.pubkey}
|
||||
onClick={() => handleSwitch(a.pubkey)}
|
||||
className="w-full flex items-center gap-2 px-1 py-1 text-left hover:bg-bg-hover transition-colors"
|
||||
>
|
||||
<Avatar account={a} />
|
||||
<span className="text-text-muted text-[11px] truncate flex-1">{displayName(a)}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="border-t border-border my-1" />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowAddLogin(true)}
|
||||
className="w-full px-2 py-1.5 text-[11px] border border-border text-text-muted hover:text-accent hover:border-accent/40 transition-colors"
|
||||
>
|
||||
login
|
||||
</button>
|
||||
</div>
|
||||
{showAddLogin && <LoginModal onClose={() => setShowAddLogin(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="border-t border-border shrink-0">
|
||||
{/* Expanded account list */}
|
||||
{open && (
|
||||
<div className="border-b border-border">
|
||||
{others.map((a) => (
|
||||
<div
|
||||
key={a.pubkey}
|
||||
className="flex items-center gap-2 px-3 py-1.5 hover:bg-bg-hover cursor-pointer group transition-colors"
|
||||
onClick={() => handleSwitch(a.pubkey)}
|
||||
>
|
||||
<Avatar account={a} />
|
||||
<span className="text-text-muted text-[11px] truncate flex-1">{displayName(a)}</span>
|
||||
<button
|
||||
onClick={(e) => handleRemove(e, a.pubkey)}
|
||||
className="text-text-dim hover:text-danger text-[11px] opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Remove account"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={handleAddAccount}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-text-dim hover:text-accent hover:bg-bg-hover text-[11px] transition-colors"
|
||||
>
|
||||
<span className="w-6 text-center text-[12px]">+</span>
|
||||
<span>add account</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current account row */}
|
||||
<div className="px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="flex items-center gap-2 flex-1 min-w-0 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={() => openProfile(pubkey)}
|
||||
>
|
||||
<Avatar account={current} />
|
||||
<span className="text-text text-[11px] truncate flex-1">{displayName(current)}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="text-text-dim hover:text-text text-[10px] transition-colors px-0.5"
|
||||
title="Switch account"
|
||||
>
|
||||
{open ? "▲" : "▼"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="flex items-center justify-between mt-1.5">
|
||||
<button
|
||||
onClick={() => { setOpen(false); logout(); }}
|
||||
className="text-text-dim hover:text-danger text-[10px] transition-colors"
|
||||
>
|
||||
sign out
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setOpen(false); removeAccount(pubkey); }}
|
||||
className="text-text-dim hover:text-danger text-[10px] transition-colors"
|
||||
>
|
||||
remove account
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAddLogin && <LoginModal onClose={() => setShowAddLogin(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { useUIStore } from "../../stores/ui";
|
||||
import { useFeedStore } from "../../stores/feed";
|
||||
import { useUserStore } from "../../stores/user";
|
||||
import { getNDK } from "../../lib/nostr";
|
||||
import { LoginModal } from "../shared/LoginModal";
|
||||
import { shortenPubkey } from "../../lib/utils";
|
||||
import { AccountSwitcher } from "./AccountSwitcher";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ id: "feed" as const, label: "feed", icon: "◈" },
|
||||
@@ -14,13 +12,9 @@ const NAV_ITEMS = [
|
||||
] as const;
|
||||
|
||||
export function Sidebar() {
|
||||
const { currentView, setView, sidebarCollapsed, toggleSidebar, openProfile } = useUIStore();
|
||||
const { currentView, setView, sidebarCollapsed, toggleSidebar } = useUIStore();
|
||||
const { connected, notes } = useFeedStore();
|
||||
const { loggedIn, profile, npub, logout } = useUserStore();
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
|
||||
const userName = profile?.displayName || profile?.name || (npub ? shortenPubkey(npub) : null);
|
||||
const userAvatar = profile?.picture;
|
||||
const { loggedIn } = useUserStore();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -74,52 +68,8 @@ export function Sidebar() {
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* User / Login */}
|
||||
{!sidebarCollapsed && (
|
||||
<div className="border-t border-border shrink-0">
|
||||
{loggedIn ? (
|
||||
<div className="px-3 py-2">
|
||||
<div
|
||||
className="flex items-center gap-2 mb-1.5 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={() => { const { pubkey } = useUserStore.getState(); if (pubkey) openProfile(pubkey); }}
|
||||
>
|
||||
{userAvatar ? (
|
||||
<img
|
||||
src={userAvatar}
|
||||
alt=""
|
||||
className="w-6 h-6 rounded-sm object-cover"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-6 h-6 rounded-sm bg-accent/20 flex items-center justify-center text-accent text-[10px]">
|
||||
{(userName || "?").charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-text text-[11px] truncate flex-1">
|
||||
{userName}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-text-dim hover:text-danger text-[10px] transition-colors"
|
||||
>
|
||||
logout
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-3 py-2">
|
||||
<button
|
||||
onClick={() => setShowLogin(true)}
|
||||
className="w-full px-2 py-1.5 text-[11px] border border-border text-text-muted hover:text-accent hover:border-accent/40 transition-colors"
|
||||
>
|
||||
login
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Account switcher */}
|
||||
{!sidebarCollapsed && <AccountSwitcher />}
|
||||
|
||||
{/* Status footer */}
|
||||
{!sidebarCollapsed && (
|
||||
@@ -133,7 +83,6 @@ export function Sidebar() {
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{showLogin && <LoginModal onClose={() => setShowLogin(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,31 @@ import { getNDK, publishContactList } from "../lib/nostr";
|
||||
import { nip19 } from "@nostr-dev-kit/ndk";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export interface SavedAccount {
|
||||
pubkey: string;
|
||||
npub: string;
|
||||
name?: string;
|
||||
picture?: string;
|
||||
}
|
||||
|
||||
function loadSavedAccounts(): SavedAccount[] {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem("wrystr_accounts") ?? "[]");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function persistAccounts(accounts: SavedAccount[]) {
|
||||
localStorage.setItem("wrystr_accounts", JSON.stringify(accounts));
|
||||
}
|
||||
|
||||
function upsertAccount(accounts: SavedAccount[], entry: SavedAccount): SavedAccount[] {
|
||||
const idx = accounts.findIndex((a) => a.pubkey === entry.pubkey);
|
||||
if (idx === -1) return [...accounts, entry];
|
||||
return accounts.map((a, i) => (i === idx ? { ...a, ...entry } : a));
|
||||
}
|
||||
|
||||
interface UserState {
|
||||
pubkey: string | null;
|
||||
npub: string | null;
|
||||
@@ -11,11 +36,14 @@ interface UserState {
|
||||
follows: string[];
|
||||
loggedIn: boolean;
|
||||
loginError: string | null;
|
||||
accounts: SavedAccount[];
|
||||
|
||||
loginWithNsec: (nsec: string) => Promise<void>;
|
||||
loginWithPubkey: (pubkey: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
restoreSession: () => Promise<void>;
|
||||
switchAccount: (pubkey: string) => Promise<void>;
|
||||
removeAccount: (pubkey: string) => void;
|
||||
fetchOwnProfile: () => Promise<void>;
|
||||
fetchFollows: () => Promise<void>;
|
||||
follow: (pubkey: string) => Promise<void>;
|
||||
@@ -29,6 +57,7 @@ export const useUserStore = create<UserState>((set, get) => ({
|
||||
follows: [],
|
||||
loggedIn: false,
|
||||
loginError: null,
|
||||
accounts: loadSavedAccounts(),
|
||||
|
||||
loginWithNsec: async (nsecInput: string) => {
|
||||
try {
|
||||
@@ -55,9 +84,13 @@ export const useUserStore = create<UserState>((set, get) => ({
|
||||
const pubkey = user.pubkey;
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
|
||||
set({ pubkey, npub, loggedIn: true, loginError: null });
|
||||
// Update accounts list
|
||||
const accounts = upsertAccount(get().accounts, { pubkey, npub });
|
||||
persistAccounts(accounts);
|
||||
|
||||
// Persist pubkey for session restoration
|
||||
set({ pubkey, npub, loggedIn: true, loginError: null, accounts });
|
||||
|
||||
// Persist active session
|
||||
localStorage.setItem("wrystr_pubkey", pubkey);
|
||||
localStorage.setItem("wrystr_login_type", "nsec");
|
||||
|
||||
@@ -89,7 +122,12 @@ export const useUserStore = create<UserState>((set, get) => ({
|
||||
}
|
||||
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
set({ pubkey, npub, loggedIn: true, loginError: null });
|
||||
|
||||
// Update accounts list
|
||||
const accounts = upsertAccount(get().accounts, { pubkey, npub });
|
||||
persistAccounts(accounts);
|
||||
|
||||
set({ pubkey, npub, loggedIn: true, loginError: null, accounts });
|
||||
|
||||
localStorage.setItem("wrystr_pubkey", pubkey);
|
||||
localStorage.setItem("wrystr_login_type", "pubkey");
|
||||
@@ -104,10 +142,7 @@ export const useUserStore = create<UserState>((set, get) => ({
|
||||
logout: () => {
|
||||
const ndk = getNDK();
|
||||
ndk.signer = undefined;
|
||||
const { pubkey } = get();
|
||||
if (pubkey) {
|
||||
invoke<void>("delete_nsec", { pubkey }).catch(() => {});
|
||||
}
|
||||
// Don't delete the keychain entry — keep the account available for instant switch-back.
|
||||
localStorage.removeItem("wrystr_pubkey");
|
||||
localStorage.removeItem("wrystr_login_type");
|
||||
set({ pubkey: null, npub: null, profile: null, follows: [], loggedIn: false, loginError: null });
|
||||
@@ -129,14 +164,45 @@ export const useUserStore = create<UserState>((set, get) => ({
|
||||
if (nsec) {
|
||||
await get().loginWithNsec(nsec);
|
||||
}
|
||||
// If no keychain entry (first run after feature lands, or keychain unavailable),
|
||||
// the user will be prompted to log in again — same as before.
|
||||
// No keychain entry yet → stay logged out, user re-enters nsec once.
|
||||
} catch {
|
||||
// Keychain unavailable (e.g. no secret service on this Linux session) — stay logged out.
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
switchAccount: async (pubkey: string) => {
|
||||
// Try nsec from keychain first; fall back to read-only
|
||||
try {
|
||||
const nsec = await invoke<string | null>("load_nsec", { pubkey });
|
||||
if (nsec) {
|
||||
await get().loginWithNsec(nsec);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Keychain unavailable
|
||||
}
|
||||
await get().loginWithPubkey(pubkey);
|
||||
},
|
||||
|
||||
removeAccount: (pubkey: string) => {
|
||||
// Delete keychain entry (best-effort)
|
||||
invoke<void>("delete_nsec", { pubkey }).catch(() => {});
|
||||
|
||||
const accounts = get().accounts.filter((a) => a.pubkey !== pubkey);
|
||||
persistAccounts(accounts);
|
||||
set({ accounts });
|
||||
|
||||
// If removing the active account, clear the session
|
||||
if (get().pubkey === pubkey) {
|
||||
const ndk = getNDK();
|
||||
ndk.signer = undefined;
|
||||
localStorage.removeItem("wrystr_pubkey");
|
||||
localStorage.removeItem("wrystr_login_type");
|
||||
set({ pubkey: null, npub: null, profile: null, follows: [], loggedIn: false, loginError: null });
|
||||
}
|
||||
},
|
||||
|
||||
fetchOwnProfile: async () => {
|
||||
const { pubkey } = get();
|
||||
if (!pubkey) return;
|
||||
@@ -146,6 +212,13 @@ export const useUserStore = create<UserState>((set, get) => ({
|
||||
const user = ndk.getUser({ pubkey });
|
||||
await user.fetchProfile();
|
||||
set({ profile: user.profile });
|
||||
|
||||
// Update cached name/picture in accounts list
|
||||
const name = user.profile?.displayName || user.profile?.name;
|
||||
const picture = user.profile?.picture;
|
||||
const accounts = upsertAccount(get().accounts, { pubkey, npub: get().npub!, name, picture });
|
||||
persistAccounts(accounts);
|
||||
set({ accounts });
|
||||
} catch {
|
||||
// Profile fetch is non-critical
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user