diff --git a/src/components/posts/ThreadModal.test.tsx b/src/components/posts/ThreadModal.test.tsx
deleted file mode 100644
index 84b83ce..0000000
--- a/src/components/posts/ThreadModal.test.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-import { AppTestProviders } from "$/test/providers";
-import { HashRouter, Route } from "@solidjs/router";
-import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library";
-import { describe, expect, it, vi } from "vitest";
-import { ThreadModal } from "./ThreadModal";
-
-const invokeMock = vi.hoisted(() => vi.fn());
-
-vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock }));
-
-function createThreadPayload() {
- return {
- thread: {
- $type: "app.bsky.feed.defs#threadViewPost",
- post: {
- author: { did: "did:plc:alice", handle: "alice.test", displayName: "Alice" },
- cid: "cid-post",
- indexedAt: "2026-03-28T12:00:00.000Z",
- likeCount: 4,
- record: { createdAt: "2026-03-28T12:00:00.000Z", text: "Thread root" },
- replyCount: 2,
- repostCount: 1,
- uri: "at://did:plc:alice/app.bsky.feed.post/123",
- viewer: {},
- },
- replies: [],
- },
- };
-}
-
-describe("ThreadModal", () => {
- it("opens from the thread query param on top of the current route and closes without changing the base path", async () => {
- invokeMock.mockImplementation((command: string) => {
- if (command === "get_post_thread") {
- return Promise.resolve(createThreadPayload());
- }
-
- throw new Error(`unexpected invoke: ${command}`);
- });
-
- globalThis.location.hash = "#/profile/alice?thread=at%3A%2F%2Fdid%3Aplc%3Aalice%2Fapp.bsky.feed.post%2F123";
-
- render(() => (
-
-
- (
- <>
- profile underneath
-
- >
- )} />
-
-
- ));
-
- expect(await screen.findByText("Thread root")).toBeInTheDocument();
- expect(screen.getByTestId("profile-screen")).toBeInTheDocument();
-
- fireEvent.click(screen.getByRole("button", { name: "Close thread" }));
-
- await waitFor(() => expect(globalThis.location.hash).toBe("#/profile/alice"));
- expect(screen.queryByText("Thread root")).not.toBeInTheDocument();
- });
-});
diff --git a/src/components/posts/usePostNavigation.ts b/src/components/posts/usePostNavigation.ts
new file mode 100644
index 0000000..bb61fee
--- /dev/null
+++ b/src/components/posts/usePostNavigation.ts
@@ -0,0 +1,21 @@
+import { TIMELINE_ROUTE } from "$/lib/feeds";
+import { buildPostRoute } from "$/lib/post-routes";
+import { useNavigate } from "@solidjs/router";
+
+export function usePostNavigation() {
+ const navigate = useNavigate();
+
+ function openPost(uri: string) {
+ return navigate(buildPostRoute(uri));
+ }
+
+ function backFromPost() {
+ if (globalThis.history.length > 1) {
+ return navigate(-1);
+ }
+
+ return navigate(TIMELINE_ROUTE);
+ }
+
+ return { backFromPost, buildPostHref: buildPostRoute, openPost };
+}
diff --git a/src/components/posts/useThreadOverlayNavigation.test.ts b/src/components/posts/useThreadOverlayNavigation.test.ts
new file mode 100644
index 0000000..32ff206
--- /dev/null
+++ b/src/components/posts/useThreadOverlayNavigation.test.ts
@@ -0,0 +1,52 @@
+import { createRoot } from "solid-js";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { useThreadOverlayNavigation } from "./useThreadOverlayNavigation";
+
+const navigateMock = vi.hoisted(() => vi.fn());
+const locationState = vi.hoisted(() => ({ pathname: "/timeline", search: "" }));
+
+vi.mock("@solidjs/router", () => ({ useLocation: () => locationState, useNavigate: () => navigateMock }));
+
+describe("useThreadOverlayNavigation", () => {
+ beforeEach(() => {
+ navigateMock.mockReset();
+ locationState.pathname = "/timeline";
+ locationState.search = "";
+ });
+
+ it("opens threads in drawer mode on eligible routes", () => {
+ createRoot((dispose) => {
+ const overlay = useThreadOverlayNavigation();
+ void overlay.openThread("at://did:plc:alice/app.bsky.feed.post/1");
+ dispose();
+ });
+
+ expect(navigateMock).toHaveBeenCalledWith("/timeline?thread=at%3A%2F%2Fdid%3Aplc%3Aalice%2Fapp.bsky.feed.post%2F1");
+ });
+
+ it("opens threads on the dedicated post route for ineligible paths", () => {
+ locationState.pathname = "/search";
+ locationState.search = "?q=test";
+
+ createRoot((dispose) => {
+ const overlay = useThreadOverlayNavigation();
+ void overlay.openThread("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("closes drawer mode by removing only the thread query param", () => {
+ locationState.pathname = "/notifications";
+ locationState.search = "?foo=bar&thread=at%3A%2F%2Fdid%3Aplc%3Aalice%2Fapp.bsky.feed.post%2F3";
+
+ createRoot((dispose) => {
+ const overlay = useThreadOverlayNavigation();
+ void overlay.closeThread();
+ dispose();
+ });
+
+ expect(navigateMock).toHaveBeenCalledWith("/notifications?foo=bar");
+ });
+});
diff --git a/src/components/posts/useThreadOverlayNavigation.ts b/src/components/posts/useThreadOverlayNavigation.ts
index 738d651..7c008f7 100644
--- a/src/components/posts/useThreadOverlayNavigation.ts
+++ b/src/components/posts/useThreadOverlayNavigation.ts
@@ -1,24 +1,60 @@
-import { buildThreadOverlayRoute, getThreadOverlayUri } from "$/lib/feeds";
+import { buildThreadOverlayRoute, getThreadOverlayUri, TIMELINE_ROUTE } from "$/lib/feeds";
+import { buildPostRoute, decodePostRouteUri, isThreadDrawerPath, POST_ROUTE } from "$/lib/post-routes";
import { useLocation, useNavigate } from "@solidjs/router";
import { createMemo } from "solid-js";
export function useThreadOverlayNavigation() {
const location = useLocation();
const navigate = useNavigate();
+ const drawerEnabled = createMemo(() => isThreadDrawerPath(location.pathname));
+ const postRouteThreadUri = createMemo(() => {
+ const prefix = `${POST_ROUTE}/`;
+ if (!location.pathname.startsWith(prefix)) {
+ return null;
+ }
- const threadUri = createMemo(() => getThreadOverlayUri(location.search));
+ return decodePostRouteUri(location.pathname.slice(prefix.length));
+ });
+
+ const threadUri = createMemo(() => {
+ if (drawerEnabled()) {
+ return getThreadOverlayUri(location.search);
+ }
+
+ return postRouteThreadUri();
+ });
function openThread(uri: string) {
- return navigate(buildThreadOverlayRoute(location.pathname, location.search, uri));
- }
+ if (drawerEnabled()) {
+ return navigate(buildThreadOverlayRoute(location.pathname, location.search, uri));
+ }
- function closeThread() {
- return navigate(buildThreadOverlayRoute(location.pathname, location.search, null));
+ return navigate(buildPostRoute(uri));
}
function buildThreadHref(uri: string | null) {
- return buildThreadOverlayRoute(location.pathname, location.search, uri);
+ if (!uri) {
+ return TIMELINE_ROUTE;
+ }
+
+ if (drawerEnabled()) {
+ return buildThreadOverlayRoute(location.pathname, location.search, uri);
+ }
+
+ return buildPostRoute(uri);
+ }
+
+ function closeThread() {
+ if (drawerEnabled() || getThreadOverlayUri(location.search)) {
+ return navigate(buildThreadOverlayRoute(location.pathname, location.search, null));
+ }
+
+ if (globalThis.history.length > 1) {
+ return navigate(-1);
+ }
+
+ return navigate(TIMELINE_ROUTE);
}
- return { buildThreadHref, closeThread, openThread, threadUri };
+ return { buildThreadHref, closeThread, drawerEnabled, openThread, threadUri };
}
diff --git a/src/components/profile/ProfilePanel.test.tsx b/src/components/profile/ProfilePanel.test.tsx
index 87b7af9..74e564b 100644
--- a/src/components/profile/ProfilePanel.test.tsx
+++ b/src/components/profile/ProfilePanel.test.tsx
@@ -17,13 +17,10 @@ const getFollowsMock = vi.hoisted(() => vi.fn());
const getProfileMock = vi.hoisted(() => vi.fn());
const navigateMock = vi.hoisted(() => vi.fn());
const unfollowActorMock = vi.hoisted(() => vi.fn());
-const threadOverlayMock = vi.hoisted(() => ({
- buildThreadHref: vi.fn((
- uri: string | null,
- ) => (uri ? `/profile/bob.test?thread=${encodeURIComponent(uri)}` : "/profile/bob.test")),
- closeThread: vi.fn(),
- openThread: vi.fn(),
- threadUri: vi.fn(() => null),
+const postNavigationMock = vi.hoisted(() => ({
+ backFromPost: vi.fn(),
+ buildPostHref: vi.fn((uri: string | null) => (uri ? `/post/${encodeURIComponent(uri)}` : "/timeline")),
+ openPost: vi.fn(),
}));
vi.mock(
@@ -52,10 +49,7 @@ vi.mock(
);
vi.mock("@solidjs/router", () => ({ useNavigate: () => navigateMock }));
-vi.mock(
- "$/components/posts/useThreadOverlayNavigation",
- () => ({ useThreadOverlayNavigation: () => threadOverlayMock }),
-);
+vi.mock("$/components/posts/usePostNavigation", () => ({ usePostNavigation: () => postNavigationMock }));
function deferred
() {
let resolve!: (value: T) => void;
@@ -148,10 +142,11 @@ describe("ProfilePanel", () => {
await waitFor(() => {
expect(followActorMock).toHaveBeenCalledWith("did:plc:bob");
- expect(getHeroFollowingButton()).toBeDefined();
- expect(screen.getAllByText("Following").length).toBeGreaterThanOrEqual(2);
expect(followersStat).toHaveTextContent("9");
});
+ const heroFollowingButton = getHeroFollowingButton();
+ expect(heroFollowingButton).toBeDefined();
+ expect(heroFollowingButton?.textContent).toContain("Following");
followRequest.resolve({ cid: "cid-follow", uri: "at://did:plc:alice/app.bsky.graph.follow/1" });
diff --git a/src/components/profile/ProfilePanel.tsx b/src/components/profile/ProfilePanel.tsx
index b7f5f3e..3645fe2 100644
--- a/src/components/profile/ProfilePanel.tsx
+++ b/src/components/profile/ProfilePanel.tsx
@@ -1,6 +1,6 @@
import { DiagnosticsPanel } from "$/components/deck/DiagnosticsPanel";
import { usePostInteractions } from "$/components/posts/usePostInteractions";
-import { useThreadOverlayNavigation } from "$/components/posts/useThreadOverlayNavigation";
+import { usePostNavigation } from "$/components/posts/usePostNavigation";
import { ProfileSkeleton } from "$/components/ProfileSkeleton";
import { useAppSession } from "$/contexts/app-session";
import {
@@ -43,7 +43,7 @@ const PROFILE_TABS: ProfileTab[] = ["posts", "replies", "media", "likes", "conte
export function ProfilePanel(props: { actor: string | null; embedded?: boolean }) {
const navigate = useNavigate();
const session = useAppSession();
- const threadOverlay = useThreadOverlayNavigation();
+ const postNavigation = usePostNavigation();
const [state, setState] = createStore(createProfilePanelState());
const [heroHeight, setHeroHeight] = createSignal(null);
let requestSequence = 0;
@@ -72,7 +72,7 @@ export function ProfilePanel(props: { actor: string | null; embedded?: boolean }
const showCompactHeader = createMemo(() => state.scrollTop >= compactHeaderThreshold());
const pinnedPostHref = createMemo(() => {
const uri = activeProfile()?.pinnedPost?.uri;
- return uri ? threadOverlay.buildThreadHref(uri) : null;
+ return uri ? postNavigation.buildPostHref(uri) : null;
});
const profileBadges = createMemo(() => {
const profile = activeProfile();
@@ -271,7 +271,7 @@ export function ProfilePanel(props: { actor: string | null; embedded?: boolean }
}
function openThread(uri: string) {
- void threadOverlay.openThread(uri);
+ void postNavigation.openPost(uri);
}
function openExplorerTarget(target: string) {
diff --git a/src/components/saved/SavedPostsPanel.test.tsx b/src/components/saved/SavedPostsPanel.test.tsx
index 0aac3b7..cff8edf 100644
--- a/src/components/saved/SavedPostsPanel.test.tsx
+++ b/src/components/saved/SavedPostsPanel.test.tsx
@@ -8,7 +8,7 @@ const getSyncStatusMock = vi.hoisted(() => vi.fn());
const listSavedPostsMock = vi.hoisted(() => vi.fn());
const syncPostsMock = vi.hoisted(() => vi.fn());
const loggerErrorMock = vi.hoisted(() => vi.fn());
-const threadOverlayMock = vi.hoisted(() => ({ openThread: vi.fn() }));
+const postNavigationMock = vi.hoisted(() => ({ backFromPost: vi.fn(), buildPostHref: vi.fn(), openPost: vi.fn() }));
vi.mock(
"$/lib/api/search",
@@ -20,10 +20,7 @@ vi.mock(
},
}),
);
-vi.mock(
- "$/components/posts/useThreadOverlayNavigation",
- () => ({ useThreadOverlayNavigation: () => threadOverlayMock }),
-);
+vi.mock("$/components/posts/usePostNavigation", () => ({ usePostNavigation: () => postNavigationMock }));
vi.mock("@tauri-apps/plugin-log", () => ({ error: loggerErrorMock }));
function createStatus(source: "bookmark" | "like", count: number) {
diff --git a/src/components/saved/SavedPostsPanel.tsx b/src/components/saved/SavedPostsPanel.tsx
index bc70585..3690e7a 100644
--- a/src/components/saved/SavedPostsPanel.tsx
+++ b/src/components/saved/SavedPostsPanel.tsx
@@ -1,4 +1,4 @@
-import { useThreadOverlayNavigation } from "$/components/posts/useThreadOverlayNavigation";
+import { usePostNavigation } from "$/components/posts/usePostNavigation";
import { LocalPostResultsList, LocalPostResultsSkeletons } from "$/components/search/LocalPostResultsList";
import { SearchEmptyState } from "$/components/search/SearchEmptyState";
import { SearchQueryInput } from "$/components/search/SearchQueryInput";
@@ -16,9 +16,11 @@ import { createStore } from "solid-js/store";
import { Motion, Presence } from "solid-motionone";
const PAGE_SIZE = 50;
+
const SEARCH_DEBOUNCE_MS = 300;
type TabKey = SavedPostSource;
+
type TabState = {
error: string | null;
items: LocalPostResult[];
@@ -116,7 +118,7 @@ function SavedPostsMessage(props: { body: string; title: string }) {
export function SavedPostsPanel() {
const session = useAppSession();
- const threadOverlay = useThreadOverlayNavigation();
+ const postNavigation = usePostNavigation();
const [activeTab, setActiveTab] = createSignal("bookmark");
const [state, setState] = createStore(createPanelState());
const browseRequestIds: Record = { bookmark: 0, like: 0 };
@@ -403,7 +405,7 @@ export function SavedPostsPanel() {
void threadOverlay.openThread(uri)}
+ onOpenThread={(uri) => void postNavigation.openPost(uri)}
query={trimmedQuery()}
searching={isSearching()}
searchingState={activeSearchState()}
diff --git a/src/components/search/HashtagPanel.test.tsx b/src/components/search/HashtagPanel.test.tsx
index 0b71223..d2244bd 100644
--- a/src/components/search/HashtagPanel.test.tsx
+++ b/src/components/search/HashtagPanel.test.tsx
@@ -6,13 +6,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { HashtagPanel } from "./HashtagPanel";
const searchPostsNetworkMock = vi.hoisted(() => vi.fn());
-const threadOverlayMock = vi.hoisted(() => ({ openThread: vi.fn() }));
+const postNavigationMock = vi.hoisted(() => ({ backFromPost: vi.fn(), buildPostHref: vi.fn(), openPost: vi.fn() }));
vi.mock("$/lib/api/search", () => ({ SearchController: { searchPostsNetwork: searchPostsNetworkMock } }));
-vi.mock(
- "$/components/posts/useThreadOverlayNavigation",
- () => ({ useThreadOverlayNavigation: () => threadOverlayMock }),
-);
+vi.mock("$/components/posts/usePostNavigation", () => ({ usePostNavigation: () => postNavigationMock }));
vi.mock("@tauri-apps/plugin-log", () => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn() }));
async function flushRouter() {
@@ -36,7 +33,7 @@ describe("HashtagPanel", () => {
beforeEach(() => {
vi.useFakeTimers();
searchPostsNetworkMock.mockReset();
- threadOverlayMock.openThread.mockReset();
+ postNavigationMock.openPost.mockReset();
searchPostsNetworkMock.mockResolvedValue({ posts: [] });
});
diff --git a/src/components/search/HashtagPanel.tsx b/src/components/search/HashtagPanel.tsx
index cda285e..59ff98a 100644
--- a/src/components/search/HashtagPanel.tsx
+++ b/src/components/search/HashtagPanel.tsx
@@ -1,5 +1,5 @@
import { PostCard } from "$/components/feeds/PostCard";
-import { useThreadOverlayNavigation } from "$/components/posts/useThreadOverlayNavigation";
+import { usePostNavigation } from "$/components/posts/usePostNavigation";
import { Icon } from "$/components/shared/Icon";
import { SearchController } from "$/lib/api/search";
import type { NetworkSearchResult } from "$/lib/api/types/search";
@@ -34,7 +34,7 @@ export function HashtagPanel() {
const location = useLocation();
const navigate = useNavigate();
const params = useParams<{ hashtag: string }>();
- const threadOverlay = useThreadOverlayNavigation();
+ const postNavigation = usePostNavigation();
const [state, setState] = createStore({
error: null,
hasSearched: false,
@@ -105,7 +105,7 @@ export function HashtagPanel() {
-
}>
+
}>
{() => }
diff --git a/src/components/search/SearchPanel.test.tsx b/src/components/search/SearchPanel.test.tsx
index cec726e..403dcfa 100644
--- a/src/components/search/SearchPanel.test.tsx
+++ b/src/components/search/SearchPanel.test.tsx
@@ -11,7 +11,7 @@ const searchPostsMock = vi.hoisted(() => vi.fn());
const searchPostsNetworkMock = vi.hoisted(() => vi.fn());
const getSyncStatusMock = vi.hoisted(() => vi.fn());
const syncPostsMock = vi.hoisted(() => vi.fn());
-const threadOverlayMock = vi.hoisted(() => ({ openThread: vi.fn() }));
+const postNavigationMock = vi.hoisted(() => ({ backFromPost: vi.fn(), buildPostHref: vi.fn(), openPost: vi.fn() }));
vi.mock(
"$/lib/api/search",
@@ -26,10 +26,7 @@ vi.mock(
}),
);
vi.mock("$/lib/api/actors", () => ({ searchActorSuggestions: searchActorSuggestionsMock }));
-vi.mock(
- "$/components/posts/useThreadOverlayNavigation",
- () => ({ useThreadOverlayNavigation: () => threadOverlayMock }),
-);
+vi.mock("$/components/posts/usePostNavigation", () => ({ usePostNavigation: () => postNavigationMock }));
vi.mock("@tauri-apps/plugin-log", () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn() }));
@@ -60,7 +57,7 @@ describe("SearchPanel", () => {
searchPostsNetworkMock.mockReset();
getSyncStatusMock.mockReset();
syncPostsMock.mockReset();
- threadOverlayMock.openThread.mockReset();
+ postNavigationMock.openPost.mockReset();
getSyncStatusMock.mockResolvedValue([]);
searchActorSuggestionsMock.mockResolvedValue([]);
diff --git a/src/components/search/SearchPanel.tsx b/src/components/search/SearchPanel.tsx
index 84a2e6a..49c1dab 100644
--- a/src/components/search/SearchPanel.tsx
+++ b/src/components/search/SearchPanel.tsx
@@ -1,7 +1,7 @@
import { ActorSuggestionList, getActorSuggestionHeadline, useActorSuggestions } from "$/components/actors/actor-search";
import { AvatarBadge } from "$/components/AvatarBadge";
import { PostCard } from "$/components/feeds/PostCard";
-import { useThreadOverlayNavigation } from "$/components/posts/useThreadOverlayNavigation";
+import { usePostNavigation } from "$/components/posts/usePostNavigation";
import { Icon, SearchModeIcon } from "$/components/shared/Icon";
import { useAppPreferences } from "$/contexts/app-preferences";
import { useAppSession } from "$/contexts/app-session";
@@ -36,6 +36,7 @@ import { SyncStatusPanel } from "./SyncStatusPanel";
import type { EmptyStateReason } from "./types";
const MODES: SearchMode[] = ["network", "keyword", "semantic", "hybrid"];
+
const SEARCH_TABS: SearchTab[] = ["posts", "profiles"];
type SearchPanelState = {
@@ -52,15 +53,27 @@ type SearchPanelState = {
type SearchPanelProps = { embedded?: boolean; initialMode?: SearchMode; initialQuery?: string };
function ModeLabel(props: { mode: SearchMode }) {
+ const text = createMemo(() => {
+ switch (props.mode) {
+ case "network": {
+ return "Network";
+ }
+ case "keyword": {
+ return "Keyword";
+ }
+ case "semantic": {
+ return "Semantic";
+ }
+ case "hybrid": {
+ return "Hybrid";
+ }
+ }
+ });
+
return (
-
- Network
- Keyword
- Semantic
- Hybrid
-
+ {text()}
);
}
@@ -70,7 +83,7 @@ export function SearchPanel(props: SearchPanelProps = {}) {
const navigate = useNavigate();
const preferences = useAppPreferences();
const session = useAppSession();
- const threadOverlay = useThreadOverlayNavigation();
+ const postNavigation = usePostNavigation();
const [search, setSearch] = createStore({
actorResults: null,
error: null,
@@ -99,6 +112,7 @@ export function SearchPanel(props: SearchPanelProps = {}) {
return parsed;
});
+
const actorSuggestions = useActorSuggestions({
container: () => actorSearchContainerRef,
disabled: () => routeState().tab !== "profiles",
@@ -107,15 +121,18 @@ export function SearchPanel(props: SearchPanelProps = {}) {
logger.warn("failed to load actor search suggestions", { keyValues: { error: normalizeError(error) } }),
value: () => routeState().q,
});
+
const isActorTab = createMemo(() => routeState().tab === "profiles");
const isLocalMode = createMemo(() => routeState().tab === "posts" && routeState().mode !== "network");
const networkFiltersEnabled = createMemo(() => routeState().tab === "posts" && routeState().mode === "network");
const semanticEnabled = createMemo(() =>
!!preferences.embeddingsConfig?.enabled && !!preferences.embeddingsConfig?.downloaded
);
+
const totalIndexedPosts = createMemo(() =>
search.syncStatus.reduce((sum, status) => sum + (status.postCount ?? 0), 0)
);
+
const hasLocalPosts = createMemo(() => totalIndexedPosts() > 0);
const lastSync = createMemo(() => {
const timestamps = search.syncStatus.map((status) => status.lastSyncedAt).filter(Boolean) as string[];
@@ -125,6 +142,7 @@ export function SearchPanel(props: SearchPanelProps = {}) {
return formatRelativeTime(timestamps.toSorted((left, right) => right.localeCompare(left))[0]);
});
+
const cycleModes = createMemo(() =>
MODES.filter((candidate) => semanticEnabled() || (candidate !== "semantic" && candidate !== "hybrid"))
);
@@ -416,7 +434,7 @@ export function SearchPanel(props: SearchPanelProps = {}) {
localResults={search.results}
networkResults={search.networkResults}
onOpenActor={openActor}
- onOpenThread={(uri) => void threadOverlay.openThread(uri)}
+ onOpenThread={(uri) => void postNavigation.openPost(uri)}
query={routeState().q} />
@@ -599,16 +617,16 @@ function SearchTabSelector(props: { activeTab: SearchTab; onTabChange: (tab: Sea
);
}
-function ResultMeta(
- props: {
- hasSearched: boolean;
- isActorTab: boolean;
- lastSync: string | null;
- mode: SearchMode;
- resultCount: number;
- totalIndexedPosts: number;
- },
-) {
+type ResultMetaProps = {
+ hasSearched: boolean;
+ isActorTab: boolean;
+ lastSync: string | null;
+ mode: SearchMode;
+ resultCount: number;
+ totalIndexedPosts: number;
+};
+
+function ResultMeta(props: ResultMetaProps) {
return (
diff --git a/src/components/shared/QuotedPostPreview.tsx b/src/components/shared/QuotedPostPreview.tsx
index 98cc6e0..0256335 100644
--- a/src/components/shared/QuotedPostPreview.tsx
+++ b/src/components/shared/QuotedPostPreview.tsx
@@ -3,31 +3,71 @@ import type { ProfileViewBasic } from "$/lib/types";
import { formatHandle } from "$/lib/utils/text";
import { createMemo, Show } from "solid-js";
-export function QuotedPostPreview(
- props: { author: ProfileViewBasic | null; class?: string; href?: string | null; text?: unknown; title: string },
-) {
+function QuotedText(props: { text: string; truncated: boolean }) {
+ return (
+
+ {props.text}
+
+ }>
+ {props.text}
+
+ );
+}
+
+type QuotedPostPreviewProps = {
+ author: ProfileViewBasic | null;
+ class?: string;
+ href?: string | null;
+ onOpenPost?: () => void;
+ text?: unknown;
+ title: string;
+ truncate?: boolean;
+};
+
+export function QuotedPostPreview(props: QuotedPostPreviewProps) {
const preview = createMemo(() => (typeof props.text === "string" ? props.text : ""));
+ const openInNewTab = createMemo(() => !!props.href && /^https?:\/\//i.test(props.href));
+ const truncated = createMemo(() => props.truncate ?? false);
return (
);
}
-function QuotedPreviewContent(props: { author: ProfileViewBasic | null; preview: string }) {
+function QuotedPreviewContent(props: { author: ProfileViewBasic | null; preview: string; truncated: boolean }) {
return (
<>
@@ -43,7 +83,7 @@ function QuotedPreviewContent(props: { author: ProfileViewBasic | null; preview:
Quoted post}>
- {(text) => {text()}
}
+ {(text) => }
>
);
diff --git a/src/lib/feeds.ts b/src/lib/feeds.ts
index 148e35e..e2325bb 100644
--- a/src/lib/feeds.ts
+++ b/src/lib/feeds.ts
@@ -198,6 +198,18 @@ export function isReplyItem(item: FeedViewPost) {
return !!asRecord(record?.reply);
}
+export function hasKnownThreadContext(post: PostView, item?: FeedViewPost) {
+ if (item && isReplyItem(item)) {
+ return true;
+ }
+
+ if (asRecord(asRecord(post.record)?.reply)) {
+ return true;
+ }
+
+ return typeof post.replyCount === "number" && post.replyCount > 0;
+}
+
function isReplyByUnfollowed(item: FeedViewPost) {
return isReplyItem(item) && !item.post.author.viewer?.following;
}
@@ -309,6 +321,11 @@ export function getQuotedAuthor(embed: Maybe) {
return getQuotedRecord(embed)?.author ?? null;
}
+export function getQuotedUri(embed: Maybe) {
+ const uri = getQuotedRecord(embed)?.uri;
+ return typeof uri === "string" && uri.trim() ? uri : null;
+}
+
export function getQuotedHref(embed: Maybe) {
const record = getQuotedRecord(embed);
return buildPublicPostHref(record?.author ?? null, record?.uri);
diff --git a/src/lib/post-routes.test.ts b/src/lib/post-routes.test.ts
new file mode 100644
index 0000000..8bbd0a1
--- /dev/null
+++ b/src/lib/post-routes.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from "vitest";
+import { buildPostRoute, decodePostRouteUri, isThreadDrawerPath } from "./post-routes";
+
+describe("post-routes", () => {
+ it("builds a canonical encoded post route", () => {
+ const uri = "at://did:plc:alice/app.bsky.feed.post/abc123";
+ expect(buildPostRoute(uri)).toBe("/post/at%3A%2F%2Fdid%3Aplc%3Aalice%2Fapp.bsky.feed.post%2Fabc123");
+ });
+
+ it("decodes valid encoded at:// URIs", () => {
+ expect(decodePostRouteUri("at%3A%2F%2Fdid%3Aplc%3Aalice%2Fapp.bsky.feed.post%2Fabc123")).toBe(
+ "at://did:plc:alice/app.bsky.feed.post/abc123",
+ );
+ });
+
+ it("rejects malformed or non-at URI values", () => {
+ expect(decodePostRouteUri("not-a-uri")).toBeNull();
+ expect(decodePostRouteUri("%E0%A4%A")).toBeNull();
+ expect(decodePostRouteUri(null)).toBeNull();
+ expect(decodePostRouteUri()).toBeNull();
+ });
+
+ it("marks only feed, notifications, and deck as drawer routes", () => {
+ expect(isThreadDrawerPath("/timeline")).toBe(true);
+ expect(isThreadDrawerPath("/notifications")).toBe(true);
+ expect(isThreadDrawerPath("/deck")).toBe(true);
+ expect(isThreadDrawerPath("/profile/alice")).toBe(false);
+ expect(isThreadDrawerPath("/search")).toBe(false);
+ });
+});
diff --git a/src/lib/post-routes.ts b/src/lib/post-routes.ts
new file mode 100644
index 0000000..3c9f5b6
--- /dev/null
+++ b/src/lib/post-routes.ts
@@ -0,0 +1,23 @@
+export const POST_ROUTE = "/post";
+export const THREAD_DRAWER_PATHS = ["/timeline", "/notifications", "/deck"] as const;
+
+export function buildPostRoute(uri: string) {
+ return `${POST_ROUTE}/${encodeURIComponent(uri)}`;
+}
+
+export function isThreadDrawerPath(pathname: string): pathname is (typeof THREAD_DRAWER_PATHS)[number] {
+ return (THREAD_DRAWER_PATHS as readonly string[]).includes(pathname);
+}
+
+export function decodePostRouteUri(value?: string | null) {
+ if (!value) {
+ return null;
+ }
+
+ try {
+ const decoded = decodeURIComponent(value);
+ return decoded.startsWith("at://") ? decoded : null;
+ } catch {
+ return null;
+ }
+}
diff --git a/src/router.test.tsx b/src/router.test.tsx
index 2fe35af..c4dd7e2 100644
--- a/src/router.test.tsx
+++ b/src/router.test.tsx
@@ -31,6 +31,7 @@ function renderRouter(hash: string, options: { preferences?: Record composer
);
const renderNotifications = vi.fn(() => notifications
);
+ const renderPost = vi.fn((props: { uri: string | null }) => {props.uri ?? "none"}
);
const renderProfile = vi.fn((props: { actor: string | null }) => (
{props.actor ?? "self-profile"}
@@ -55,13 +56,14 @@ function renderRouter(hash: string, options: { preferences?: Record
));
- return { renderComposer, renderMessages, renderNotifications, renderProfile, renderTimeline };
+ return { renderComposer, renderMessages, renderNotifications, renderPost, renderProfile, renderTimeline };
}
describe("AppRouter", () => {
@@ -108,6 +110,16 @@ describe("AppRouter", () => {
expect(screen.getByTestId("shell")).toHaveAttribute("data-full-width", "false");
});
+ it("renders decoded post routes inside the protected shell", async () => {
+ const uri = "at://did:plc:alice/app.bsky.feed.post/123";
+ const { renderPost } = renderRouter(`#/post/${encodeURIComponent(uri)}`);
+
+ await screen.findByTestId("post-view");
+
+ expect(renderPost.mock.lastCall?.[0].uri).toBe(uri);
+ expect(screen.getByTestId("shell")).toHaveAttribute("data-full-width", "false");
+ });
+
it("renders the saved posts route inside the protected shell", async () => {
renderRouter("#/saved");
diff --git a/src/router.tsx b/src/router.tsx
index ccd206b..e5fdd02 100644
--- a/src/router.tsx
+++ b/src/router.tsx
@@ -14,10 +14,12 @@ import { SearchPreflightPanel } from "./components/search/SearchPreflightPanel";
import { SettingsPanel } from "./components/settings/SettingsPanel";
import { decodeMessagesRouteMemberDid } from "./lib/conversations";
import { TIMELINE_ROUTE } from "./lib/feeds";
+import { decodePostRouteUri } from "./lib/post-routes";
import { decodeProfileRouteActor } from "./lib/profile";
import { buildSearchPreflightRoute, decodeHashtagRouteTag, parseSearchRouteState } from "./lib/search-routes";
type TMessagesRouteProps = { memberDid: string | null };
+type TPostRouteProps = { uri: string | null };
type TProfileRouteProps = { actor: string | null };
type AppShellProps = ParentProps<{ fullWidth?: boolean }>;
@@ -27,6 +29,7 @@ type AppRouterProps = {
renderComposer: () => JSX.Element;
renderMessages: Component
;
renderNotifications: () => JSX.Element;
+ renderPost: Component;
renderProfile: Component;
renderShell: Component;
renderTimeline: () => JSX.Element;
@@ -100,6 +103,16 @@ export function AppRouter(props: AppRouterProps) {
const NotificationsRoute = () => {props.renderNotifications()};
+ const PostRoute = () => {
+ const params = useParams<{ encodedUri: string }>();
+
+ return (
+
+
+
+ );
+ };
+
const HashtagRoute = () => {
const params = useParams<{ hashtag: string }>();
const tag = decodeHashtagRouteTag(params.hashtag);
@@ -174,6 +187,7 @@ export function AppRouter(props: AppRouterProps) {
+