BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: update error logging and add sticky header to profile view

+298 -112
+11 -1
src-tauri/src/error.rs
··· 1 + use tauri_plugin_log::log; 2 + 1 3 pub type Result<T> = std::result::Result<T, AppError>; 2 4 3 5 #[derive(Debug, Clone, Copy, PartialEq, Eq)] ··· 93 95 94 96 impl AppError { 95 97 pub fn validation(msg: impl Into<String>) -> Self { 96 - AppError::Validation(msg.into()) 98 + let msg = msg.into(); 99 + log::error!("validation error: {}", &msg); 100 + AppError::Validation(msg) 101 + } 102 + 103 + pub fn state_poisoned(msg: impl Into<String>) -> Self { 104 + let msg = msg.into(); 105 + log::error!("state lock poisoned: {}", msg); 106 + AppError::StatePoisoned(Box::leak(msg.into_boxed_str())) 97 107 } 98 108 }
+39 -30
src-tauri/src/feed.rs
··· 62 62 state 63 63 .sessions 64 64 .read() 65 - .map_err(|error| { 66 - log::error!("sessions poisoned: {error}"); 67 - AppError::StatePoisoned("sessions") 68 - })? 65 + .map_err(|error| AppError::state_poisoned(format!("sessions {error}")))? 69 66 .get(&did) 70 67 .cloned() 71 - .ok_or_else(|| { 72 - log::error!("session not found for active account"); 73 - AppError::Validation("session not found for active account".into()) 74 - }) 68 + .ok_or_else(|| AppError::validation(format!("session not found for active account {did}"))) 75 69 } 76 70 77 71 fn active_did(state: &AppState) -> Result<String> { 78 72 state 79 73 .active_session 80 74 .read() 81 - .map_err(|error| { 82 - log::error!("active_session poisoned: {error}"); 83 - AppError::StatePoisoned("active_session") 84 - })? 75 + .map_err(|error| AppError::state_poisoned(format!("active_session poisoned with error {error}")))? 85 76 .as_ref() 86 - .ok_or_else(|| { 87 - log::error!("no active account"); 88 - AppError::Validation("no active account".into()) 89 - }) 77 + .ok_or_else(|| AppError::Validation("no active account".into())) 90 78 .map(|s| s.did.clone()) 91 79 } 92 80 ··· 203 191 } 204 192 205 193 fn accepts_empty_put_preferences_response(status: reqwest::StatusCode, body: &[u8]) -> bool { 194 + status.is_success() && body.is_empty() 195 + } 196 + 197 + fn accepts_empty_bookmark_response(status: reqwest::StatusCode, body: &[u8]) -> bool { 206 198 status.is_success() && body.is_empty() 207 199 } 208 200 ··· 719 711 let session = get_session(state).await?; 720 712 let post_uri = AtUri::new(&uri).map_err(|_| AppError::validation("invalid post URI"))?; 721 713 722 - session 714 + let response = session 723 715 .send(CreateBookmark::new().uri(post_uri).cid(Cid::str(&cid)).build()) 724 716 .await 725 717 .map_err(|error| { 726 718 log::error!("createBookmark error: {error}"); 727 719 AppError::validation("Could not save this post.") 728 - })? 729 - .into_output() 730 - .map_err(|error| { 731 - log::error!("createBookmark output error: {error}"); 732 - AppError::validation("Could not save this post.") 733 720 })?; 734 721 722 + // Bluesky may return a 200 with no body for bookmark writes. jacquard's default 723 + // unit decoder still attempts to parse JSON, which raises an EOF on success. 724 + if accepts_empty_bookmark_response(response.status(), response.buffer()) { 725 + return Ok(()); 726 + } 727 + 728 + response.into_output().map_err(|error| { 729 + log::error!("createBookmark output error: {error}"); 730 + AppError::validation("Could not save this post.") 731 + })?; 732 + 735 733 Ok(()) 736 734 } 737 735 ··· 739 737 let session = get_session(state).await?; 740 738 let post_uri = AtUri::new(&uri).map_err(|_| AppError::validation("invalid post URI"))?; 741 739 742 - session 740 + let response = session 743 741 .send(DeleteBookmark::new().uri(post_uri).build()) 744 742 .await 745 743 .map_err(|error| { 746 744 log::error!("deleteBookmark error: {error}"); 747 745 AppError::validation("Could not remove this saved post.") 748 - })? 749 - .into_output() 750 - .map_err(|error| { 751 - log::error!("deleteBookmark output error: {error}"); 752 - AppError::validation("Could not remove this saved post.") 753 746 })?; 747 + 748 + if accepts_empty_bookmark_response(response.status(), response.buffer()) { 749 + return Ok(()); 750 + } 751 + 752 + response.into_output().map_err(|error| { 753 + log::error!("deleteBookmark output error: {error}"); 754 + AppError::validation("Could not remove this saved post.") 755 + })?; 754 756 755 757 Ok(()) 756 758 } ··· 916 918 #[cfg(test)] 917 919 mod tests { 918 920 use super::{ 919 - accepts_empty_put_preferences_response, merge_feed_view_preferences, merge_saved_feeds_preferences, 920 - user_preferences_from_items, FeedViewPrefItem, SavedFeedItem, 921 + accepts_empty_bookmark_response, accepts_empty_put_preferences_response, merge_feed_view_preferences, 922 + merge_saved_feeds_preferences, user_preferences_from_items, FeedViewPrefItem, SavedFeedItem, 921 923 }; 922 924 use jacquard::api::app_bsky::actor::{AdultContentPref, FeedViewPref, PreferencesItem}; 923 925 use reqwest::StatusCode; ··· 1009 1011 assert!(accepts_empty_put_preferences_response(StatusCode::OK, b"")); 1010 1012 assert!(!accepts_empty_put_preferences_response(StatusCode::OK, b"null")); 1011 1013 assert!(!accepts_empty_put_preferences_response(StatusCode::BAD_REQUEST, b"")); 1014 + } 1015 + 1016 + #[test] 1017 + fn empty_success_bookmark_response_is_treated_as_valid() { 1018 + assert!(accepts_empty_bookmark_response(StatusCode::OK, b"")); 1019 + assert!(!accepts_empty_bookmark_response(StatusCode::OK, b"{}")); 1020 + assert!(!accepts_empty_bookmark_response(StatusCode::BAD_REQUEST, b"")); 1012 1021 } 1013 1022 }
+74 -40
src/components/profile/ProfileHero.tsx
··· 55 55 ); 56 56 } 57 57 58 - function StickyIdentity(props: { displayName: string; handle: string; progress: number }) { 59 - const style = createMemo(() => ({ opacity: `${Math.min(1, props.progress * 1.35)}` })); 60 - 61 - return ( 62 - <div class="mb-1 min-w-0 transition-opacity duration-100 ease-out" style={style()}> 63 - <p class="m-0 truncate text-lg font-semibold leading-tight tracking-[-0.02em] text-on-surface"> 64 - {props.displayName} 65 - </p> 66 - <p class="m-0 truncate text-sm leading-tight text-on-surface-variant">@{props.handle.replace(/^@/, "")}</p> 67 - </div> 68 - ); 69 - } 70 - 71 58 function ProfileIdentity( 72 59 props: { description: string | null; displayName: string; handle: string; viewLabel: string }, 73 60 ) { 74 61 return ( 75 - <div class="grid gap-3"> 62 + <div class="grid min-w-0 flex-1 gap-3"> 76 63 <div class="grid gap-1"> 77 64 <p class="overline-copy text-[0.68rem] text-on-surface-variant">{props.viewLabel}</p> 78 65 <h1 class="m-0 text-[clamp(2rem,4vw,3rem)] font-semibold leading-[0.96] tracking-[-0.04em] text-on-surface"> ··· 201 188 202 189 export function ProfileHero( 203 190 props: { 204 - avatarProgress: number; 205 191 coverOffset: number; 206 - coverScale: number; 207 192 followLoading: boolean; 208 193 isSelf: boolean; 209 194 joinedLabel: string | null; ··· 215 200 pinnedPostHref: string | null; 216 201 profile: ProfileViewDetailed; 217 202 profileBadges: string[]; 203 + rootRef?: (element: HTMLElement) => void; 218 204 viewLabel: string; 219 205 }, 220 206 ) { 221 - const avatarLabel = createMemo(() => getAvatarLabel(props.profile)); 222 207 const displayName = createMemo(() => getDisplayName(props.profile)); 223 208 const isFollowing = createMemo(() => !!props.profile.viewer?.following); 224 - const bannerStyle = createMemo(() => ({ 225 - transform: `translate3d(0, ${props.coverOffset}px, 0) scale(${props.coverScale})`, 226 - })); 209 + const bannerStyle = createMemo(() => ({ transform: `translate3d(0, ${props.coverOffset}px, 0)` })); 227 210 228 211 return ( 229 - <header class="relative"> 212 + <header class="relative" ref={(element) => props.rootRef?.(element)}> 230 213 <div class="relative h-64 overflow-hidden bg-surface-container-high shadow-[inset_0_-64px_80px_rgba(0,0,0,0.55)] max-[760px]:h-56"> 231 214 <Show 232 215 when={props.profile.banner} ··· 242 225 </div> 243 226 244 227 <div class="relative z-10 -mt-16 px-6 pb-6 max-[760px]:px-4 max-[520px]:px-3"> 245 - <div class="sticky top-4 z-20 mb-4 flex items-center gap-3"> 246 - <div class="relative h-32 w-32 shrink-0 overflow-hidden rounded-full bg-black/60 shadow-[0_0_0_4px_rgba(8,8,8,0.96),0_0_0_6px_rgba(125,175,255,0.22),0_24px_40px_rgba(0,0,0,0.36)] backdrop-blur-sm"> 247 - <Show 248 - when={props.profile.avatar} 249 - fallback={ 250 - <div class="flex h-full w-full items-center justify-center text-[2rem] font-semibold text-on-surface"> 251 - {avatarLabel()} 252 - </div> 253 - }> 254 - {(avatar) => <img alt="" class="h-full w-full object-cover" src={avatar()} />} 255 - </Show> 256 - </div> 228 + <div class="grid gap-5 rounded-4xl bg-[rgba(8,8,8,0.82)] px-5 pb-6 pt-5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.035)] backdrop-blur-[18px] max-[760px]:px-4 max-[520px]:px-3.5"> 229 + <div class="flex flex-wrap items-start justify-between gap-5"> 230 + <ProfileAvatar profile={props.profile} /> 257 231 258 - <StickyIdentity displayName={displayName()} handle={props.profile.handle} progress={props.avatarProgress} /> 259 - </div> 260 - 261 - <div class="grid gap-5 pt-20"> 262 - <div 263 - class="flex flex-wrap items-start justify-between gap-4 transition-opacity duration-100 ease-out" 264 - style={{ opacity: 1 - props.avatarProgress }}> 265 232 <ProfileIdentity 266 233 description={props.profile.description ?? null} 267 234 displayName={displayName()} ··· 293 260 </header> 294 261 ); 295 262 } 263 + 264 + function ProfileAvatar(props: { profile: ProfileViewDetailed }) { 265 + const profile = () => props.profile; 266 + const avatar = createMemo(() => profile().avatar); 267 + const label = createMemo(() => getAvatarLabel(props.profile)); 268 + 269 + return ( 270 + <div class="relative h-32 w-32 shrink-0 overflow-hidden rounded-full bg-black/60 shadow-[0_0_0_4px_rgba(8,8,8,0.96),0_0_0_6px_rgba(125,175,255,0.22),0_24px_40px_rgba(0,0,0,0.36)] backdrop-blur-sm"> 271 + <Show 272 + when={avatar()} 273 + fallback={ 274 + <div class="flex h-full w-full items-center justify-center text-[2rem] font-semibold text-on-surface"> 275 + {label()} 276 + </div> 277 + }> 278 + {(a) => <img alt="" class="h-full w-full object-cover" src={a()} />} 279 + </Show> 280 + </div> 281 + ); 282 + } 283 + 284 + export function ProfileStickyHeader(props: { profile: ProfileViewDetailed; profileBadges: string[] }) { 285 + const avatarLabel = createMemo(() => getAvatarLabel(props.profile)); 286 + const displayName = createMemo(() => getDisplayName(props.profile)); 287 + const visibleBadges = createMemo(() => props.profileBadges.slice(0, 2)); 288 + 289 + return ( 290 + <div 291 + class="sticky top-0 z-30 px-3 pb-3 pt-3 backdrop-blur-[18px] max-[520px]:px-2" 292 + data-testid="profile-sticky-header"> 293 + <div class="flex items-center gap-3 rounded-3xl bg-[rgba(14,14,14,0.92)] px-4 py-3 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 294 + <div class="relative h-12 w-12 shrink-0 overflow-hidden rounded-full bg-black/60 shadow-[0_0_0_2px_rgba(8,8,8,0.96),0_0_0_3px_rgba(125,175,255,0.2)]"> 295 + <Show 296 + when={props.profile.avatar} 297 + fallback={ 298 + <div class="flex h-full w-full items-center justify-center text-sm font-semibold text-on-surface"> 299 + {avatarLabel()} 300 + </div> 301 + }> 302 + {(avatar) => <img alt="" class="h-full w-full object-cover" src={avatar()} />} 303 + </Show> 304 + </div> 305 + 306 + <div class="min-w-0"> 307 + <p class="m-0 truncate text-base font-semibold leading-tight tracking-[-0.02em] text-on-surface"> 308 + {displayName()} 309 + </p> 310 + <p class="m-0 truncate text-sm leading-tight text-on-surface-variant"> 311 + @{props.profile.handle.replace(/^@/, "")} 312 + </p> 313 + </div> 314 + 315 + <Show when={visibleBadges().length > 0}> 316 + <div class="ml-auto hidden flex-wrap justify-end gap-2 min-[720px]:flex"> 317 + <For each={visibleBadges()}> 318 + {(badge) => ( 319 + <span class="inline-flex items-center rounded-full bg-white/6 px-3 py-1.5 text-xs font-medium text-on-surface"> 320 + {badge} 321 + </span> 322 + )} 323 + </For> 324 + </div> 325 + </Show> 326 + </div> 327 + </div> 328 + ); 329 + }
+16
src/components/profile/ProfilePanel.test.tsx
··· 168 168 }); 169 169 }); 170 170 171 + it("only shows the compact sticky header after the profile hero scrolls away", async () => { 172 + renderProfilePanel(); 173 + 174 + expect(await screen.findByRole("button", { name: "Follow" })).toBeInTheDocument(); 175 + expect(screen.queryByTestId("profile-sticky-header")).not.toBeInTheDocument(); 176 + 177 + const scrollRegion = screen.getByTestId("profile-scroll-region"); 178 + Object.defineProperty(scrollRegion, "scrollTop", { configurable: true, value: 500, writable: true }); 179 + fireEvent.scroll(scrollRegion); 180 + 181 + await waitFor(() => { 182 + expect(screen.getByTestId("profile-sticky-header")).toBeInTheDocument(); 183 + }); 184 + }); 185 + 171 186 it("opens the followers sheet with bios, inline follow controls, pagination, and escape-to-close", async () => { 172 187 getFollowersMock.mockResolvedValueOnce({ 173 188 actors: [{ ··· 225 240 renderProfilePanel(); 226 241 227 242 expect(await screen.findByRole("button", { name: "Follow" })).toBeInTheDocument(); 243 + expect(screen.queryByTestId("profile-sticky-header")).not.toBeInTheDocument(); 228 244 expect(screen.queryByText("Social Diagnostics")).not.toBeInTheDocument(); 229 245 230 246 fireEvent.click(screen.getByRole("button", { name: "Context" }));
+27 -10
src/components/profile/ProfilePanel.tsx
··· 17 17 import { patchFeedItems } from "$/lib/feeds"; 18 18 import { buildProfileRoute, filterProfileFeed, getProfileRouteActor, type ProfileTab } from "$/lib/profile"; 19 19 import type { ActorListResponse, FeedResponse, FeedViewPost, ProfileViewBasic } from "$/lib/types"; 20 - import { clamp } from "$/lib/utils/numbers"; 21 20 import { formatJoinedDate, normalizeError } from "$/lib/utils/text"; 22 21 import { useNavigate } from "@solidjs/router"; 23 - import { createEffect, createMemo, For, Show } from "solid-js"; 22 + import { createEffect, createMemo, createSignal, For, Show } from "solid-js"; 24 23 import { createStore } from "solid-js/store"; 25 24 import { Presence } from "solid-motionone"; 26 25 import { createActorListState, createFeedState, createProfilePanelState, tabLabel } from "./profile-state"; 27 26 import type { ProfilePanelState } from "./profile-state"; 28 27 import { ActorListOverlay } from "./ProfileActorList"; 29 28 import { ProfileFeedMessage, ProfileFeedSection, ProfileFeedSkeleton } from "./ProfileFeed"; 30 - import { ProfileHero } from "./ProfileHero"; 29 + import { ProfileHero, ProfileStickyHeader } from "./ProfileHero"; 31 30 32 31 const FEED_PAGE_SIZE = 30; 32 + const PROFILE_COMPACT_HEADER_FALLBACK_THRESHOLD = 360; 33 33 34 34 const PROFILE_TABS: ProfileTab[] = ["posts", "replies", "media", "likes", "context"]; 35 35 ··· 38 38 const session = useAppSession(); 39 39 const threadOverlay = useThreadOverlayNavigation(); 40 40 const [state, setState] = createStore<ProfilePanelState>(createProfilePanelState()); 41 + const [heroHeight, setHeroHeight] = createSignal<number | null>(null); 41 42 let requestSequence = 0; 42 43 const interactions = usePostInteractions({ 43 44 onError: session.reportError, ··· 54 55 const visibleItems = createMemo(() => 55 56 state.activeTab === "likes" ? state.likesFeed.items : filterProfileFeed(state.authorFeed.items, state.activeTab) 56 57 ); 57 - const avatarProgress = createMemo(() => clamp((state.scrollTop - 18) / 180, 0, 1)); 58 58 const coverOffset = createMemo(() => Math.min(state.scrollTop * 0.28, 88)); 59 - const coverScale = createMemo(() => 1 + Math.min(state.scrollTop / 1600, 0.08)); 60 59 const viewLabel = createMemo(() => isSelf() ? "Your profile" : "Viewing profile"); 61 60 const joinedLabel = createMemo(() => formatJoinedDate(activeProfile()?.createdAt)); 61 + const compactHeaderThreshold = createMemo(() => { 62 + const measured = heroHeight() ?? 0; 63 + return Math.max(0, (measured > 0 ? measured : PROFILE_COMPACT_HEADER_FALLBACK_THRESHOLD) - 24); 64 + }); 65 + const showCompactHeader = createMemo(() => state.scrollTop >= compactHeaderThreshold()); 62 66 const pinnedPostHref = createMemo(() => { 63 67 const uri = activeProfile()?.pinnedPost?.uri; 64 68 return uri ? threadOverlay.buildThreadHref(uri) : null; ··· 403 407 class="relative grid min-h-0 overflow-hidden bg-[rgba(8,8,8,0.32)]" 404 408 classList={{ "rounded-4xl shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)]": !props.embedded }}> 405 409 <div 410 + data-testid="profile-scroll-region" 406 411 class="min-h-0 overflow-y-auto overscroll-contain" 407 412 onScroll={(event) => setState("scrollTop", event.currentTarget.scrollTop)}> 408 413 <Show when={!state.profileLoading} fallback={<ProfileLoadingView />}> ··· 412 417 {(profile) => ( 413 418 <> 414 419 <ProfileHero 415 - avatarProgress={avatarProgress()} 416 420 coverOffset={coverOffset()} 417 - coverScale={coverScale()} 418 421 followLoading={state.followLoading} 419 422 isSelf={isSelf()} 420 423 joinedLabel={joinedLabel()} ··· 426 429 pinnedPostHref={pinnedPostHref()} 427 430 profile={profile()} 428 431 profileBadges={profileBadges()} 432 + rootRef={(element) => { 433 + setHeroHeight(element.offsetHeight || null); 434 + }} 429 435 viewLabel={viewLabel()} /> 430 436 431 - <ProfileTabs activeTab={state.activeTab} onSelect={selectTab} /> 437 + <Show when={showCompactHeader()}> 438 + <ProfileStickyHeader profile={profile()} profileBadges={profileBadges()} /> 439 + </Show> 440 + 441 + <ProfileTabs 442 + activeTab={state.activeTab} 443 + compactHeaderVisible={showCompactHeader()} 444 + onSelect={selectTab} /> 432 445 433 446 <Show 434 447 when={state.activeTab === "context"} ··· 498 511 ); 499 512 } 500 513 501 - function ProfileTabs(props: { activeTab: ProfileTab; onSelect: (tab: ProfileTab) => void }) { 514 + function ProfileTabs( 515 + props: { activeTab: ProfileTab; compactHeaderVisible: boolean; onSelect: (tab: ProfileTab) => void }, 516 + ) { 502 517 return ( 503 - <div class="sticky top-22 z-30 px-3 pb-3 pt-1 backdrop-blur-[18px] max-[520px]:px-2"> 518 + <div 519 + class="sticky z-20 px-3 pb-3 pt-1 backdrop-blur-[18px] max-[520px]:px-2" 520 + classList={{ "top-22": props.compactHeaderVisible, "top-0": !props.compactHeaderVisible }}> 504 521 <div class="rounded-3xl bg-[rgba(14,14,14,0.92)] p-2 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 505 522 <div class="flex flex-wrap gap-2"> 506 523 <For each={PROFILE_TABS}>
+5
src/components/saved/SavedPostsPanel.test.tsx
··· 8 8 const listSavedPostsMock = vi.hoisted(() => vi.fn()); 9 9 const syncPostsMock = vi.hoisted(() => vi.fn()); 10 10 const loggerErrorMock = vi.hoisted(() => vi.fn()); 11 + const threadOverlayMock = vi.hoisted(() => ({ openThread: vi.fn() })); 11 12 12 13 vi.mock( 13 14 "$/lib/api/search", 14 15 () => ({ getSyncStatus: getSyncStatusMock, listSavedPosts: listSavedPostsMock, syncPosts: syncPostsMock }), 16 + ); 17 + vi.mock( 18 + "$/components/posts/useThreadOverlayNavigation", 19 + () => ({ useThreadOverlayNavigation: () => threadOverlayMock }), 15 20 ); 16 21 vi.mock("@tauri-apps/plugin-log", () => ({ error: loggerErrorMock })); 17 22
+8 -1
src/components/saved/SavedPostsPanel.tsx
··· 1 + import { useThreadOverlayNavigation } from "$/components/posts/useThreadOverlayNavigation"; 1 2 import { LocalPostResultsList, LocalPostResultsSkeletons } from "$/components/search/LocalPostResultsList"; 2 3 import { SearchEmptyState } from "$/components/search/SearchEmptyState"; 3 4 import { SearchQueryInput } from "$/components/search/SearchQueryInput"; ··· 115 116 116 117 export function SavedPostsPanel() { 117 118 const session = useAppSession(); 119 + const threadOverlay = useThreadOverlayNavigation(); 118 120 const [activeTab, setActiveTab] = createSignal<TabKey>("bookmark"); 119 121 const [state, setState] = createStore<SavedPanelState>(createPanelState()); 120 122 const browseRequestIds: Record<TabKey, number> = { bookmark: 0, like: 0 }; ··· 401 403 <SavedPostsViewport 402 404 activeTab={activeTab()} 403 405 browsingState={activeTabState()} 406 + onOpenThread={(uri) => void threadOverlay.openThread(uri)} 404 407 query={trimmedQuery()} 405 408 searching={isSearching()} 406 409 searchingState={activeSearchState()} ··· 518 521 props: { 519 522 activeTab: TabKey; 520 523 browsingState: TabState; 524 + onOpenThread: (uri: string) => void; 521 525 onLoadMore: () => void; 522 526 query: string; 523 527 searching: boolean; ··· 530 534 <Show when={props.activeTab === "bookmark"} keyed> 531 535 <SavedPostsBody 532 536 browsingState={props.browsingState} 537 + onOpenThread={props.onOpenThread} 533 538 onLoadMore={props.onLoadMore} 534 539 query={props.query} 535 540 searching={props.searching} ··· 539 544 <Show when={props.activeTab === "like"} keyed> 540 545 <SavedPostsBody 541 546 browsingState={props.browsingState} 547 + onOpenThread={props.onOpenThread} 542 548 onLoadMore={props.onLoadMore} 543 549 query={props.query} 544 550 searching={props.searching} ··· 553 559 function SavedPostsBody( 554 560 props: { 555 561 browsingState: TabState; 562 + onOpenThread: (uri: string) => void; 556 563 onLoadMore: () => void; 557 564 query: string; 558 565 searching: boolean; ··· 600 607 </Match> 601 608 <Match when={activeState().items.length > 0}> 602 609 <div class="grid gap-3"> 603 - <LocalPostResultsList query={props.query} results={activeState().items} /> 610 + <LocalPostResultsList onOpenThread={props.onOpenThread} query={props.query} results={activeState().items} /> 604 611 <LoadMoreButton 605 612 next={activeState().nextOffset} 606 613 onLoadMore={props.onLoadMore}
+6 -1
src/components/search/LocalPostResultsList.tsx
··· 24 24 ); 25 25 } 26 26 27 - export function LocalPostResultsList(props: { query: string; results: LocalPostResult[] }) { 27 + export function LocalPostResultsList( 28 + props: { onOpenThread?: (uri: string) => void; query: string; results: LocalPostResult[] }, 29 + ) { 28 30 return ( 29 31 <Motion.div 30 32 class="grid gap-2" ··· 47 49 text={result.text ?? ""} 48 50 createdAt={result.createdAt ?? ""} 49 51 isSemanticMatch={result.semanticMatch && !result.keywordMatch} 52 + onOpenThread={props.onOpenThread 53 + ? () => props.onOpenThread?.(result.uri) 54 + : undefined} 50 55 query={props.query} /> 51 56 </Motion.div> 52 57 )}
+5
src/components/search/SearchPanel.test.tsx
··· 7 7 const searchPostsNetworkMock = vi.hoisted(() => vi.fn()); 8 8 const getSyncStatusMock = vi.hoisted(() => vi.fn()); 9 9 const syncPostsMock = vi.hoisted(() => vi.fn()); 10 + const threadOverlayMock = vi.hoisted(() => ({ openThread: vi.fn() })); 10 11 11 12 vi.mock( 12 13 "$/lib/api/search", ··· 16 17 getSyncStatus: getSyncStatusMock, 17 18 syncPosts: syncPostsMock, 18 19 }), 20 + ); 21 + vi.mock( 22 + "$/components/posts/useThreadOverlayNavigation", 23 + () => ({ useThreadOverlayNavigation: () => threadOverlayMock }), 19 24 ); 20 25 21 26 vi.mock("@tauri-apps/plugin-log", () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn() }));
+11 -3
src/components/search/SearchPanel.tsx
··· 1 + import { useThreadOverlayNavigation } from "$/components/posts/useThreadOverlayNavigation"; 1 2 import { Icon, SearchModeIcon } from "$/components/shared/Icon"; 2 3 import { useAppPreferences } from "$/contexts/app-preferences"; 3 4 import { useAppSession } from "$/contexts/app-session"; ··· 57 58 export function SearchPanel(props: SearchPanelProps = {}) { 58 59 const preferences = useAppPreferences(); 59 60 const session = useAppSession(); 61 + const threadOverlay = useThreadOverlayNavigation(); 60 62 const [search, setSearch] = createStore<SearchPanelState>({ 61 63 error: null, 62 64 hasSearched: false, ··· 250 252 loading={search.loading} 251 253 localResults={search.results} 252 254 networkResults={search.networkResults} 255 + onOpenThread={(uri) => void threadOverlay.openThread(uri)} 253 256 query={search.query} /> 254 257 </section> 255 258 ··· 416 419 loading: boolean; 417 420 localResults: LocalPostResult[]; 418 421 networkResults: NetworkSearchResult | null; 422 + onOpenThread: (uri: string) => void; 419 423 query: string; 420 424 }, 421 425 ) { ··· 436 440 isLocalMode: boolean; 437 441 localResults: LocalPostResult[]; 438 442 networkResults: NetworkSearchResult | null; 443 + onOpenThread: (uri: string) => void; 439 444 query: string; 440 445 }, 441 446 ) { ··· 463 468 </Match> 464 469 465 470 <Match when={props.isLocalMode}> 466 - <LocalPostResultsList query={props.query} results={props.localResults} /> 471 + <LocalPostResultsList onOpenThread={props.onOpenThread} query={props.query} results={props.localResults} /> 467 472 </Match> 468 473 469 474 <Match when={!props.isLocalMode && props.networkResults}> 470 - <NetworkResultsList query={props.query} results={props.networkResults} /> 475 + <NetworkResultsList onOpenThread={props.onOpenThread} query={props.query} results={props.networkResults} /> 471 476 </Match> 472 477 </Switch> 473 478 </Presence> ··· 487 492 ); 488 493 } 489 494 490 - function NetworkResultsList(props: { query: string; results: NetworkSearchResult | null }) { 495 + function NetworkResultsList( 496 + props: { onOpenThread: (uri: string) => void; query: string; results: NetworkSearchResult | null }, 497 + ) { 491 498 return ( 492 499 <Motion.div 493 500 class="grid gap-2" ··· 510 517 text={typeof post.record.text === "string" ? post.record.text : ""} 511 518 createdAt={post.indexedAt} 512 519 likeCount={post.likeCount ?? 0} 520 + onOpenThread={() => props.onOpenThread(post.uri)} 513 521 replyCount={post.replyCount ?? 0} 514 522 query={props.query} /> 515 523 </Motion.div>
+26
src/components/search/SearchResultCard.test.tsx
··· 1 + import { fireEvent, render, screen } from "@solidjs/testing-library"; 2 + import { describe, expect, it, vi } from "vitest"; 3 + import { SearchResultCard } from "./SearchResultCard"; 4 + 5 + describe("SearchResultCard", () => { 6 + it("opens the thread from the post body but keeps profile navigation explicit", () => { 7 + const onOpenThread = vi.fn(); 8 + 9 + render(() => ( 10 + <SearchResultCard 11 + authorDid="did:plc:alice" 12 + authorHandle="alice.test" 13 + createdAt="2026-04-03T12:00:00.000Z" 14 + onOpenThread={onOpenThread} 15 + source="bookmark" 16 + text="Saved post body" /> 17 + )); 18 + 19 + const primaryRegion = screen.getByRole("button", { name: "Open thread" }); 20 + fireEvent.click(primaryRegion); 21 + fireEvent.keyDown(primaryRegion, { key: "Enter" }); 22 + fireEvent.click(screen.getByRole("link", { name: "@alice.test" })); 23 + 24 + expect(onOpenThread).toHaveBeenCalledTimes(2); 25 + }); 26 + });
+69 -25
src/components/search/SearchResultCard.tsx
··· 1 1 import { formatRelativeTime } from "$/lib/feeds"; 2 2 import { buildProfileRoute } from "$/lib/profile"; 3 3 import { escapeForRegex } from "$/lib/utils/text"; 4 - import { createMemo, type JSX, Show } from "solid-js"; 4 + import { createMemo, type JSX, type ParentProps, Show } from "solid-js"; 5 5 import { Icon } from "../shared/Icon"; 6 6 7 - function CardContent( 8 - props: { 9 - avatarLabel: string; 10 - authorHandle: string; 11 - profileHref: string; 12 - time: string; 13 - isSemantic?: boolean; 14 - text: string | (string | JSX.Element)[]; 15 - likes?: number; 16 - replies?: number; 17 - sourceLabel: string | null; 18 - }, 19 - ) { 7 + type CardContentProps = { 8 + avatarLabel: string; 9 + authorHandle: string; 10 + profileHref: string; 11 + time: string; 12 + isSemantic?: boolean; 13 + text: string | (string | JSX.Element)[]; 14 + likes?: number; 15 + onOpenThread?: () => void; 16 + replies?: number; 17 + sourceLabel: string | null; 18 + }; 19 + 20 + function CardContent(props: CardContentProps) { 20 21 return ( 21 22 <div class="flex gap-3"> 22 - <a class="shrink-0 no-underline" href={`#${props.profileHref}`}> 23 + <a class="shrink-0 no-underline" href={`#${props.profileHref}`} onClick={(event) => event.stopPropagation()}> 23 24 <Avatar label={props.avatarLabel} /> 24 25 </a> 25 26 <div class="min-w-0 flex-1"> 26 - <CardHeader 27 - handle={props.authorHandle} 28 - profileHref={props.profileHref} 29 - time={props.time} 30 - isSemantic={props.isSemantic} /> 31 - <TextContent text={props.text} /> 32 - <CardFooter likes={props.likes} replies={props.replies} sourceLabel={props.sourceLabel} /> 27 + <PostPreviewRegion onOpenThread={props.onOpenThread}> 28 + <CardHeader 29 + handle={props.authorHandle} 30 + profileHref={props.profileHref} 31 + time={props.time} 32 + isSemantic={props.isSemantic} /> 33 + <TextContent text={props.text} /> 34 + <CardFooter 35 + likes={props.likes} 36 + onOpenThread={props.onOpenThread} 37 + replies={props.replies} 38 + sourceLabel={props.sourceLabel} /> 39 + </PostPreviewRegion> 33 40 </div> 34 41 </div> 35 42 ); ··· 54 61 <header class="mb-2 flex flex-wrap items-center gap-x-2 gap-y-1"> 55 62 <a 56 63 class="wrap-break-word text-sm font-semibold text-on-surface no-underline transition hover:text-primary" 57 - href={`#${props.profileHref}`}> 64 + href={`#${props.profileHref}`} 65 + onClick={(event) => event.stopPropagation()}> 58 66 @{props.handle.replace(/^@/, "")} 59 67 </a> 60 68 <span class="text-xs text-on-surface-variant">{props.time}</span> ··· 79 87 ); 80 88 } 81 89 82 - function CardFooter(props: { likes?: number; replies?: number; sourceLabel: string | null }) { 90 + function CardFooter( 91 + props: { likes?: number; onOpenThread?: () => void; replies?: number; sourceLabel: string | null }, 92 + ) { 83 93 return ( 84 94 <footer class="mt-3 flex min-w-0 flex-wrap items-center gap-3"> 85 95 <Show when={typeof props.likes === "number"}> ··· 93 103 <Show when={props.sourceLabel}> 94 104 {(label) => <span class="rounded-full bg-white/10 px-2 py-0.5 text-xs text-on-surface-variant">{label()}</span>} 95 105 </Show> 106 + 107 + <Show when={props.onOpenThread}> 108 + <span class="inline-flex items-center gap-1.5 rounded-full bg-primary/12 px-2.5 py-1 text-xs font-medium text-primary"> 109 + <Icon iconClass="i-ri-node-tree" class="text-sm" /> 110 + Thread 111 + </span> 112 + </Show> 96 113 </footer> 97 114 ); 98 115 } ··· 108 125 ); 109 126 } 110 127 128 + function PostPreviewRegion(props: ParentProps<{ onOpenThread?: () => void }>) { 129 + const interactive = () => !!props.onOpenThread; 130 + 131 + return ( 132 + <div 133 + class="min-w-0 rounded-2xl outline-none transition duration-150 ease-out" 134 + classList={{ 135 + "cursor-pointer hover:bg-white/2.5 focus-visible:bg-white/3 focus-visible:ring-1 focus-visible:ring-primary/30": 136 + interactive(), 137 + }} 138 + aria-label={interactive() ? "Open thread" : undefined} 139 + role={interactive() ? "button" : undefined} 140 + tabIndex={interactive() ? 0 : undefined} 141 + onClick={() => props.onOpenThread?.()} 142 + onKeyDown={(event) => { 143 + if ((event.key === "Enter" || event.key === " ") && props.onOpenThread) { 144 + event.preventDefault(); 145 + props.onOpenThread(); 146 + } 147 + }}> 148 + {props.children} 149 + </div> 150 + ); 151 + } 152 + 111 153 type SearchResultCardProps = { 112 154 authorDid?: string; 113 155 authorHandle: string; ··· 115 157 text: string; 116 158 createdAt: string; 117 159 likeCount?: number; 160 + onOpenThread?: () => void; 118 161 replyCount?: number; 119 162 isSemanticMatch?: boolean; 120 163 query?: string; ··· 162 205 163 206 return ( 164 207 <article 165 - class="group cursor-pointer rounded-2xl bg-surface px-5 py-4 transition-colors duration-150 hover:bg-white/3" 208 + class="group rounded-2xl bg-surface px-5 py-4 transition-colors duration-150 hover:bg-white/3" 166 209 role="article"> 167 210 <CardContent 168 211 avatarLabel={avatarLabel()} ··· 172 215 isSemantic={props.isSemanticMatch} 173 216 text={highlightedText()} 174 217 likes={props.likeCount} 218 + onOpenThread={props.onOpenThread} 175 219 replies={props.replyCount} 176 220 sourceLabel={sourceLabel()} /> 177 221 </article>
+1 -1
tsconfig.json
··· 19 19 "noFallthroughCasesInSwitch": true, 20 20 "types": ["vite/client", "@testing-library/jest-dom"], 21 21 "baseUrl": "src", 22 - "ignoreDeprecations": "5.0", 22 + "ignoreDeprecations": "6.0", 23 23 "paths": { "$/*": ["./*"] } 24 24 }, 25 25 "include": ["src"],