diff --git a/src/components/sidebar/AccountSwitcher.tsx b/src/components/sidebar/AccountSwitcher.tsx new file mode 100644 index 0000000..bae9c34 --- /dev/null +++ b/src/components/sidebar/AccountSwitcher.tsx @@ -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 ( + { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + ); + } + return ( +
+ {initial} +
+ ); +} + +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 ( + <> +
+ {accounts.length > 0 && ( +
+ {accounts.map((a) => ( + + ))} +
+
+ )} + +
+ {showAddLogin && setShowAddLogin(false)} />} + + ); + } + + return ( + <> +
+ {/* Expanded account list */} + {open && ( +
+ {others.map((a) => ( +
handleSwitch(a.pubkey)} + > + + {displayName(a)} + +
+ ))} + + +
+ )} + + {/* Current account row */} +
+
+
openProfile(pubkey)} + > + + {displayName(current)} +
+ +
+ + {open && ( +
+ + +
+ )} +
+
+ + {showAddLogin && setShowAddLogin(false)} />} + + ); +} diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx index 1ff3f2e..e4470dd 100644 --- a/src/components/sidebar/Sidebar.tsx +++ b/src/components/sidebar/Sidebar.tsx @@ -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() { ))} - {/* User / Login */} - {!sidebarCollapsed && ( -
- {loggedIn ? ( -
-
{ const { pubkey } = useUserStore.getState(); if (pubkey) openProfile(pubkey); }} - > - {userAvatar ? ( - { - (e.target as HTMLImageElement).style.display = "none"; - }} - /> - ) : ( -
- {(userName || "?").charAt(0).toUpperCase()} -
- )} - - {userName} - -
- -
- ) : ( -
- -
- )} -
- )} + {/* Account switcher */} + {!sidebarCollapsed && } {/* Status footer */} {!sidebarCollapsed && ( @@ -133,7 +83,6 @@ export function Sidebar() { )} - {showLogin && setShowLogin(false)} />} ); } diff --git a/src/stores/user.ts b/src/stores/user.ts index 5e83b06..9c47ff3 100644 --- a/src/stores/user.ts +++ b/src/stores/user.ts @@ -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; loginWithPubkey: (pubkey: string) => Promise; logout: () => void; restoreSession: () => Promise; + switchAccount: (pubkey: string) => Promise; + removeAccount: (pubkey: string) => void; fetchOwnProfile: () => Promise; fetchFollows: () => Promise; follow: (pubkey: string) => Promise; @@ -29,6 +57,7 @@ export const useUserStore = create((set, get) => ({ follows: [], loggedIn: false, loginError: null, + accounts: loadSavedAccounts(), loginWithNsec: async (nsecInput: string) => { try { @@ -55,9 +84,13 @@ export const useUserStore = create((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((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((set, get) => ({ logout: () => { const ndk = getNDK(); ndk.signer = undefined; - const { pubkey } = get(); - if (pubkey) { - invoke("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((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("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("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((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 }