diff --git a/src/App.tsx b/src/App.tsx index 3de00c7..eca7bc7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,17 +1,8 @@ -import { - getAppBootstrap, - login as loginRequest, - logout as logoutRequest, - switchAccount as switchAccountRequest, -} from "$/lib/api/app"; -import { getUnreadCount } from "$/lib/api/notifications"; -import { listen } from "@tauri-apps/api/event"; import { getCurrentWindow } from "@tauri-apps/api/window"; -import { createEffect, createMemo, onCleanup, onMount, Show, startTransition } from "solid-js"; -import { createStore } from "solid-js/store"; import "@fontsource-variable/google-sans"; -import "./App.css"; import type { ParentProps } from "solid-js"; +import { Show } from "solid-js"; +import "./App.css"; import { AccountLedger } from "./components/account/AccountLedger"; import { AppRail } from "./components/AppRail"; import { ComposerWindow } from "./components/feeds/ComposerWindow"; @@ -21,330 +12,93 @@ import { NotificationsPanel } from "./components/notifications/NotificationsPane import { HeaderPanel } from "./components/panels/Header"; import { SessionSpotlight } from "./components/Session"; import { ErrorToast } from "./components/shared/ErrorToast"; -import { ACCOUNT_SWITCH_EVENT, NOTIFICATIONS_UNREAD_COUNT_EVENT } from "./lib/constants/events"; -import type { AccountSummary, ActiveSession } from "./lib/types"; +import { AppSessionProvider, useAppSession } from "./contexts/app-session"; +import { AppShellUiProvider, useAppShellUi } from "./contexts/app-shell-ui"; import { AppRouter } from "./router"; const COMPOSER_WINDOW_LABEL = "composer"; -const RAIL_COLLAPSED_STORAGE_KEY = "lazurite:rail-collapsed"; -type AppState = { - accounts: AccountSummary[]; - activeSession: ActiveSession | null; - bootstrapping: boolean; - errorMessage: string | null; - loggingIn: boolean; - loginValue: string; - logoutDid: string | null; - narrowViewport: boolean; - railCollapsed: boolean; - reauthNeeded: boolean; - shakeCount: number; - showSwitcher: boolean; - switchingDid: string | null; - unreadNotifications: number; -}; +function AppShell(props: ParentProps) { + const session = useAppSession(); + const shell = useAppShellUi(); -function createInitialAppState(): AppState { - return { - accounts: [], - activeSession: null, - bootstrapping: true, - errorMessage: null, - loggingIn: false, - loginValue: "", - logoutDid: null, - narrowViewport: false, - railCollapsed: false, - reauthNeeded: false, - shakeCount: 0, - showSwitcher: false, - switchingDid: null, - unreadNotifications: 0, - }; + return ( + <> +
+ + +
+ {props.children} +
+
+ + + + ); } -function App() { - const [app, setApp] = createStore(createInitialAppState()); - +function AppContent() { + const session = useAppSession(); const standaloneComposerWindow = isComposerWindow(); - const activeAccount = createMemo(() => - app.accounts.find((account) => account.did === app.activeSession?.did) ?? null - ); - const primaryAccount = createMemo(() => activeAccount() ?? app.accounts[0] ?? null); - const hasSession = createMemo(() => !!app.activeSession); - const railCompact = createMemo(() => app.railCollapsed && !app.narrowViewport); - const railCondensed = createMemo(() => railCompact() || app.narrowViewport); - const railColumns = createMemo(() => (railCompact() ? "5.75rem minmax(0,1fr)" : "16rem minmax(0,1fr)")); - const metaLabel = createMemo(() => { - if (app.bootstrapping) { - return "reconnecting"; - } - - if (app.activeSession) { - return "connected"; - } - - return "ready"; - }); - - async function loadBootstrap() { - setApp("bootstrapping", true); - - try { - const payload = await getAppBootstrap(); - startTransition(() => { - setApp("activeSession", payload.activeSession); - setApp("accounts", payload.accountList); - setApp("reauthNeeded", payload.accountList.length > 0 && !payload.activeSession); - }); - - if (payload.activeSession) { - try { - setApp("unreadNotifications", await getUnreadCount()); - } catch { - setApp("unreadNotifications", 0); - } - } else { - setApp("unreadNotifications", 0); - } - } catch (error) { - setApp("errorMessage", `Failed to load app bootstrap: ${String(error)}`); - } finally { - setApp("bootstrapping", false); - } - } - - function closeSwitcher() { - if (app.showSwitcher) { - setApp("showSwitcher", false); - } - } - - function triggerShake() { - setApp("shakeCount", (count) => count + 1); - } - - function markPotentialExpiry(error: unknown) { - const message = String(error).toLowerCase(); - if (message.includes("refresh failed permanently") || message.includes("session does not exist")) { - setApp("reauthNeeded", true); - } - } - - async function submitLogin(identifier = app.loginValue) { - const trimmed = identifier.trim(); - if (!validateIdentifier(trimmed)) { - triggerShake(); - setApp("errorMessage", "Please enter a valid handle or DID."); - return; - } - - setApp("loggingIn", true); - try { - await loginRequest(trimmed); - setApp("loginValue", ""); - closeSwitcher(); - await loadBootstrap(); - } catch (error) { - markPotentialExpiry(error); - setApp("errorMessage", `Authentication failed: ${String(error)}`); - } finally { - setApp("loggingIn", false); - } - } - - async function switchAccount(did: string) { - setApp("switchingDid", did); - try { - await switchAccountRequest(did); - closeSwitcher(); - await loadBootstrap(); - } catch (error) { - markPotentialExpiry(error); - setApp("errorMessage", `Failed to switch account: ${String(error)}`); - } finally { - setApp("switchingDid", null); - } - } - - async function logout(did: string) { - setApp("logoutDid", did); - try { - await logoutRequest(did); - closeSwitcher(); - await loadBootstrap(); - } catch (error) { - markPotentialExpiry(error); - setApp("errorMessage", `Failed to logout account: ${String(error)}`); - } finally { - setApp("logoutDid", null); - } - } - - async function reauthorizePrimaryAccount() { - const account = primaryAccount(); - if (!account) { - return; - } - - await submitLogin(account.handle || account.did); - } - - onMount(() => { - let unlisten: (() => void) | undefined; - const media = globalThis.matchMedia("(max-width: 1180px)"); - const syncViewport = () => setApp("narrowViewport", media.matches); - - const stored = globalThis.localStorage.getItem(RAIL_COLLAPSED_STORAGE_KEY); - if (stored === "true") { - setApp("railCollapsed", true); - } - - syncViewport(); - media.addEventListener("change", syncViewport); - - void loadBootstrap(); - - void listen(ACCOUNT_SWITCH_EVENT, () => { - void loadBootstrap(); - }).then((dispose) => { - unlisten = dispose; - }); - - let unlistenUnread: (() => void) | undefined; - void listen(NOTIFICATIONS_UNREAD_COUNT_EVENT, (event) => { - setApp("unreadNotifications", event.payload); - }).then((dispose) => { - unlistenUnread = dispose; - }); - - onCleanup(() => { - unlisten?.(); - unlistenUnread?.(); - media.removeEventListener("change", syncViewport); - }); - }); - - createEffect(() => { - globalThis.localStorage.setItem(RAIL_COLLAPSED_STORAGE_KEY, app.railCollapsed ? "true" : "false"); - }); - - function AppShell(props: ParentProps) { - return ( - <> -
- void logout(did)} - onSwitch={(did) => void switchAccount(did)} - onToggleCollapse={() => setApp("railCollapsed", (collapsed) => !collapsed)} - onToggleSwitcher={() => setApp("showSwitcher", (open) => !open)} /> - -
- {props.children} -
-
- - setApp("errorMessage", null)} /> - - ); - } return ( - }> + } + renderComposer={() => } + renderNotifications={() => } + renderShell={AppShell} + renderTimeline={({ context }) => ( + + )} /> + }> + <> +
setApp("loginValue", value)} - onSubmit={() => void submitLogin()} /> + value={session.loginValue} + pending={session.loggingIn} + shakeCount={session.shakeCount} + onInput={session.setLoginValue} + onSubmit={() => void session.submitLogin()} />
}> - {(session) => ( - setApp("errorMessage", message)} /> - )} +
-
+ }> + +
- setApp("errorMessage", null)} /> - - }> - setApp("showSwitcher", false)} - renderAuth={() => ( - setApp("loginValue", value)} - onLogout={(did) => void logout(did)} - onReauth={() => void reauthorizePrimaryAccount()} - onSubmit={() => void submitLogin()} - onSwitch={(did) => void switchAccount(did)} /> - )} - renderShell={AppShell} - renderComposer={(session) => ( - setApp("errorMessage", message)} /> - )} - renderTimeline={({ session, context }) => ( - setApp("errorMessage", message)} - onThreadRouteChange={context.onThreadRouteChange} - threadUri={context.threadUri} /> - )} - renderNotifications={() => setApp("unreadNotifications", 0)} />} /> + + ); } +function App() { + return ( + + + + + + ); +} + function isComposerWindow() { try { return getCurrentWindow().label === COMPOSER_WINDOW_LABEL; @@ -364,29 +118,9 @@ function ComposerBootState() { ); } -function AuthWorkspace( - props: { - accounts: AccountSummary[]; - activeAccount: AccountSummary | null; - activeSession: ActiveSession | null; - activeDid: string | null; - bootstrapping: boolean; - loggingIn: boolean; - loginValue: string; - logoutDid: string | null; - metaLabel: string; - reauthNeeded: boolean; - shakeCount: number; - switchingDid: string | null; - onInput: (value: string) => void; - onLogout: (did: string) => void; - onReauth: () => void; - onSubmit: () => void; - onSwitch: (did: string) => void; - }, -) { - const hasAccounts = () => props.accounts.length > 0; - const displayAccount = () => props.activeAccount ?? (props.reauthNeeded ? props.accounts[0] ?? null : null); +function AuthWorkspace() { + const session = useAppSession(); + const hasAccounts = () => session.accounts.length > 0; return (
+ value={session.loginValue} + pending={session.loggingIn} + shakeCount={session.shakeCount} + onInput={session.setLoginValue} + onSubmit={() => void session.submitLogin()} />
}> <> - - - + + +
); } -function validateIdentifier(value: string) { - const trimmed = value.trim(); - const handlePattern = /^@?[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/i; - const didPattern = /^did:[a-z]+:[a-zA-Z0-9._:%-]+$/; - const urlPattern = /^https?:\/\/\S+$/i; - - return handlePattern.test(trimmed) || didPattern.test(trimmed) || urlPattern.test(trimmed); -} - export default App; diff --git a/src/components/AppRail.tsx b/src/components/AppRail.tsx index 520ba48..3f27240 100644 --- a/src/components/AppRail.tsx +++ b/src/components/AppRail.tsx @@ -1,4 +1,5 @@ -import type { AccountSummary, ActiveSession } from "$/lib/types"; +import { useAppSession } from "$/contexts/app-session"; +import { useAppShellUi } from "$/contexts/app-shell-ui"; import { Show } from "solid-js"; import { AccountSwitcher } from "./account/AccountSwitcher"; import { RailButton } from "./RailButton"; @@ -46,48 +47,24 @@ function RailNavigation(props: { collapsed: boolean; hasSession: boolean; unread ); } -export function AppRail( - props: { - activeAccount: AccountSummary | null; - activeSession: ActiveSession | null; - accounts: AccountSummary[]; - collapsed: boolean; - hasSession: boolean; - logoutDid: string | null; - narrow: boolean; - openSwitcher: boolean; - unreadNotifications: number; - onCloseSwitcher: () => void; - switchingDid: string | null; - onLogout: (did: string) => void; - onSwitch: (did: string) => void; - onToggleCollapse: () => void; - onToggleSwitcher: () => void; - }, -) { +export function AppRail() { + const session = useAppSession(); + const shell = useAppShellUi(); + return ( ); } diff --git a/src/components/Session.test.tsx b/src/components/Session.test.tsx index 705746d..e7de815 100644 --- a/src/components/Session.test.tsx +++ b/src/components/Session.test.tsx @@ -1,11 +1,11 @@ +import { AppTestProviders } from "$/test/providers"; import { render, screen } from "@solidjs/testing-library"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { SessionEmptyState, SessionSpotlight } from "./Session"; describe("SessionEmptyState", () => { it("renders empty state copy", () => { render(() => ); - expect(screen.getByText("No account connected yet.")).toBeInTheDocument(); expect(screen.getByText("Connect your Bluesky account to start exploring.")).toBeInTheDocument(); }); @@ -14,12 +14,9 @@ describe("SessionEmptyState", () => { describe("SessionSpotlight", () => { it("renders 'Your account' label", () => { render(() => ( - + + + )); expect(screen.getByText("Your account")).toBeInTheDocument(); @@ -27,25 +24,29 @@ describe("SessionSpotlight", () => { it("shows Ready status when no session and not bootstrapping", () => { render(() => ( - + + + )); expect(screen.getByText("Ready")).toBeInTheDocument(); }); it("shows expired account state when reauth is needed", () => { + const account = { active: false, did: "did:plc:alice", handle: "alice.test", pdsUrl: "https://pds.example.com" }; + render(() => ( - + + + )); expect(screen.getByText("Expired")).toBeInTheDocument(); @@ -55,14 +56,10 @@ describe("SessionSpotlight", () => { it("shows Reconnecting status when bootstrapping", () => { render(() => ( - + + + )); - expect(screen.getByText("Reconnecting")).toBeInTheDocument(); }); }); diff --git a/src/components/Session.tsx b/src/components/Session.tsx index 3464d3c..caf71a1 100644 --- a/src/components/Session.tsx +++ b/src/components/Session.tsx @@ -1,3 +1,4 @@ +import { useAppSession } from "$/contexts/app-session"; import type { AccountSummary, ActiveSession } from "$/lib/types"; import { createMemo, Show } from "solid-js"; import { Presence } from "solid-motionone"; @@ -44,32 +45,27 @@ export function SessionProfile(props: { session: ActiveSession; activeAccount: A ); } -type SessionSpotlightProps = { - activeSession: ActiveSession | null; - activeAccount: AccountSummary | null; - bootstrapping: boolean; - reauthNeeded: boolean; - onReauth: () => void; -}; - -export function SessionSpotlight(props: SessionSpotlightProps) { - const bootstrapping = () => props.bootstrapping; - const activeSession = () => props.activeSession; +export function SessionSpotlight() { + const session = useAppSession(); + const displayAccount = createMemo(() => + session.activeAccount ?? (session.reauthNeeded ? session.primaryAccount : null) + ); const label = createMemo(() => { - if (bootstrapping()) { + if (session.bootstrapping) { return "Reconnecting"; } - if (activeSession()) { + if (session.activeSession) { return "Connected"; } - if (props.reauthNeeded && props.activeAccount) { + if (session.reauthNeeded && displayAccount()) { return "Expired"; } return "Ready"; }); + return (
@@ -78,14 +74,14 @@ export function SessionSpotlight(props: SessionSpotlightProps) {
+ activeAccount={displayAccount()} + activeSession={session.activeSession} + bootstrapping={session.bootstrapping} + reauthNeeded={session.reauthNeeded} /> - - + + void session.reauthorizePrimaryAccount()} />
@@ -109,7 +105,7 @@ export function SessionBody( {(account) => } }> - {(session) => } + {(currentSession) => } ); diff --git a/src/components/account/AccountLedger.tsx b/src/components/account/AccountLedger.tsx index 7c7382d..075fb30 100644 --- a/src/components/account/AccountLedger.tsx +++ b/src/components/account/AccountLedger.tsx @@ -1,41 +1,35 @@ import { AvatarBadge } from "$/components/AvatarBadge"; +import { useAppSession } from "$/contexts/app-session"; import type { AccountSummary } from "$/lib/types"; import { For, Show } from "solid-js"; import { Motion } from "solid-motionone"; import { AccountSwitchButton, LogoutButton } from "./AccountButtons"; -type AccountLedgerProps = { - accounts: AccountSummary[]; - activeDid: string | null; - busyDid: string | null; - logoutDid: string | null; - onSwitch: (did: string) => void; - onLogout: (did: string) => void; -}; +export function AccountLedger() { + const session = useAppSession(); -export function AccountLedger(props: AccountLedgerProps) { return (

Accounts

-

{props.accounts.length} added

+

{session.accounts.length} added

0} + when={session.accounts.length > 0} fallback={

Your accounts will appear here once you sign in.

}>
- + {(account) => ( + activeDid={session.activeDid} + busyDid={session.switchingDid} + logoutDid={session.logoutDid} + onSwitch={(did) => void session.switchAccount(did)} + onLogout={(did) => void session.logout(did)} /> )}
diff --git a/src/components/account/AccountSwitcher.test.tsx b/src/components/account/AccountSwitcher.test.tsx index 2d0eae0..384ec75 100644 --- a/src/components/account/AccountSwitcher.test.tsx +++ b/src/components/account/AccountSwitcher.test.tsx @@ -1,3 +1,4 @@ +import { AppTestProviders } from "$/test/providers"; import { fireEvent, render, screen } from "@solidjs/testing-library"; import { describe, expect, it, vi } from "vitest"; import { AccountSwitcher } from "./AccountSwitcher"; @@ -13,17 +14,16 @@ const ACCOUNT = { describe("AccountSwitcher", () => { it("renders the stored account when no active session exists", () => { render(() => ( - + + + )); expect(screen.getByText("alice.test")).toBeInTheDocument(); @@ -31,29 +31,27 @@ describe("AccountSwitcher", () => { }); it("closes the menu on outside pointerdown instead of toggling", () => { - const onClose = vi.fn(); - const onToggle = vi.fn(); + const closeSwitcher = vi.fn(); + const toggleSwitcher = vi.fn(); render(() => ( <> - + + +
outside
)); fireEvent.pointerDown(screen.getByTestId("outside")); - expect(onClose).toHaveBeenCalledTimes(1); - expect(onToggle).not.toHaveBeenCalled(); + expect(closeSwitcher).toHaveBeenCalledTimes(1); + expect(toggleSwitcher).not.toHaveBeenCalled(); }); }); diff --git a/src/components/account/AccountSwitcher.tsx b/src/components/account/AccountSwitcher.tsx index 4fc92f0..b4093e7 100644 --- a/src/components/account/AccountSwitcher.tsx +++ b/src/components/account/AccountSwitcher.tsx @@ -1,34 +1,21 @@ import { ArrowIcon } from "$/components/shared/Icon"; -import type { AccountSummary, ActiveSession } from "$/lib/types"; +import { useAppSession } from "$/contexts/app-session"; +import { useAppShellUi } from "$/contexts/app-shell-ui"; import { createMemo, onCleanup, onMount, Show } from "solid-js"; import { SwitcherIdentity } from "./AccountSwitcherIdentity"; import { AccountSwitcherMenuList } from "./AccountSwitcherMenuList"; -type AccountSwitcherProps = { - activeAccount: AccountSummary | null; - activeSession: ActiveSession | null; - accounts: AccountSummary[]; - busyDid: string | null; - compact?: boolean; - logoutDid: string | null; - narrow?: boolean; - open: boolean; - onClose: () => void; - onToggle: () => void; - onSwitch: (did: string) => void; - onLogout: (did: string) => void; -}; - -export function AccountSwitcher(props: AccountSwitcherProps) { - const isOpen = () => props.open; - const previewAccount = createMemo(() => props.activeAccount ?? props.accounts[0] ?? null); +export function AccountSwitcher() { + const session = useAppSession(); + const shell = useAppShellUi(); + const previewAccount = createMemo(() => session.activeAccount ?? session.accounts[0] ?? null); const identity = createMemo(() => { - if (props.activeSession) { + if (session.activeSession) { return { - avatar: props.activeAccount?.avatar ?? null, - label: props.activeSession.handle, + avatar: session.activeAvatar, + label: session.activeSession.handle, meta: "Current account", - name: props.activeSession.handle, + name: session.activeSession.handle, tone: "primary" as const, }; } @@ -48,10 +35,12 @@ export function AccountSwitcher(props: AccountSwitcherProps) { }); let container: HTMLDivElement | undefined; + const compact = () => shell.railCondensed; + onMount(() => { const pointerListener = { handleEvent(event: Event) { - if (!isOpen()) { + if (!shell.showSwitcher) { return; } @@ -59,7 +48,7 @@ export function AccountSwitcher(props: AccountSwitcherProps) { return; } - props.onClose(); + shell.closeSwitcher(); }, }; @@ -67,14 +56,24 @@ export function AccountSwitcher(props: AccountSwitcherProps) { onCleanup(() => globalThis.removeEventListener("pointerdown", pointerListener)); }); + async function handleSwitch(did: string) { + shell.closeSwitcher(); + await session.switchAccount(did); + } + + async function handleLogout(did: string) { + shell.closeSwitcher(); + await session.logout(did); + } + return (
{ container = element; @@ -82,17 +81,17 @@ export function AccountSwitcher(props: AccountSwitcherProps) { - +
diff --git a/src/components/feeds/ComposerWindow.tsx b/src/components/feeds/ComposerWindow.tsx index eb22c86..fd27a86 100644 --- a/src/components/feeds/ComposerWindow.tsx +++ b/src/components/feeds/ComposerWindow.tsx @@ -1,3 +1,4 @@ +import { useAppSession } from "$/contexts/app-session"; import { createPost } from "$/lib/api/feeds"; import { POST_CREATED_EVENT } from "$/lib/constants/events"; import { emitTo } from "@tauri-apps/api/event"; @@ -5,13 +6,12 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; import { createSignal } from "solid-js"; import { ComposerSurface } from "./FeedComposer"; -type ComposerWindowProps = { activeAvatar?: string | null; activeHandle: string; onError: (message: string) => void }; - async function closeWindow() { await getCurrentWindow().close(); } -export function ComposerWindow(props: ComposerWindowProps) { +export function ComposerWindow() { + const session = useAppSession(); const [pending, setPending] = createSignal(false); const [text, setText] = createSignal(""); @@ -27,7 +27,7 @@ export function ComposerWindow(props: ComposerWindowProps) { await emitTo("main", POST_CREATED_EVENT, null); await closeWindow(); } catch (error) { - props.onError(`Failed to create post: ${String(error)}`); + session.reportError(`Failed to create post: ${String(error)}`); } finally { setPending(false); } @@ -36,8 +36,8 @@ export function ComposerWindow(props: ComposerWindowProps) { return (
void; onRefresh: () => void }) { return ( @@ -29,57 +30,34 @@ function FeedHeaderActions(props: { onCompose: () => void; onRefresh: () => void } function FeedScroller( - props: { - activeFeedId: string; - activeFeedState: FeedState | undefined; - activeAvatar?: string | null; - activeHandle: string; - focusedIndex: number; - generators: Record; - likePendingByUri: Record; - likePulseUri: string | null; - onCompose: () => void; - onFocusIndex: (index: number) => void; - onLike: (post: PostView) => Promise; - onOpenThread: (uri: string) => Promise; - onQuote: (post: PostView) => void; - onReply: (post: PostView, root: PostView) => void; - onRepost: (post: PostView) => Promise; - postRefs: Map; - repostPendingByUri: Record; - repostPulseUri: string | null; - scrollerRef: (element: HTMLDivElement) => void; - sentinelRef: (element: HTMLDivElement) => void; - setScrollTop: (top: number) => void; - visibleItems: FeedViewPost[]; - }, + props: { controller: FeedWorkspaceController; activeAvatar?: string | null; activeHandle: string }, ) { return (
props.scrollerRef(element)} + ref={(element) => props.controller.registerScroller(element)} class="feed-scroll-region min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain px-6 pb-8 pt-4 max-[760px]:px-4 max-[520px]:px-3" - onScroll={(event) => props.setScrollTop(event.currentTarget.scrollTop)}> + onScroll={(event) => props.controller.rememberScrollTop(event.currentTarget.scrollTop)}> + onCompose={props.controller.openComposer} /> + activeFeedId={props.controller.activeFeed().id} + activeFeedState={props.controller.activeFeedState()} + focusedIndex={props.controller.workspace.focusedIndex} + likePendingByUri={props.controller.workspace.likePendingByUri} + likePulseUri={props.controller.workspace.likePulseUri} + onFocusIndex={props.controller.setFocusedIndex} + onLike={props.controller.toggleLike} + onOpenThread={props.controller.openThread} + onQuote={props.controller.openQuoteComposer} + onReply={props.controller.openReplyComposer} + onRepost={props.controller.toggleRepost} + postRefs={props.controller.postRefs} + repostPendingByUri={props.controller.workspace.repostPendingByUri} + repostPulseUri={props.controller.workspace.repostPulseUri} + sentinelRef={props.controller.registerSentinel} + visibleItems={props.controller.visibleItems()} />
); } @@ -105,98 +83,34 @@ function FeedPaneTitle( ); } -function FeedPaneHeader( - props: { - activeFeed: SavedFeedItem; - generators: Record; - onCompose: () => void; - onFeedSelect: (feedId: string) => void; - onRefresh: () => void; - onToggleDrawer: () => void; - pinnedFeeds: SavedFeedItem[]; - }, -) { +function FeedPaneHeader(props: { controller: FeedWorkspaceController }) { return (
+ activeFeed={props.controller.activeFeed()} + generators={props.controller.workspace.generators} + onCompose={props.controller.openComposer} + onRefresh={() => void props.controller.refreshActiveFeed()} /> + activeFeedId={props.controller.activeFeed().id} + generators={props.controller.workspace.generators} + onFeedSelect={props.controller.switchFeed} + onToggleDrawer={props.controller.toggleFeedsDrawer} + pinnedFeeds={props.controller.pinnedFeeds().slice(0, 9)} />
); } -export function FeedPane( - props: { - activeFeed: SavedFeedItem; - activeFeedId: string; - activeFeedState: FeedState | undefined; - activeAvatar?: string | null; - activeHandle: string; - focusedIndex: number; - generators: Record; - likePendingByUri: Record; - likePulseUri: string | null; - onCompose: () => void; - onFeedSelect: (feedId: string) => void; - onFocusIndex: (index: number) => void; - onLike: (post: PostView) => Promise; - onOpenThread: (uri: string) => Promise; - onQuote: (post: PostView) => void; - onRefresh: () => void; - onReply: (post: PostView, root: PostView) => void; - onRepost: (post: PostView) => Promise; - onToggleDrawer: () => void; - pinnedFeeds: SavedFeedItem[]; - postRefs: Map; - repostPendingByUri: Record; - repostPulseUri: string | null; - scrollerRef: (element: HTMLDivElement) => void; - sentinelRef: (element: HTMLDivElement) => void; - setScrollTop: (top: number) => void; - visibleItems: FeedViewPost[]; - }, -) { +export function FeedPane(props: { controller: FeedWorkspaceController }) { + const session = useAppSession(); + return (
- + + controller={props.controller} + activeAvatar={session.activeAvatar} + activeHandle={session.activeHandle ?? ""} />
); } diff --git a/src/components/feeds/FeedWorkspace.test.tsx b/src/components/feeds/FeedWorkspace.test.tsx index 7e6b84f..c44f841 100644 --- a/src/components/feeds/FeedWorkspace.test.tsx +++ b/src/components/feeds/FeedWorkspace.test.tsx @@ -1,3 +1,4 @@ +import { AppTestProviders } from "$/test/providers"; import { fireEvent, render, screen } from "@solidjs/testing-library"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { FeedWorkspace } from "./FeedWorkspace"; @@ -107,7 +108,10 @@ describe("FeedWorkspace", () => { }); const { container } = render(() => ( - + + + )); await screen.findByText("Page 1"); diff --git a/src/components/feeds/FeedWorkspace.tsx b/src/components/feeds/FeedWorkspace.tsx index 370ed6a..4c205a5 100644 --- a/src/components/feeds/FeedWorkspace.tsx +++ b/src/components/feeds/FeedWorkspace.tsx @@ -1,3 +1,4 @@ +import { useAppSession } from "$/contexts/app-session"; import { FeedComposer } from "./FeedComposer"; import { SavedFeedsDrawer } from "./FeedDrawer"; import { FeedPane } from "./FeedPane"; @@ -5,40 +6,32 @@ import { FeedWorkspaceSidebar } from "./FeedWorkspaceSidebar"; import { ThreadPanel } from "./ThreadPanel"; import { type FeedWorkspaceProps, useFeedWorkspaceController } from "./useFeedWorkspaceController"; -export function FeedWorkspace(props: FeedWorkspaceProps) { - const controller = useFeedWorkspaceController(props); +type FeedWorkspaceRouteProps = Pick; + +export function FeedWorkspace(props: FeedWorkspaceRouteProps) { + const session = useAppSession(); + const activeSession = () => { + if (!session.activeSession) { + throw new Error("FeedWorkspace requires an active session"); + } + + return session.activeSession; + }; + const controller = useFeedWorkspaceController({ + activeSession: activeSession(), + onError: session.reportError, + get onThreadRouteChange() { + return props.onThreadRouteChange; + }, + get threadUri() { + return props.threadUri; + }, + }); return ( <>
- void controller.refreshActiveFeed()} - onReply={controller.openReplyComposer} - onRepost={controller.toggleRepost} - onToggleDrawer={controller.toggleFeedsDrawer} - pinnedFeeds={controller.pinnedFeeds().slice(0, 9)} - postRefs={controller.postRefs} - repostPendingByUri={controller.workspace.repostPendingByUri} - repostPulseUri={controller.workspace.repostPulseUri} - scrollerRef={controller.registerScroller} - sentinelRef={controller.registerSentinel} - setScrollTop={controller.rememberScrollTop} - visibleItems={controller.visibleItems()} /> + void; onThreadRouteChange: (uri: string | null) => void; @@ -725,3 +724,5 @@ export function useFeedWorkspaceController(props: FeedWorkspaceProps) { workspace, }; } + +export type FeedWorkspaceController = ReturnType; diff --git a/src/components/notifications/NotificationsPanel.test.tsx b/src/components/notifications/NotificationsPanel.test.tsx index 07ff427..fd5be70 100644 --- a/src/components/notifications/NotificationsPanel.test.tsx +++ b/src/components/notifications/NotificationsPanel.test.tsx @@ -1,3 +1,4 @@ +import { AppTestProviders } from "$/test/providers"; import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { NotificationsPanel } from "./NotificationsPanel"; @@ -43,12 +44,16 @@ describe("NotificationsPanel", () => { seenAt: null, }); - const onMarkSeen = vi.fn(); - render(() => ); + const markNotificationsSeen = vi.fn(); + render(() => ( + + + + )); await screen.findByLabelText("mention author mentioned you"); await waitFor(() => expect(updateSeenMock).toHaveBeenCalledOnce()); - await waitFor(() => expect(onMarkSeen).toHaveBeenCalledOnce()); + await waitFor(() => expect(markNotificationsSeen).toHaveBeenCalledOnce()); expect(screen.queryByLabelText("like author liked your post")).not.toBeInTheDocument(); @@ -75,7 +80,11 @@ describe("NotificationsPanel", () => { return Promise.resolve(() => {}); }); - render(() => ); + render(() => ( + + + + )); await screen.findByLabelText("mention author mentioned you"); @@ -88,7 +97,11 @@ describe("NotificationsPanel", () => { it("shows the error state when loading fails", async () => { listNotificationsMock.mockRejectedValue(new Error("notification fetch failed")); - render(() => ); + render(() => ( + + + + )); expect(await screen.findByText("notification fetch failed")).toBeInTheDocument(); }); diff --git a/src/components/notifications/NotificationsPanel.tsx b/src/components/notifications/NotificationsPanel.tsx index ba8ce39..2579231 100644 --- a/src/components/notifications/NotificationsPanel.tsx +++ b/src/components/notifications/NotificationsPanel.tsx @@ -1,3 +1,4 @@ +import { useAppSession } from "$/contexts/app-session"; import { listNotifications, updateSeen } from "$/lib/api/notifications"; import { NOTIFICATIONS_UNREAD_COUNT_EVENT } from "$/lib/constants/events"; import type { ListNotificationsResponse, NotificationView } from "$/lib/types"; @@ -13,9 +14,9 @@ type Tab = "mentions" | "activity"; const MENTION_REASONS = new Set(["mention", "reply", "quote"]); -type NotificationsPanelProps = { onMarkSeen: () => void }; - -export function NotificationsPanel(props: NotificationsPanelProps) { +export function NotificationsPanel() { + const session = useAppSession(); + // TODO: NotificationsStore via createStore const [tab, setTab] = createSignal("mentions"); const [notifications, setNotifications] = createSignal([]); const [loading, setLoading] = createSignal(true); @@ -43,7 +44,7 @@ export function NotificationsPanel(props: NotificationsPanelProps) { try { await updateSeen(); setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true }))); - props.onMarkSeen(); + session.markNotificationsSeen(); } catch (err) { const error = normalizeError(err); logger.warn("failed to mark notifications as seen", { keyValues: { error } }); diff --git a/src/components/search/SearchPanel.test.tsx b/src/components/search/SearchPanel.test.tsx index 22c019e..8529efe 100644 --- a/src/components/search/SearchPanel.test.tsx +++ b/src/components/search/SearchPanel.test.tsx @@ -1,3 +1,4 @@ +import { AppTestProviders } from "$/test/providers"; import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { SearchPanel } from "./SearchPanel"; @@ -25,6 +26,14 @@ vi.mock( vi.mock("@tauri-apps/plugin-log", () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn() })); +function renderSearchPanel() { + render(() => ( + + + + )); +} + describe("SearchPanel", () => { beforeEach(() => { vi.useFakeTimers(); @@ -61,7 +70,7 @@ describe("SearchPanel", () => { }); it("renders the search panel with initial state", async () => { - render(() => ); + renderSearchPanel(); expect(await screen.findByPlaceholderText("Search your saved & liked posts...")).toBeInTheDocument(); expect(screen.getByText("Network")).toBeInTheDocument(); @@ -71,7 +80,7 @@ describe("SearchPanel", () => { }); it("switches search modes when clicking mode buttons", async () => { - render(() => ); + renderSearchPanel(); const keywordButton = screen.getByRole("button", { name: /keyword/i }); fireEvent.click(keywordButton); @@ -92,7 +101,7 @@ describe("SearchPanel", () => { }], }); - render(() => ); + renderSearchPanel(); const input = await screen.findByPlaceholderText("Search your saved & liked posts..."); fireEvent.input(input, { target: { value: "test query" } }); @@ -121,7 +130,7 @@ describe("SearchPanel", () => { semanticMatch: false, }]); - render(() => ); + renderSearchPanel(); const keywordButton = screen.getByRole("button", { name: /keyword/i }); fireEvent.click(keywordButton); @@ -139,7 +148,7 @@ describe("SearchPanel", () => { }); it("cycles through modes with Tab key", async () => { - render(() => ); + renderSearchPanel(); const input = await screen.findByPlaceholderText("Search your saved & liked posts..."); input.focus(); @@ -159,7 +168,7 @@ describe("SearchPanel", () => { }], }); - render(() => ); + renderSearchPanel(); const input = await screen.findByPlaceholderText("Search your saved & liked posts..."); fireEvent.input(input, { target: { value: "test" } }); @@ -177,7 +186,7 @@ describe("SearchPanel", () => { it("displays error state when search fails", async () => { searchPostsNetworkMock.mockRejectedValue(new Error("Search failed")); - render(() => ); + renderSearchPanel(); const input = await screen.findByPlaceholderText("Search your saved & liked posts..."); fireEvent.input(input, { target: { value: "test" } }); @@ -192,7 +201,7 @@ describe("SearchPanel", () => { getSyncStatusMock.mockResolvedValue([{ did: "did:plc:test", source: "like", postCount: 12, lastSyncedAt: null }]); searchPostsMock.mockResolvedValue([]); - render(() => ); + renderSearchPanel(); const keywordButton = screen.getByRole("button", { name: /keyword/i }); fireEvent.click(keywordButton); diff --git a/src/components/search/SearchPanel.tsx b/src/components/search/SearchPanel.tsx index 42ada02..ebccbc3 100644 --- a/src/components/search/SearchPanel.tsx +++ b/src/components/search/SearchPanel.tsx @@ -1,4 +1,5 @@ import { Icon, SearchModeIcon } from "$/components/shared/Icon"; +import { useAppSession } from "$/contexts/app-session"; import { type EmbeddingsConfig, getEmbeddingsConfig, @@ -10,7 +11,6 @@ import { type SyncStatus, } from "$/lib/api/search"; import { formatRelativeTime } from "$/lib/feeds"; -import type { ActiveSession } from "$/lib/types"; import { normalizeError } from "$/lib/utils/text"; import * as logger from "@tauri-apps/plugin-log"; import { createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js"; @@ -37,9 +37,8 @@ function ModeLabel(props: { mode: SearchMode }) { ); } -type SearchPanelProps = { session: ActiveSession }; - -export function SearchPanel(props: SearchPanelProps) { +export function SearchPanel() { + const session = useAppSession(); const [mode, setMode] = createSignal("network"); const [query, setQuery] = createSignal(""); const [results, setResults] = createSignal([]); @@ -231,7 +230,7 @@ export function SearchPanel(props: SearchPanelProps) {