diff --git a/src/App.tsx b/src/App.tsx index 0bc941a..7ecac1b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import { LoginPanel } from "./components/LoginPanel"; import { MessagesPanel } from "./components/messages/MessagesPanel"; import { NotificationsPanel } from "./components/notifications/NotificationsPanel"; import { HeaderPanel } from "./components/panels/Header"; +import { PostEngagementPanel } from "./components/posts/PostEngagementPanel"; import { PostPanel } from "./components/posts/PostPanel"; import { ThreadDrawer } from "./components/posts/ThreadDrawer"; import { ProfilePanel } from "./components/profile/ProfilePanel"; @@ -86,6 +87,7 @@ function AppContent() { renderComposer={() => } renderMessages={(props) => } renderNotifications={() => } + renderPostEngagement={(props) => } renderPost={(props) => } renderProfile={(props) => } renderShell={AppShell} diff --git a/src/components/deck/DeckColumn.tsx b/src/components/deck/DeckColumn.tsx index 795ebb8..6db426d 100644 --- a/src/components/deck/DeckColumn.tsx +++ b/src/components/deck/DeckColumn.tsx @@ -1,6 +1,7 @@ import { ExplorerPanel } from "$/components/explorer/ExplorerPanel"; import { FeedContent } from "$/components/feeds/FeedContent"; import { MessagesPanel } from "$/components/messages/MessagesPanel"; +import { usePostNavigation } from "$/components/posts/usePostNavigation"; import { ProfilePanel } from "$/components/profile/ProfilePanel"; import { SearchPanel } from "$/components/search/SearchPanel"; import type { Column, ColumnWidth } from "$/lib/api/types/columns"; @@ -157,6 +158,7 @@ function FeedBody(props: FeedBodyProps) { type FeedBodyContentProps = { feed: SavedFeedItem; onOpenThread: (uri: string) => void }; function FeedBodyContent(props: FeedBodyContentProps) { + const postNavigation = usePostNavigation(); const { bookmarkPendingByUri, likePendingByUri, @@ -187,6 +189,7 @@ function FeedBodyContent(props: FeedBodyContentProps) { onBookmark={(post: PostView) => toggleBookmark(post)} onFocusIndex={() => void 0} onLike={(post: PostView) => toggleLike(post)} + onOpenEngagement={(uri, tab) => Promise.resolve(postNavigation.openPostEngagement(uri, tab))} onOpenThread={(uri: string) => Promise.resolve(props.onOpenThread(uri))} onQuote={() => void 0} onReply={() => void 0} diff --git a/src/components/feeds/FeedContent.tsx b/src/components/feeds/FeedContent.tsx index e18ff06..e8519b9 100644 --- a/src/components/feeds/FeedContent.tsx +++ b/src/components/feeds/FeedContent.tsx @@ -1,4 +1,5 @@ import { getReplyRootPost } from "$/lib/feeds"; +import type { PostEngagementTab } from "$/lib/post-engagement-routes"; import type { FeedViewPost, PostView } from "$/lib/types"; import { For, Show } from "solid-js"; import { EmptyFeedState, FeedSkeleton, LoadingMoreIndicator } from "./FeedEmpty"; @@ -38,6 +39,7 @@ export function FeedContent( onFocusIndex: (index: number) => void; onBookmark: (post: PostView) => Promise | void; onLike: (post: PostView) => Promise | void; + onOpenEngagement: (uri: string, tab: PostEngagementTab) => Promise | void; onOpenThread: (uri: string) => Promise | void; onQuote: (post: PostView) => void; onReply: (post: PostView, root: PostView) => void; @@ -62,6 +64,7 @@ export function FeedContent( onBookmark={() => void props.onBookmark(item.post)} onFocus={() => props.onFocusIndex(index())} onLike={() => void props.onLike(item.post)} + onOpenEngagement={(tab) => void props.onOpenEngagement(item.post.uri, tab)} onOpenThread={() => void props.onOpenThread(item.post.uri)} onQuote={() => props.onQuote(item.post)} onReply={() => props.onReply(item.post, getReplyRootPost(item))} diff --git a/src/components/feeds/FeedPane.tsx b/src/components/feeds/FeedPane.tsx index fd752be..5c67a37 100644 --- a/src/components/feeds/FeedPane.tsx +++ b/src/components/feeds/FeedPane.tsx @@ -51,6 +51,7 @@ function FeedScroller( onBookmark={props.controller.toggleBookmark} onFocusIndex={props.controller.setFocusedIndex} onLike={props.controller.toggleLike} + onOpenEngagement={props.controller.openPostEngagement} onOpenThread={props.controller.openThread} onQuote={props.controller.openQuoteComposer} onReply={props.controller.openReplyComposer} diff --git a/src/components/feeds/FeedWorkspace.tsx b/src/components/feeds/FeedWorkspace.tsx index 2707347..677bc57 100644 --- a/src/components/feeds/FeedWorkspace.tsx +++ b/src/components/feeds/FeedWorkspace.tsx @@ -1,3 +1,4 @@ +import { usePostNavigation } from "$/components/posts/usePostNavigation"; import { useThreadOverlayNavigation } from "$/components/posts/useThreadOverlayNavigation"; import { Icon } from "$/components/shared/Icon"; import { useAppSession } from "$/contexts/app-session"; @@ -12,6 +13,7 @@ import { useFeedWorkspaceController } from "./useFeedWorkspaceController"; export function FeedWorkspace() { const session = useAppSession(); + const postNavigation = usePostNavigation(); const threadOverlay = useThreadOverlayNavigation(); const activeSession = () => { if (!session.activeSession) { @@ -23,6 +25,7 @@ export function FeedWorkspace() { const controller = useFeedWorkspaceController({ activeSession: activeSession(), onError: session.reportError, + onOpenPostEngagement: (uri, tab) => void postNavigation.openPostEngagement(uri, tab), onOpenThread: (uri) => void threadOverlay.openThread(uri), }); diff --git a/src/components/feeds/PostCard.tsx b/src/components/feeds/PostCard.tsx index 1459e3d..79b625d 100644 --- a/src/components/feeds/PostCard.tsx +++ b/src/components/feeds/PostCard.tsx @@ -19,6 +19,7 @@ import { isReplyItem, } from "$/lib/feeds"; import { collectModerationLabels } from "$/lib/moderation"; +import type { PostEngagementTab } from "$/lib/post-engagement-routes"; import { buildProfileRoute, getProfileRouteActor } from "$/lib/profile"; import type { EmbedView, @@ -57,11 +58,16 @@ function mergeModerationDecisions( }; } -function PostHeader(props: { authorHandle: string; authorName: string; createdAt: string }) { +function PostHeader(props: { authorHandle: string; authorHref: string; authorName: string; createdAt: string }) { return (
{props.authorName} - {props.authorHandle} + event.stopPropagation()}> + {props.authorHandle} + {props.createdAt}
); @@ -80,7 +86,13 @@ function PostPrimaryRegion(props: ParentProps<{ onFocus?: () => void; onOpenThre aria-label={interactive() ? "Open thread" : undefined} role={interactive() ? "button" : undefined} tabIndex={interactive() ? 0 : undefined} - onClick={() => props.onOpenThread?.()} + onClick={(event) => { + if (isInteractiveTarget(event.target)) { + return; + } + + props.onOpenThread?.(); + }} onFocus={() => props.onFocus?.()} onKeyDown={(event) => { if ((event.key === "Enter" || event.key === " ") && props.onOpenThread) { @@ -95,25 +107,26 @@ function PostPrimaryRegion(props: ParentProps<{ onFocus?: () => void; onOpenThre type PostActionButtonProps = { active?: boolean; + ariaLabel?: string; busy?: boolean; icon: string; iconActive?: string; label: string; - onClick?: () => void; + onClick?: (event: MouseEvent) => void; pulse?: boolean; }; function PostActionButton(props: PostActionButtonProps) { return ( + + + + +
+ + + + + + + + + + + + + + + + + +
+ + ); +} + +function activeCount(group: DiagnosticBacklinkGroup) { + return group.total ?? group.records.length; +} + +function EngagementList( + props: { + items: DiagnosticBacklinkItem[]; + kind: PostEngagementTab; + onOpenProfile: (item: DiagnosticBacklinkItem) => void; + onOpenQuote: (item: DiagnosticBacklinkItem) => void; + }, +) { + return ( +
+ + {(item, index) => ( + + )} + +
+ ); +} + +function EngagementRow( + props: { + index: number; + item: DiagnosticBacklinkItem; + kind: PostEngagementTab; + onOpenProfile: (item: DiagnosticBacklinkItem) => void; + onOpenQuote: (item: DiagnosticBacklinkItem) => void; + }, +) { + const actorLabel = createMemo(() => + props.item.profile?.displayName ?? props.item.profile?.handle ?? props.item.did ?? "Unknown account" + ); + const handleLabel = createMemo(() => formatHandle(props.item.profile?.handle, props.item.did)); + const quoteInteractive = createMemo(() => props.kind === "quotes" && !!props.item.uri); + const profileInteractive = createMemo(() => + props.kind !== "quotes" && !!(props.item.profile?.handle || props.item.did) + ); + const interactive = createMemo(() => quoteInteractive() || profileInteractive()); + + return ( + + ); +} + +function PanelMessage(props: { body: string; title: string }) { + return ( +
+
+

{props.title}

+

{props.body}

+
+
+ ); +} + +function EngagementSkeleton() { + return ( +
+ + {() => ( +
+
+
+
+
+
+
+
+
+
+ )} + +
+ ); +} diff --git a/src/components/posts/PostPanel.tsx b/src/components/posts/PostPanel.tsx index db12773..0ed5bc2 100644 --- a/src/components/posts/PostPanel.tsx +++ b/src/components/posts/PostPanel.tsx @@ -161,6 +161,7 @@ export function PostPanel(props: { uri: string | null }) { loading={state.loading} onBookmark={(post) => void interactions.toggleBookmark(post)} onLike={(post) => void interactions.toggleLike(post)} + onOpenEngagement={(uri, tab) => void postNavigation.openPostEngagement(uri, tab)} onOpenPost={(uri) => void postNavigation.openPost(uri)} onRepost={(post) => void interactions.toggleRepost(post)} parentChain={parentChain()} @@ -180,6 +181,7 @@ function ThreadState( loading: boolean; onBookmark: (post: PostView) => void; onLike: (post: PostView) => void; + onOpenEngagement: (uri: string, tab: "likes" | "reposts" | "quotes") => void; onOpenPost: (uri: string) => void; onRepost: (post: PostView) => void; parentChain: ThreadViewPost[]; @@ -210,6 +212,7 @@ function ThreadState( likePending={!!props.likePendingByUri[parent.post.uri]} onBookmark={() => props.onBookmark(parent.post)} onLike={() => props.onLike(parent.post)} + onOpenEngagement={(tab) => props.onOpenEngagement(parent.post.uri, tab)} onOpenThread={() => props.onOpenPost(parent.post.uri)} onRepost={() => props.onRepost(parent.post)} post={parent.post} @@ -225,6 +228,7 @@ function ThreadState( likePending={!!props.likePendingByUri[focused().post.uri]} onBookmark={() => props.onBookmark(focused().post)} onLike={() => props.onLike(focused().post)} + onOpenEngagement={(tab) => props.onOpenEngagement(focused().post.uri, tab)} onOpenThread={() => props.onOpenPost(focused().post.uri)} onRepost={() => props.onRepost(focused().post)} post={focused().post} @@ -240,6 +244,7 @@ function ThreadState( node={reply} onBookmark={props.onBookmark} onLike={props.onLike} + onOpenEngagement={props.onOpenEngagement} onOpenPost={props.onOpenPost} onRepost={props.onRepost} repostPendingByUri={props.repostPendingByUri} /> @@ -261,6 +266,7 @@ function ThreadReplies( node: ThreadNode; onBookmark: (post: PostView) => void; onLike: (post: PostView) => void; + onOpenEngagement: (uri: string, tab: "likes" | "reposts" | "quotes") => void; onOpenPost: (uri: string) => void; onRepost: (post: PostView) => void; repostPendingByUri: Record; @@ -284,6 +290,7 @@ function ThreadReplies( likePending={!!props.likePendingByUri[current().post.uri]} onBookmark={() => props.onBookmark(current().post)} onLike={() => props.onLike(current().post)} + onOpenEngagement={(tab) => props.onOpenEngagement(current().post.uri, tab)} onOpenThread={() => props.onOpenPost(current().post.uri)} onRepost={() => props.onRepost(current().post)} post={current().post} @@ -299,6 +306,7 @@ function ThreadReplies( node={reply} onBookmark={props.onBookmark} onLike={props.onLike} + onOpenEngagement={props.onOpenEngagement} onOpenPost={props.onOpenPost} onRepost={props.onRepost} repostPendingByUri={props.repostPendingByUri} /> diff --git a/src/components/posts/ThreadDrawer.tsx b/src/components/posts/ThreadDrawer.tsx index d54bd20..6940dcc 100644 --- a/src/components/posts/ThreadDrawer.tsx +++ b/src/components/posts/ThreadDrawer.tsx @@ -157,7 +157,7 @@ export function ThreadDrawer() { transition={{ duration: 0.22 }}> void postNavigation.openPost(uri)} + onMaximize={(uri) => void postNavigation.openPostScreen(uri)} parentThreadHref={parentThreadHref()} onClose={() => void threadOverlay.closeThread()} /> void interactions.toggleBookmark(post)} onLike={(post) => void interactions.toggleLike(post)} + onOpenEngagement={(uri, tab) => void postNavigation.openPostEngagement(uri, tab)} onOpenThread={(uri) => void threadOverlay.openThread(uri)} onRepost={(post) => void interactions.toggleRepost(post)} repostPendingByUri={interactions.repostPendingByUri()} @@ -189,6 +190,7 @@ function ThreadDrawerBody( loading: boolean; onBookmark: (post: PostView) => void; onLike: (post: PostView) => void; + onOpenEngagement: (uri: string, tab: "likes" | "reposts" | "quotes") => void; onOpenThread: (uri: string) => void; onRepost: (post: PostView) => void; repostPendingByUri: Record; @@ -218,6 +220,7 @@ function ThreadDrawerBody( node={props.thread!} onBookmark={props.onBookmark} onLike={props.onLike} + onOpenEngagement={props.onOpenEngagement} onOpenThread={props.onOpenThread} onRepost={props.onRepost} repostPendingByUri={props.repostPendingByUri} @@ -293,6 +296,7 @@ function ThreadNodeView( node: ThreadNode; onBookmark: (post: PostView) => void; onLike: (post: PostView) => void; + onOpenEngagement: (uri: string, tab: "likes" | "reposts" | "quotes") => void; onOpenThread: (uri: string) => void; onRepost: (post: PostView) => void; repostPendingByUri: Record; @@ -322,6 +326,7 @@ function ThreadNodeView( node={parent()} onBookmark={props.onBookmark} onLike={props.onLike} + onOpenEngagement={props.onOpenEngagement} onOpenThread={props.onOpenThread} onRepost={props.onRepost} repostPendingByUri={props.repostPendingByUri} @@ -336,6 +341,7 @@ function ThreadNodeView( likePending={!!props.likePendingByUri[threadNode().post.uri]} onBookmark={() => props.onBookmark(threadNode().post)} onLike={() => props.onLike(threadNode().post)} + onOpenEngagement={(tab) => props.onOpenEngagement(threadNode().post.uri, tab)} onOpenThread={() => props.onOpenThread(threadNode().post.uri)} onRepost={() => props.onRepost(threadNode().post)} post={threadNode().post} @@ -352,6 +358,7 @@ function ThreadNodeView( node={reply} onBookmark={props.onBookmark} onLike={props.onLike} + onOpenEngagement={props.onOpenEngagement} onOpenThread={props.onOpenThread} onRepost={props.onRepost} repostPendingByUri={props.repostPendingByUri} diff --git a/src/components/posts/usePostNavigation.test.ts b/src/components/posts/usePostNavigation.test.ts new file mode 100644 index 0000000..bb9bc50 --- /dev/null +++ b/src/components/posts/usePostNavigation.test.ts @@ -0,0 +1,66 @@ +import { createRoot } from "solid-js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { usePostNavigation } from "./usePostNavigation"; + +const navigateMock = vi.hoisted(() => vi.fn()); +const threadOverlayMock = vi.hoisted(() => ({ + buildThreadHref: vi.fn((uri: string | null) => (uri ? `/timeline?thread=${encodeURIComponent(uri)}` : "/timeline")), + closeThread: vi.fn(), + drawerEnabled: vi.fn(() => true), + openThread: vi.fn(), + threadUri: vi.fn(() => null), +})); + +vi.mock("@solidjs/router", () => ({ useNavigate: () => navigateMock })); +vi.mock("./useThreadOverlayNavigation", () => ({ useThreadOverlayNavigation: () => threadOverlayMock })); + +describe("usePostNavigation", () => { + beforeEach(() => { + navigateMock.mockReset(); + threadOverlayMock.buildThreadHref.mockClear(); + threadOverlayMock.openThread.mockClear(); + }); + + it("opens posts with thread overlay context", () => { + createRoot((dispose) => { + const navigation = usePostNavigation(); + void navigation.openPost("at://did:plc:alice/app.bsky.feed.post/1"); + dispose(); + }); + + expect(threadOverlayMock.openThread).toHaveBeenCalledWith("at://did:plc:alice/app.bsky.feed.post/1"); + expect(navigateMock).not.toHaveBeenCalled(); + }); + + it("opens full-screen post routes explicitly", () => { + createRoot((dispose) => { + const navigation = usePostNavigation(); + void navigation.openPostScreen("at://did:plc:alice/app.bsky.feed.post/2"); + dispose(); + }); + + expect(navigateMock).toHaveBeenCalledWith("/post/at%3A%2F%2Fdid%3Aplc%3Aalice%2Fapp.bsky.feed.post%2F2"); + }); + + it("builds and opens post engagement routes", () => { + createRoot((dispose) => { + const navigation = usePostNavigation(); + void navigation.openPostEngagement("at://did:plc:alice/app.bsky.feed.post/3", "quotes"); + dispose(); + }); + + expect(navigateMock).toHaveBeenCalledWith( + "/post/at%3A%2F%2Fdid%3Aplc%3Aalice%2Fapp.bsky.feed.post%2F3/engagement?tab=quotes", + ); + }); + + it("delegates href building to thread overlay routing", () => { + createRoot((dispose) => { + const navigation = usePostNavigation(); + navigation.buildPostHref("at://did:plc:alice/app.bsky.feed.post/4"); + dispose(); + }); + + expect(threadOverlayMock.buildThreadHref).toHaveBeenCalledWith("at://did:plc:alice/app.bsky.feed.post/4"); + }); +}); diff --git a/src/components/posts/usePostNavigation.ts b/src/components/posts/usePostNavigation.ts index bb61fee..4b3428d 100644 --- a/src/components/posts/usePostNavigation.ts +++ b/src/components/posts/usePostNavigation.ts @@ -1,14 +1,25 @@ import { TIMELINE_ROUTE } from "$/lib/feeds"; +import { buildPostEngagementRoute, type PostEngagementTab } from "$/lib/post-engagement-routes"; import { buildPostRoute } from "$/lib/post-routes"; import { useNavigate } from "@solidjs/router"; +import { useThreadOverlayNavigation } from "./useThreadOverlayNavigation"; export function usePostNavigation() { const navigate = useNavigate(); + const threadOverlay = useThreadOverlayNavigation(); function openPost(uri: string) { + return threadOverlay.openThread(uri); + } + + function openPostScreen(uri: string) { return navigate(buildPostRoute(uri)); } + function openPostEngagement(uri: string, tab: PostEngagementTab) { + return navigate(buildPostEngagementRoute(uri, tab)); + } + function backFromPost() { if (globalThis.history.length > 1) { return navigate(-1); @@ -17,5 +28,5 @@ export function usePostNavigation() { return navigate(TIMELINE_ROUTE); } - return { backFromPost, buildPostHref: buildPostRoute, openPost }; + return { backFromPost, buildPostHref: threadOverlay.buildThreadHref, openPost, openPostEngagement, openPostScreen }; } diff --git a/src/components/profile/ProfileFeed.tsx b/src/components/profile/ProfileFeed.tsx index 2b77ca4..66455f7 100644 --- a/src/components/profile/ProfileFeed.tsx +++ b/src/components/profile/ProfileFeed.tsx @@ -1,3 +1,4 @@ +import type { PostEngagementTab } from "$/lib/post-engagement-routes"; import type { ProfileTab } from "$/lib/profile"; import type { FeedViewPost, PostView } from "$/lib/types"; import { For, Match, Show, Switch } from "solid-js"; @@ -11,6 +12,7 @@ function ProfilePostList( items: FeedViewPost[]; likePendingByUri: Record; onBookmark: (post: PostView) => void; + onOpenEngagement: (uri: string, tab: PostEngagementTab) => void; onLike: (post: PostView) => void; onOpenThread: (uri: string) => void; onRepost: (post: PostView) => void; @@ -26,6 +28,7 @@ function ProfilePostList( likePending={!!props.likePendingByUri[item.post.uri]} onBookmark={() => props.onBookmark(item.post)} onLike={() => props.onLike(item.post)} + onOpenEngagement={(tab) => props.onOpenEngagement(item.post.uri, tab)} post={item.post} item={item} onOpenThread={() => props.onOpenThread(item.post.uri)} @@ -97,6 +100,7 @@ export function ProfileFeedSection( loading: boolean; loadingMore: boolean; onBookmark: (post: PostView) => void; + onOpenEngagement: (uri: string, tab: PostEngagementTab) => void; onLike: (post: PostView) => void; onLoadMore: () => void; onOpenThread: (uri: string) => void; @@ -120,6 +124,7 @@ export function ProfileFeedSection( items={props.items} likePendingByUri={props.likePendingByUri} onBookmark={props.onBookmark} + onOpenEngagement={props.onOpenEngagement} onLike={props.onLike} onOpenThread={props.onOpenThread} onRepost={props.onRepost} diff --git a/src/components/profile/ProfilePanel.tsx b/src/components/profile/ProfilePanel.tsx index 3645fe2..a561e4c 100644 --- a/src/components/profile/ProfilePanel.tsx +++ b/src/components/profile/ProfilePanel.tsx @@ -274,6 +274,10 @@ export function ProfilePanel(props: { actor: string | null; embedded?: boolean } void postNavigation.openPost(uri); } + function openEngagement(uri: string, tab: "likes" | "reposts" | "quotes") { + void postNavigation.openPostEngagement(uri, tab); + } + function openExplorerTarget(target: string) { queueExplorerTarget(target); void navigate("/explorer"); @@ -496,6 +500,7 @@ export function ProfilePanel(props: { actor: string | null; embedded?: boolean } onBookmark={(post) => void interactions.toggleBookmark(post)} onLike={(post) => void interactions.toggleLike(post)} onLoadMore={handleLoadMore} + onOpenEngagement={openEngagement} onOpenThread={openThread} onRepost={(post) => void interactions.toggleRepost(post)} repostPendingByUri={interactions.repostPendingByUri()} /> diff --git a/src/components/rail/AppRail.test.tsx b/src/components/rail/AppRail.test.tsx index da4ea49..a59f262 100644 --- a/src/components/rail/AppRail.test.tsx +++ b/src/components/rail/AppRail.test.tsx @@ -38,6 +38,25 @@ describe("AppRail", () => { expect(link).toHaveAttribute("href", "#/saved"); }); + it("tracks in-app history and enables back/forward controls", async () => { + renderRail(); + + const backButton = await screen.findByRole("button", { name: "Back" }); + const forwardButton = await screen.findByRole("button", { name: "Forward" }); + expect(backButton).toBeDisabled(); + expect(forwardButton).toBeDisabled(); + + fireEvent.click(screen.getByRole("link", { name: "Profile" })); + await waitFor(() => expect(globalThis.location.hash).toBe("#/profile")); + expect(backButton).toBeEnabled(); + expect(forwardButton).toBeDisabled(); + + fireEvent.click(backButton); + await waitFor(() => expect(globalThis.location.hash).toBe("#/timeline")); + expect(backButton).toBeDisabled(); + expect(forwardButton).toBeEnabled(); + }); + it("opens the support URL with the opener plugin", async () => { renderRail(); diff --git a/src/components/rail/AppRail.tsx b/src/components/rail/AppRail.tsx index 9087dd8..68a159c 100644 --- a/src/components/rail/AppRail.tsx +++ b/src/components/rail/AppRail.tsx @@ -1,32 +1,123 @@ import { useAppSession } from "$/contexts/app-session"; import { useAppShellUi } from "$/contexts/app-shell-ui"; +import { useLocation, useNavigate } from "@solidjs/router"; import { openUrl } from "@tauri-apps/plugin-opener"; -import { Show } from "solid-js"; +import { createEffect, createMemo, createSignal, Show } from "solid-js"; import { AccountSwitcher } from "../account/AccountSwitcher"; -import { RailFoldIcon } from "../shared/Icon"; +import { ArrowIcon, RailFoldIcon } from "../shared/Icon"; import { Wordmark } from "../Wordmark"; import { RailActionButton, RailButton } from "./AppRailButton"; -function RailHeader(props: { collapsed: boolean; onToggleCollapse: () => void }) { +function RailHeader( + props: { + canGoBack: boolean; + canGoForward: boolean; + collapsed: boolean; + onGoBack: () => void; + onGoForward: () => void; + onToggleCollapse: () => void; + }, +) { return ( -
- - -
+ <> +
+ + +
+ +
+
+
+ + + +
+ ); } +function useShellHistoryTracker() { + const location = useLocation(); + const navigate = useNavigate(); + const [entries, setEntries] = createSignal([]); + const [index, setIndex] = createSignal(-1); + const routeKey = createMemo(() => `${location.pathname}${location.search}`); + + createEffect(() => { + const key = routeKey(); + const stack = entries(); + const currentIndex = index(); + + if (stack.length === 0) { + setEntries([key]); + setIndex(0); + return; + } + + if (stack[currentIndex] === key) { + return; + } + + if (currentIndex > 0 && stack[currentIndex - 1] === key) { + setIndex(currentIndex - 1); + return; + } + + if (currentIndex < stack.length - 1 && stack[currentIndex + 1] === key) { + setIndex(currentIndex + 1); + return; + } + + const nextStack = [...stack.slice(0, currentIndex + 1), key]; + setEntries(nextStack); + setIndex(nextStack.length - 1); + }); + + const canGoBack = createMemo(() => index() > 0); + const canGoForward = createMemo(() => index() >= 0 && index() < entries().length - 1); + + function goBack() { + if (!canGoBack()) { + return; + } + + void navigate(-1); + } + + function goForward() { + if (!canGoForward()) { + return; + } + + void navigate(1); + } + + return { canGoBack, canGoForward, goBack, goForward }; +} + function RailNavigation(props: { collapsed: boolean; hasSession: boolean; unreadNotifications: number }) { return (
@@ -68,6 +159,7 @@ function RailSecondaryActions(props: { collapsed: boolean }) { export function AppRail() { const session = useAppSession(); const shell = useAppShellUi(); + const history = useShellHistoryTracker(); return (