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: add profile panel

+1103 -39
+14 -2
src-tauri/src/commands/mod.rs
··· 53 53 } 54 54 55 55 #[tauri::command] 56 + pub async fn get_profile(actor: String, state: State<'_, AppState>) -> Result<Value, AppError> { 57 + feed::get_profile(actor, &state).await 58 + } 59 + 60 + #[tauri::command] 56 61 pub async fn get_feed_generators(uris: Vec<String>, state: State<'_, AppState>) -> Result<Value, AppError> { 57 62 feed::get_feed_generators(uris, &state).await 58 63 } ··· 83 88 84 89 #[tauri::command] 85 90 pub async fn get_author_feed( 86 - did: String, cursor: Option<String>, state: State<'_, AppState>, 91 + actor: String, cursor: Option<String>, limit: Option<u32>, state: State<'_, AppState>, 92 + ) -> Result<Value, AppError> { 93 + feed::get_author_feed(actor, cursor, limit, &state).await 94 + } 95 + 96 + #[tauri::command] 97 + pub async fn get_actor_likes( 98 + actor: String, cursor: Option<String>, limit: Option<u32>, state: State<'_, AppState>, 87 99 ) -> Result<Value, AppError> { 88 - feed::get_author_feed(did, cursor, &state).await 100 + feed::get_actor_likes(actor, cursor, limit, &state).await 89 101 } 90 102 91 103 #[tauri::command]
+72 -3
src-tauri/src/feed.rs
··· 2 2 use super::error::{AppError, Result}; 3 3 use super::state::AppState; 4 4 use jacquard::api::app_bsky::actor::get_preferences::GetPreferences; 5 + use jacquard::api::app_bsky::actor::get_profile::GetProfile; 5 6 use jacquard::api::app_bsky::actor::put_preferences::PutPreferences; 6 7 use jacquard::api::app_bsky::actor::{ 7 8 FeedViewPref, PreferencesItem, SavedFeed, SavedFeedType, SavedFeedsPrefV2, SavedFeedsPrefV2Builder, 8 9 }; 9 10 use jacquard::api::app_bsky::embed::record::Record; 11 + use jacquard::api::app_bsky::feed::get_actor_likes::GetActorLikes; 10 12 use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed; 11 13 use jacquard::api::app_bsky::feed::get_feed::GetFeed; 12 14 use jacquard::api::app_bsky::feed::get_feed_generators::GetFeedGenerators; ··· 25 27 use jacquard::types::cid::Cid; 26 28 use jacquard::types::datetime::Datetime; 27 29 use jacquard::types::did::Did; 30 + use jacquard::types::handle::Handle; 28 31 use jacquard::types::ident::AtIdentifier; 29 32 use jacquard::types::nsid::Nsid; 30 33 use jacquard::types::recordkey::RecordKey; ··· 80 83 AppError::Validation("no active account".into()) 81 84 }) 82 85 .map(|s| s.did.clone()) 86 + } 87 + 88 + fn parse_actor_identifier(actor: &str) -> Result<AtIdentifier<'static>> { 89 + let trimmed = actor.trim(); 90 + if trimmed.is_empty() { 91 + return Err(AppError::validation("actor must not be empty")); 92 + } 93 + 94 + if let Ok(did) = Did::new(trimmed) { 95 + return Ok(AtIdentifier::Did(did.into_static())); 96 + } 97 + 98 + let normalized_handle = trimmed.trim_start_matches('@'); 99 + if let Ok(handle) = Handle::new(normalized_handle) { 100 + return Ok(AtIdentifier::Handle(handle.into_static())); 101 + } 102 + 103 + Err(AppError::validation("actor must be a valid DID or handle")) 83 104 } 84 105 85 106 #[derive(Debug, Serialize, Deserialize)] ··· 416 437 serde_json::to_value(&output).map_err(AppError::from) 417 438 } 418 439 419 - pub async fn get_author_feed(did: String, cursor: Option<String>, state: &AppState) -> Result<serde_json::Value> { 440 + pub async fn get_profile(actor: String, state: &AppState) -> Result<serde_json::Value> { 441 + let session = get_session(state).await?; 442 + let actor = parse_actor_identifier(&actor)?; 443 + 444 + let output = session 445 + .send(GetProfile::new().actor(actor).build()) 446 + .await 447 + .map_err(|error| { 448 + log::error!("getProfile error: {error}"); 449 + AppError::validation("getProfile") 450 + })? 451 + .into_output() 452 + .map_err(|error| { 453 + log::error!("getProfile output error: {error}"); 454 + AppError::validation("getProfile output") 455 + })?; 456 + 457 + serde_json::to_value(output.value).map_err(AppError::from) 458 + } 459 + 460 + pub async fn get_author_feed( 461 + actor: String, cursor: Option<String>, limit: Option<u32>, state: &AppState, 462 + ) -> Result<serde_json::Value> { 420 463 let session = get_session(state).await?; 421 - let actor = AtIdentifier::Did(Did::new(&did)?); 422 - let mut req = GetAuthorFeed::new().actor(actor); 464 + let actor = parse_actor_identifier(&actor)?; 465 + let mut req = GetAuthorFeed::new().actor(actor).limit(limit.map(|value| value as i64)); 423 466 if let Some(c) = &cursor { 424 467 req = req.cursor(Some(c.as_str().into())); 425 468 } ··· 435 478 .map_err(|error| { 436 479 log::error!("getAuthorFeed output error: {error}"); 437 480 AppError::validation("getAuthorFeed output") 481 + })?; 482 + 483 + serde_json::to_value(&output).map_err(AppError::from) 484 + } 485 + 486 + pub async fn get_actor_likes( 487 + actor: String, cursor: Option<String>, limit: Option<u32>, state: &AppState, 488 + ) -> Result<serde_json::Value> { 489 + let session = get_session(state).await?; 490 + let actor = parse_actor_identifier(&actor)?; 491 + let mut req = GetActorLikes::new().actor(actor).limit(limit.map(|value| value as i64)); 492 + if let Some(c) = &cursor { 493 + req = req.cursor(Some(c.as_str().into())); 494 + } 495 + 496 + let output = session 497 + .send(req.build()) 498 + .await 499 + .map_err(|error| { 500 + log::error!("getActorLikes error: {error}"); 501 + AppError::validation("getActorLikes") 502 + })? 503 + .into_output() 504 + .map_err(|error| { 505 + log::error!("getActorLikes output error: {error}"); 506 + AppError::validation("getActorLikes output") 438 507 })?; 439 508 440 509 serde_json::to_value(&output).map_err(AppError::from)
+2
src-tauri/src/lib.rs
··· 78 78 cmd::set_active_account, 79 79 cmd::search_login_suggestions, 80 80 cmd::get_preferences, 81 + cmd::get_profile, 81 82 cmd::get_feed_generators, 82 83 cmd::get_timeline, 83 84 cmd::get_feed, 84 85 cmd::get_list_feed, 85 86 cmd::get_post_thread, 86 87 cmd::get_author_feed, 88 + cmd::get_actor_likes, 87 89 cmd::create_post, 88 90 cmd::like_post, 89 91 cmd::unlike_post,
+2
src/App.tsx
··· 10 10 import { LoginPanel } from "./components/LoginPanel"; 11 11 import { NotificationsPanel } from "./components/notifications/NotificationsPanel"; 12 12 import { HeaderPanel } from "./components/panels/Header"; 13 + import { ProfilePanel } from "./components/profile/ProfilePanel"; 13 14 import { AppRail } from "./components/rail/AppRail"; 14 15 import { SessionSpotlight } from "./components/Session"; 15 16 import { ErrorToast } from "./components/shared/ErrorToast"; ··· 81 82 renderAuth={() => <AuthWorkspace />} 82 83 renderComposer={() => <ComposerWindow />} 83 84 renderNotifications={() => <NotificationsPanel />} 85 + renderProfile={(props) => <ProfilePanel actor={props.actor} />} 84 86 renderShell={AppShell} 85 87 renderTimeline={({ context }) => ( 86 88 <FeedWorkspace onThreadRouteChange={context.onThreadRouteChange} threadUri={context.threadUri} />
+5 -1
src/components/feeds/PostCard.test.tsx
··· 43 43 item={{ 44 44 post: createPost(), 45 45 reply: { 46 - parent: { $type: "app.bsky.feed.defs#postView", ...createPost(), author: { ...createPost().author, handle: "bob.test" } }, 46 + parent: { 47 + $type: "app.bsky.feed.defs#postView", 48 + ...createPost(), 49 + author: { ...createPost().author, handle: "bob.test" }, 50 + }, 47 51 root: { $type: "app.bsky.feed.defs#postView", ...createPost() }, 48 52 }, 49 53 }}
+87 -26
src/components/feeds/PostCard.tsx
··· 9 9 getQuotedText, 10 10 isReplyItem, 11 11 } from "$/lib/feeds"; 12 + import { buildProfileRoute, getProfileRouteActor } from "$/lib/profile"; 12 13 import type { FeedViewPost, ImagesEmbedView, PostView, ProfileViewBasic } from "$/lib/types"; 13 14 import { formatCount } from "$/lib/utils/text"; 14 15 import { createMemo, For, Match, Show, Switch } from "solid-js"; ··· 29 30 pulseRepost?: boolean; 30 31 registerRef?: (element: HTMLElement) => void; 31 32 repostPending?: boolean; 33 + showActions?: boolean; 32 34 }; 33 35 34 36 export function PostCard(props: PostCardProps) { ··· 61 63 const likeCount = createMemo(() => formatCount(props.post.likeCount)); 62 64 const replyCount = createMemo(() => formatCount(props.post.replyCount)); 63 65 const repostCount = createMemo(() => formatCount(props.post.repostCount)); 66 + const profileHref = createMemo(() => buildProfileRoute(getProfileRouteActor(props.post.author))); 67 + const articleClickable = createMemo(() => props.showActions === false && !!props.onOpenThread); 64 68 65 69 return ( 66 70 <article 67 71 ref={(element) => props.registerRef?.(element)} 68 72 class="group min-w-0 overflow-hidden rounded-3xl bg-white/2.5 px-5 py-5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)] transition duration-150 ease-out hover:bg-white/4 max-[760px]:px-4 max-[760px]:py-4 max-[520px]:rounded-3xl max-[520px]:px-3.5" 69 73 classList={{ 74 + "cursor-pointer": articleClickable(), 70 75 "bg-[linear-gradient(135deg,rgba(125,175,255,0.11),rgba(0,115,222,0.06))] shadow-[inset_0_0_0_1px_rgba(125,175,255,0.22),0_0_0_1px_rgba(125,175,255,0.08)]": 71 76 !!props.focused, 72 77 }} 73 78 role="article" 74 79 tabIndex={0} 75 - onClick={() => props.onFocus?.()} 80 + onClick={() => { 81 + if (articleClickable()) { 82 + props.onOpenThread?.(); 83 + return; 84 + } 85 + 86 + props.onFocus?.(); 87 + }} 76 88 onFocus={() => props.onFocus?.()} 77 89 onKeyDown={(event) => { 78 90 if (event.key === "Enter") { ··· 93 105 </Show> 94 106 95 107 <div class="flex min-w-0 gap-3"> 96 - <AuthorAvatar avatar={props.post.author.avatar} label={getAvatarLabel(props.post.author)} /> 108 + <a class="shrink-0 no-underline" href={`#${profileHref()}`} onClick={(event) => event.stopPropagation()}> 109 + <AuthorAvatar avatar={props.post.author.avatar} label={getAvatarLabel(props.post.author)} /> 110 + </a> 97 111 98 112 <div class="min-w-0 flex-1"> 99 113 <header class="mb-2 flex flex-wrap items-center gap-x-2 gap-y-1"> 100 - <span class="wrap-break-word text-base font-semibold tracking-[-0.01em] text-on-surface"> 114 + <a 115 + class="wrap-break-word text-base font-semibold tracking-[-0.01em] text-on-surface no-underline transition hover:text-primary" 116 + href={`#${profileHref()}`} 117 + onClick={(event) => event.stopPropagation()}> 101 118 {authorName()} 102 - </span> 103 - <span class="break-all text-xs text-on-surface-variant">@{props.post.author.handle.replace(/^@/, "")}</span> 119 + </a> 120 + <a 121 + class="break-all text-xs text-on-surface-variant no-underline transition hover:text-primary" 122 + href={`#${profileHref()}`} 123 + onClick={(event) => event.stopPropagation()}> 124 + @{props.post.author.handle.replace(/^@/, "")} 125 + </a> 104 126 <span class="text-xs text-on-surface-variant">{createdAt()}</span> 105 127 </header> 106 128 ··· 114 136 115 137 <PostEmbeds post={props.post} /> 116 138 117 - <footer class="mt-4 flex min-w-0 flex-wrap items-center gap-2 max-[520px]:gap-1"> 118 - <ActionButton 119 - active={isLiked()} 120 - busy={!!props.likePending} 121 - icon="i-ri-heart-3-line" 122 - iconActive="i-ri-heart-3-fill" 123 - label={likeCount()} 124 - pulse={!!props.pulseLike} 125 - onClick={props.onLike} /> 126 - <ActionButton icon="i-ri-chat-1-line" label={replyCount()} onClick={props.onReply} /> 127 - <ActionButton 128 - active={isReposted()} 129 - busy={!!props.repostPending} 130 - icon="i-ri-repeat-2-line" 131 - iconActive="i-ri-repeat-2-fill" 132 - label={repostCount()} 133 - pulse={!!props.pulseRepost} 134 - onClick={props.onRepost} /> 135 - <ActionButton icon="i-ri-chat-quote-line" label="Quote" onClick={props.onQuote} /> 136 - <ActionButton icon="i-ri-node-tree" label="Thread" onClick={props.onOpenThread} /> 137 - </footer> 139 + <Show when={props.showActions !== false}> 140 + <PostActions 141 + isLiked={isLiked()} 142 + isReposted={isReposted()} 143 + likeCount={likeCount()} 144 + likePending={!!props.likePending} 145 + pulseLike={!!props.pulseLike} 146 + pulseRepost={!!props.pulseRepost} 147 + replyCount={replyCount()} 148 + repostCount={repostCount()} 149 + repostPending={!!props.repostPending} 150 + onLike={props.onLike} 151 + onOpenThread={props.onOpenThread} 152 + onQuote={props.onQuote} 153 + onReply={props.onReply} 154 + onRepost={props.onRepost} /> 155 + </Show> 138 156 </div> 139 157 </div> 140 158 </article> 159 + ); 160 + } 161 + 162 + function PostActions( 163 + props: { 164 + isLiked: boolean; 165 + isReposted: boolean; 166 + likeCount: string; 167 + likePending: boolean; 168 + pulseLike: boolean; 169 + pulseRepost: boolean; 170 + replyCount: string; 171 + repostCount: string; 172 + repostPending: boolean; 173 + onLike?: () => void; 174 + onOpenThread?: () => void; 175 + onQuote?: () => void; 176 + onReply?: () => void; 177 + onRepost?: () => void; 178 + }, 179 + ) { 180 + return ( 181 + <footer class="mt-4 flex min-w-0 flex-wrap items-center gap-2 max-[520px]:gap-1"> 182 + <ActionButton 183 + active={props.isLiked} 184 + busy={props.likePending} 185 + icon="i-ri-heart-3-line" 186 + iconActive="i-ri-heart-3-fill" 187 + label={props.likeCount} 188 + pulse={props.pulseLike} 189 + onClick={props.onLike} /> 190 + <ActionButton icon="i-ri-chat-1-line" label={props.replyCount} onClick={props.onReply} /> 191 + <ActionButton 192 + active={props.isReposted} 193 + busy={props.repostPending} 194 + icon="i-ri-repeat-2-line" 195 + iconActive="i-ri-repeat-2-fill" 196 + label={props.repostCount} 197 + pulse={props.pulseRepost} 198 + onClick={props.onRepost} /> 199 + <ActionButton icon="i-ri-chat-quote-line" label="Quote" onClick={props.onQuote} /> 200 + <ActionButton icon="i-ri-node-tree" label="Thread" onClick={props.onOpenThread} /> 201 + </footer> 141 202 ); 142 203 } 143 204
+11 -2
src/components/notifications/NotificationItem.tsx
··· 1 1 import { Icon } from "$/components/shared/Icon"; 2 2 import { formatRelativeTime, getAvatarLabel, getDisplayName } from "$/lib/feeds"; 3 + import { buildProfileRoute, getProfileRouteActor } from "$/lib/profile"; 3 4 import type { NotificationReason, NotificationView } from "$/lib/types"; 4 5 import { createMemo, Match, Show, Switch } from "solid-js"; 5 6 ··· 68 69 }); 69 70 const time = createMemo(() => formatRelativeTime(props.notification.indexedAt)); 70 71 const avatarLabel = createMemo(() => getAvatarLabel(props.notification.author)); 72 + const profileHref = createMemo(() => buildProfileRoute(getProfileRouteActor(props.notification.author))); 71 73 const postText = createMemo<string | null>(() => { 72 74 const record = props.notification.record; 73 75 const text = record["text"]; ··· 81 83 classList={{ "opacity-60": props.notification.isRead }} 82 84 aria-label={`${name()} ${description()}`}> 83 85 <ReasonIcon reason={props.notification.reason} /> 84 - <AuthorAvatar avatar={props.notification.author.avatar} label={avatarLabel()} /> 86 + <a class="shrink-0 no-underline" href={`#${profileHref()}`}> 87 + <AuthorAvatar avatar={props.notification.author.avatar} label={avatarLabel()} /> 88 + </a> 85 89 86 90 <div class="min-w-0 flex-1"> 87 91 <p class="m-0 text-sm leading-relaxed text-on-surface"> 88 - <span class="font-semibold">{name()}</span> <span class="text-on-surface-variant">{description()}</span> 92 + <a 93 + class="font-semibold text-on-surface no-underline transition hover:text-primary" 94 + href={`#${profileHref()}`}> 95 + {name()} 96 + </a>{" "} 97 + <span class="text-on-surface-variant">{description()}</span> 89 98 </p> 90 99 91 100 <Show when={detail()}>
+703
src/components/profile/ProfilePanel.tsx
··· 1 + import { PostCard } from "$/components/feeds/PostCard"; 2 + import { ProfileSkeleton } from "$/components/ProfileSkeleton"; 3 + import { Icon } from "$/components/shared/Icon"; 4 + import { useAppSession } from "$/contexts/app-session"; 5 + import { getActorLikes, getAuthorFeed, getProfile } from "$/lib/api/profile"; 6 + import { buildThreadRoute, getAvatarLabel, getDisplayName } from "$/lib/feeds"; 7 + import { filterProfileFeed, type ProfileTab } from "$/lib/profile"; 8 + import type { FeedResponse, FeedViewPost, ProfileViewDetailed } from "$/lib/types"; 9 + import { formatCount, normalizeError } from "$/lib/utils/text"; 10 + import { useNavigate } from "@solidjs/router"; 11 + import { createEffect, createMemo, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"; 12 + import { createStore } from "solid-js/store"; 13 + 14 + const FEED_PAGE_SIZE = 30; 15 + const PROFILE_TABS: ProfileTab[] = ["posts", "replies", "media", "likes"]; 16 + 17 + type FeedState = { 18 + cursor: string | null; 19 + error: string | null; 20 + items: FeedViewPost[]; 21 + loaded: boolean; 22 + loading: boolean; 23 + loadingMore: boolean; 24 + }; 25 + 26 + type ProfilePanelState = { 27 + activeTab: ProfileTab; 28 + authorFeed: FeedState; 29 + likesFeed: FeedState; 30 + profile: ProfileViewDetailed | null; 31 + profileError: string | null; 32 + profileLoading: boolean; 33 + scrollTop: number; 34 + }; 35 + 36 + function createFeedState(): FeedState { 37 + return { cursor: null, error: null, items: [], loaded: false, loading: false, loadingMore: false }; 38 + } 39 + 40 + function createProfilePanelState(): ProfilePanelState { 41 + return { 42 + activeTab: "posts", 43 + authorFeed: createFeedState(), 44 + likesFeed: createFeedState(), 45 + profile: null, 46 + profileError: null, 47 + profileLoading: true, 48 + scrollTop: 0, 49 + }; 50 + } 51 + 52 + export function ProfilePanel(props: { actor: string | null }) { 53 + const navigate = useNavigate(); 54 + const session = useAppSession(); 55 + const [state, setState] = createStore<ProfilePanelState>(createProfilePanelState()); 56 + const [avatarTrackWidth, setAvatarTrackWidth] = createSignal(0); 57 + let avatarTrackRef: HTMLDivElement | undefined; 58 + let requestSequence = 0; 59 + 60 + const activeActor = createMemo(() => props.actor?.trim() || session.activeHandle || session.activeDid || ""); 61 + const activeProfile = createMemo(() => state.profile); 62 + const isSelf = createMemo(() => activeProfile()?.did === session.activeDid); 63 + const activeFeedState = createMemo(() => state.activeTab === "likes" ? state.likesFeed : state.authorFeed); 64 + const visibleItems = createMemo(() => 65 + state.activeTab === "likes" ? state.likesFeed.items : filterProfileFeed(state.authorFeed.items, state.activeTab) 66 + ); 67 + const avatarProgress = createMemo(() => clamp((state.scrollTop - 18) / 180, 0, 1)); 68 + const avatarScale = createMemo(() => 1 - avatarProgress() * 0.34); 69 + const avatarShift = createMemo(() => { 70 + const scaledSize = 128 * avatarScale(); 71 + return Math.max((avatarTrackWidth() - scaledSize) / 2, 0) * avatarProgress(); 72 + }); 73 + const coverOffset = createMemo(() => Math.min(state.scrollTop * 0.28, 88)); 74 + const coverScale = createMemo(() => 1 + Math.min(state.scrollTop / 1600, 0.08)); 75 + const viewLabel = createMemo(() => isSelf() ? "Your profile" : "Viewing profile"); 76 + const joinedLabel = createMemo(() => formatJoinedDate(activeProfile()?.createdAt)); 77 + const pinnedPostHref = createMemo(() => { 78 + const uri = activeProfile()?.pinnedPost?.uri; 79 + return uri ? buildThreadRoute(uri) : null; 80 + }); 81 + const profileBadges = createMemo(() => { 82 + const profile = activeProfile(); 83 + if (!profile) { 84 + return []; 85 + } 86 + 87 + return [ 88 + isSelf() ? "Current account" : null, 89 + profile.viewer?.following ? "Following" : null, 90 + profile.viewer?.followedBy ? "Follows you" : null, 91 + profile.viewer?.muted ? "Muted" : null, 92 + profile.viewer?.blockedBy ? "Blocks you" : null, 93 + ].filter(Boolean) as string[]; 94 + }); 95 + 96 + createEffect(() => { 97 + const actor = activeActor(); 98 + if (!actor) { 99 + return; 100 + } 101 + 102 + requestSequence += 1; 103 + const sequence = requestSequence; 104 + setState({ 105 + authorFeed: createFeedState(), 106 + likesFeed: createFeedState(), 107 + profile: null, 108 + profileError: null, 109 + profileLoading: true, 110 + scrollTop: 0, 111 + }); 112 + 113 + void loadProfile(sequence, actor); 114 + }); 115 + 116 + createEffect(() => { 117 + const actor = activeActor(); 118 + if (!actor || state.profileLoading || !!state.profileError) { 119 + return; 120 + } 121 + 122 + if (state.activeTab === "likes") { 123 + if (!state.likesFeed.loaded && !state.likesFeed.loading) { 124 + void loadLikesPage(requestSequence, actor, false); 125 + } 126 + return; 127 + } 128 + 129 + if (!state.authorFeed.loaded && !state.authorFeed.loading) { 130 + void loadAuthorPage(requestSequence, actor, false); 131 + } 132 + }); 133 + 134 + createEffect(() => { 135 + const element = avatarTrackRef; 136 + if (!element) { 137 + return; 138 + } 139 + 140 + const observer = new ResizeObserver((entries) => { 141 + const entry = entries[0]; 142 + if (entry) { 143 + setAvatarTrackWidth(entry.contentRect.width); 144 + } 145 + }); 146 + 147 + observer.observe(element); 148 + setAvatarTrackWidth(element.getBoundingClientRect().width); 149 + onCleanup(() => observer.disconnect()); 150 + }); 151 + 152 + async function loadProfile(sequence: number, actor: string) { 153 + try { 154 + const profile = await getProfile(actor); 155 + if (sequence !== requestSequence || actor !== activeActor()) { 156 + return; 157 + } 158 + 159 + setState({ profile, profileError: null, profileLoading: false }); 160 + } catch (error) { 161 + if (sequence !== requestSequence || actor !== activeActor()) { 162 + return; 163 + } 164 + 165 + setState({ profile: null, profileError: normalizeError(error), profileLoading: false }); 166 + } 167 + } 168 + 169 + async function loadAuthorPage(sequence: number, actor: string, loadMore: boolean) { 170 + const feed = state.authorFeed; 171 + if (feed.loading || feed.loadingMore) { 172 + return; 173 + } 174 + 175 + setState("authorFeed", loadMore ? "loadingMore" : "loading", true); 176 + 177 + try { 178 + const response = await getAuthorFeed(actor, loadMore ? feed.cursor : null, FEED_PAGE_SIZE); 179 + if (sequence !== requestSequence || actor !== activeActor()) { 180 + return; 181 + } 182 + 183 + applyFeedPage("authorFeed", response, loadMore); 184 + } catch (error) { 185 + if (sequence !== requestSequence || actor !== activeActor()) { 186 + return; 187 + } 188 + 189 + setState("authorFeed", { 190 + ...state.authorFeed, 191 + error: normalizeError(error), 192 + loaded: true, 193 + loading: false, 194 + loadingMore: false, 195 + }); 196 + } 197 + } 198 + 199 + async function loadLikesPage(sequence: number, actor: string, loadMore: boolean) { 200 + const feed = state.likesFeed; 201 + if (feed.loading || feed.loadingMore) { 202 + return; 203 + } 204 + 205 + setState("likesFeed", loadMore ? "loadingMore" : "loading", true); 206 + 207 + try { 208 + const response = await getActorLikes(actor, loadMore ? feed.cursor : null, FEED_PAGE_SIZE); 209 + if (sequence !== requestSequence || actor !== activeActor()) { 210 + return; 211 + } 212 + 213 + applyFeedPage("likesFeed", response, loadMore); 214 + } catch (error) { 215 + if (sequence !== requestSequence || actor !== activeActor()) { 216 + return; 217 + } 218 + 219 + setState("likesFeed", { 220 + ...state.likesFeed, 221 + error: normalizeError(error), 222 + loaded: true, 223 + loading: false, 224 + loadingMore: false, 225 + }); 226 + } 227 + } 228 + 229 + function applyFeedPage(feedKey: "authorFeed" | "likesFeed", response: FeedResponse, loadMore: boolean) { 230 + const current = state[feedKey]; 231 + const nextItems = mergeFeedItems(loadMore ? current.items : [], response.feed); 232 + 233 + setState(feedKey, { 234 + cursor: response.cursor ?? null, 235 + error: null, 236 + items: nextItems, 237 + loaded: true, 238 + loading: false, 239 + loadingMore: false, 240 + }); 241 + } 242 + 243 + function handleLoadMore() { 244 + const actor = activeActor(); 245 + if (!actor) { 246 + return; 247 + } 248 + 249 + if (state.activeTab === "likes") { 250 + void loadLikesPage(requestSequence, actor, true); 251 + return; 252 + } 253 + 254 + void loadAuthorPage(requestSequence, actor, true); 255 + } 256 + 257 + function selectTab(tab: ProfileTab) { 258 + if (tab !== state.activeTab) { 259 + setState("activeTab", tab); 260 + } 261 + } 262 + 263 + function openThread(uri: string) { 264 + navigate(buildThreadRoute(uri)); 265 + } 266 + 267 + return ( 268 + <section class="grid min-h-0 overflow-hidden rounded-4xl bg-[rgba(8,8,8,0.32)] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)]"> 269 + <div 270 + class="min-h-0 overflow-y-auto overscroll-contain" 271 + onScroll={(event) => setState("scrollTop", event.currentTarget.scrollTop)}> 272 + <Show when={!state.profileLoading} fallback={<ProfileLoadingView />}> 273 + <Show 274 + when={!state.profileError && activeProfile()} 275 + fallback={<ProfileErrorView error={state.profileError} />}> 276 + {(profile) => ( 277 + <> 278 + <ProfileHero 279 + avatarProgress={avatarProgress()} 280 + avatarScale={avatarScale()} 281 + avatarShift={avatarShift()} 282 + avatarTrackRef={(element) => { 283 + avatarTrackRef = element; 284 + }} 285 + coverOffset={coverOffset()} 286 + coverScale={coverScale()} 287 + isSelf={isSelf()} 288 + joinedLabel={joinedLabel()} 289 + pinnedPostHref={pinnedPostHref()} 290 + profile={profile()} 291 + profileBadges={profileBadges()} 292 + viewLabel={viewLabel()} /> 293 + 294 + <ProfileTabs activeTab={state.activeTab} onSelect={selectTab} /> 295 + 296 + <ProfileFeedSection 297 + activeTab={state.activeTab} 298 + cursor={activeFeedState().cursor} 299 + error={activeFeedState().error} 300 + items={visibleItems()} 301 + loading={activeFeedState().loading} 302 + loadingMore={activeFeedState().loadingMore} 303 + onLoadMore={handleLoadMore} 304 + onOpenThread={openThread} /> 305 + </> 306 + )} 307 + </Show> 308 + </Show> 309 + </div> 310 + </section> 311 + ); 312 + } 313 + 314 + function ProfileHero( 315 + props: { 316 + avatarProgress: number; 317 + avatarScale: number; 318 + avatarShift: number; 319 + avatarTrackRef: (element: HTMLDivElement) => void; 320 + coverOffset: number; 321 + coverScale: number; 322 + isSelf: boolean; 323 + joinedLabel: string | null; 324 + pinnedPostHref: string | null; 325 + profile: ProfileViewDetailed; 326 + profileBadges: string[]; 327 + viewLabel: string; 328 + }, 329 + ) { 330 + const avatarLabel = createMemo(() => getAvatarLabel(props.profile)); 331 + const displayName = createMemo(() => getDisplayName(props.profile)); 332 + const bannerStyle = createMemo(() => ({ 333 + transform: `translate3d(0, ${props.coverOffset}px, 0) scale(${props.coverScale})`, 334 + })); 335 + const avatarStyle = createMemo(() => ({ 336 + transform: `translate3d(${props.avatarShift}px, ${-10 * props.avatarProgress}px, 0) scale(${props.avatarScale})`, 337 + })); 338 + 339 + return ( 340 + <header class="relative"> 341 + <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"> 342 + <Show 343 + when={props.profile.banner} 344 + fallback={ 345 + <div 346 + class="absolute inset-0 bg-[radial-gradient(circle_at_18%_18%,rgba(125,175,255,0.22),transparent_30%),radial-gradient(circle_at_86%_24%,rgba(125,175,255,0.15),transparent_24%),linear-gradient(180deg,rgba(255,255,255,0.05),rgba(0,0,0,0.16))]" 347 + style={bannerStyle()} /> 348 + }> 349 + {(banner) => ( 350 + <img alt="" class="absolute inset-0 h-full w-full object-cover" src={banner()} style={bannerStyle()} /> 351 + )} 352 + </Show> 353 + </div> 354 + 355 + <div class="relative z-10 -mt-16 px-6 pb-6 max-[760px]:px-4 max-[520px]:px-3"> 356 + <div class="sticky top-4 z-20 mb-4" ref={props.avatarTrackRef}> 357 + <div 358 + class="relative h-32 w-32 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 transition-transform duration-100 ease-out" 359 + style={avatarStyle()}> 360 + <Show 361 + when={props.profile.avatar} 362 + fallback={ 363 + <div class="flex h-full w-full items-center justify-center text-[2rem] font-semibold text-on-surface"> 364 + {avatarLabel()} 365 + </div> 366 + }> 367 + {(avatar) => <img alt="" class="h-full w-full object-cover" src={avatar()} />} 368 + </Show> 369 + </div> 370 + </div> 371 + 372 + <div class="grid gap-5 pt-20"> 373 + <div class="flex flex-wrap items-start justify-between gap-4"> 374 + <ProfileIdentity 375 + description={props.profile.description ?? null} 376 + displayName={displayName()} 377 + handle={props.profile.handle} 378 + viewLabel={props.viewLabel} /> 379 + <ProfileBadgeRow badges={props.profileBadges} isSelf={props.isSelf} /> 380 + </div> 381 + 382 + <ProfileMetaRow 383 + did={props.profile.did} 384 + joinedLabel={props.joinedLabel} 385 + pinnedPostHref={props.pinnedPostHref} 386 + website={props.profile.website ?? null} /> 387 + 388 + <div class="flex flex-wrap gap-6"> 389 + <ProfileStat label="Following" value={props.profile.followsCount} /> 390 + <ProfileStat label="Followers" value={props.profile.followersCount} /> 391 + <ProfileStat label="Posts" value={props.profile.postsCount} /> 392 + </div> 393 + </div> 394 + </div> 395 + </header> 396 + ); 397 + } 398 + 399 + function ProfileStat(props: { label: string; value?: number | null }) { 400 + return ( 401 + <div class="grid gap-1"> 402 + <span class="text-lg font-semibold tracking-[-0.02em] text-on-surface">{formatCount(props.value ?? 0)}</span> 403 + <span class="text-xs uppercase tracking-[0.12em] text-on-surface-variant">{props.label}</span> 404 + </div> 405 + ); 406 + } 407 + 408 + function ProfileIdentity( 409 + props: { description: string | null; displayName: string; handle: string; viewLabel: string }, 410 + ) { 411 + return ( 412 + <div class="grid gap-3"> 413 + <div class="grid gap-1"> 414 + <p class="overline-copy text-[0.68rem] text-on-surface-variant">{props.viewLabel}</p> 415 + <h1 class="m-0 text-[clamp(2rem,4vw,3rem)] font-semibold leading-[0.96] tracking-[-0.04em] text-on-surface"> 416 + {props.displayName} 417 + </h1> 418 + <p class="m-0 text-sm text-on-surface-variant">@{props.handle.replace(/^@/, "")}</p> 419 + </div> 420 + <Show when={props.description}> 421 + {(description) => ( 422 + <p class="m-0 max-w-3xl whitespace-pre-wrap text-[0.98rem] leading-[1.7] text-on-secondary-container"> 423 + {description()} 424 + </p> 425 + )} 426 + </Show> 427 + </div> 428 + ); 429 + } 430 + 431 + function ProfileBadgeRow(props: { badges: string[]; isSelf: boolean }) { 432 + return ( 433 + <div class="flex flex-wrap items-center justify-end gap-2"> 434 + <For each={props.badges}> 435 + {(badge) => ( 436 + <span class="inline-flex items-center rounded-full bg-white/6 px-3 py-2 text-xs font-medium text-on-surface"> 437 + {badge} 438 + </span> 439 + )} 440 + </For> 441 + <Show when={props.badges.length === 0}> 442 + <span class="inline-flex items-center rounded-full bg-white/5 px-3 py-2 text-xs font-medium text-on-surface-variant"> 443 + {props.isSelf ? "Signed in" : "Public profile"} 444 + </span> 445 + </Show> 446 + </div> 447 + ); 448 + } 449 + 450 + function ProfileMetaRow( 451 + props: { did: string; joinedLabel: string | null; pinnedPostHref: string | null; website: string | null }, 452 + ) { 453 + return ( 454 + <div class="flex flex-wrap items-center gap-4 text-sm text-on-surface-variant"> 455 + <Show when={props.website}> 456 + {(website) => ( 457 + <a 458 + class="inline-flex items-center gap-2 text-primary no-underline transition hover:text-on-surface" 459 + href={website()} 460 + rel="noreferrer" 461 + target="_blank"> 462 + <Icon iconClass="i-ri-link" class="text-base" /> 463 + <span>{website().replace(/^https?:\/\//, "")}</span> 464 + </a> 465 + )} 466 + </Show> 467 + 468 + <Show when={props.joinedLabel}> 469 + {(joined) => ( 470 + <span class="inline-flex items-center gap-2"> 471 + <Icon iconClass="i-ri-calendar-line" class="text-base" /> 472 + <span>Joined {joined()}</span> 473 + </span> 474 + )} 475 + </Show> 476 + 477 + <span class="inline-flex items-center gap-2"> 478 + <Icon iconClass="i-ri-at-line" class="text-base" /> 479 + <span class="max-w-full break-all">{props.did}</span> 480 + </span> 481 + 482 + <Show when={props.pinnedPostHref}> 483 + {(href) => ( 484 + <a 485 + class="inline-flex items-center gap-2 rounded-full bg-white/6 px-3 py-2 text-xs font-medium text-on-surface no-underline transition hover:-translate-y-px hover:bg-white/10" 486 + href={`#${href()}`}> 487 + <Icon iconClass="i-ri-pushpin-2-line" class="text-base" /> 488 + <span>Pinned post</span> 489 + </a> 490 + )} 491 + </Show> 492 + </div> 493 + ); 494 + } 495 + 496 + function ProfileTabs(props: { activeTab: ProfileTab; onSelect: (tab: ProfileTab) => void }) { 497 + return ( 498 + <div class="sticky top-0 z-30 px-3 pb-3 pt-1 backdrop-blur-[18px] max-[520px]:px-2"> 499 + <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)]"> 500 + <div class="flex flex-wrap gap-2"> 501 + <For each={PROFILE_TABS}> 502 + {(tab) => ( 503 + <button 504 + class="rounded-full border-0 px-4 py-2.5 text-sm font-medium transition duration-150 ease-out" 505 + classList={{ 506 + "bg-white/8 text-primary shadow-[inset_0_0_0_1px_rgba(125,175,255,0.2)]": props.activeTab === tab, 507 + "text-on-surface-variant hover:bg-white/5 hover:text-on-surface": props.activeTab !== tab, 508 + }} 509 + type="button" 510 + onClick={() => props.onSelect(tab)}> 511 + {tabLabel(tab)} 512 + </button> 513 + )} 514 + </For> 515 + </div> 516 + </div> 517 + </div> 518 + ); 519 + } 520 + 521 + function ProfileFeedSection( 522 + props: { 523 + activeTab: ProfileTab; 524 + cursor: string | null; 525 + error: string | null; 526 + items: FeedViewPost[]; 527 + loading: boolean; 528 + loadingMore: boolean; 529 + onLoadMore: () => void; 530 + onOpenThread: (uri: string) => void; 531 + }, 532 + ) { 533 + return ( 534 + <section class="grid gap-3 px-3 pb-4 max-[520px]:px-2"> 535 + <Show when={!props.loading} fallback={<ProfileFeedSkeleton />}> 536 + <Switch> 537 + <Match when={props.error}> 538 + <ProfileFeedMessage 539 + body={props.error ?? "The feed could not be loaded."} 540 + title={`Could not load ${tabLabel(props.activeTab).toLowerCase()}.`} /> 541 + </Match> 542 + 543 + <Match when={props.items.length > 0}> 544 + <ProfilePostList items={props.items} onOpenThread={props.onOpenThread} /> 545 + </Match> 546 + 547 + <Match when={props.cursor}> 548 + <ProfileFeedMessage 549 + body={`No ${ 550 + tabLabel(props.activeTab).toLowerCase() 551 + } in the loaded window yet. Load more to keep scanning.`} 552 + title={`Still looking for ${tabLabel(props.activeTab).toLowerCase()}.`} /> 553 + </Match> 554 + 555 + <Match when={true}> 556 + <ProfileFeedMessage 557 + body={`This profile does not have any visible ${tabLabel(props.activeTab).toLowerCase()} yet.`} 558 + title={`No ${tabLabel(props.activeTab)} available`} /> 559 + </Match> 560 + </Switch> 561 + </Show> 562 + 563 + <Show when={props.cursor}> 564 + <ProfileLoadMoreButton 565 + activeTab={props.activeTab} 566 + loadingMore={props.loadingMore} 567 + onLoadMore={props.onLoadMore} /> 568 + </Show> 569 + </section> 570 + ); 571 + } 572 + 573 + function ProfilePostList(props: { items: FeedViewPost[]; onOpenThread: (uri: string) => void }) { 574 + return ( 575 + <div class="grid gap-3"> 576 + <For each={props.items}> 577 + {(item) => ( 578 + <PostCard 579 + post={item.post} 580 + item={item} 581 + showActions={false} 582 + onOpenThread={() => props.onOpenThread(item.post.uri)} /> 583 + )} 584 + </For> 585 + </div> 586 + ); 587 + } 588 + 589 + function ProfileLoadMoreButton(props: { activeTab: ProfileTab; loadingMore: boolean; onLoadMore: () => void }) { 590 + return ( 591 + <div class="flex justify-center py-2"> 592 + <button 593 + class="inline-flex min-h-12 items-center gap-2 rounded-full border-0 bg-white/6 px-5 text-sm font-medium text-on-surface transition duration-150 ease-out hover:-translate-y-px hover:bg-white/10 disabled:translate-y-0 disabled:opacity-70" 594 + type="button" 595 + disabled={props.loadingMore} 596 + onClick={() => props.onLoadMore()}> 597 + <Show when={props.loadingMore} fallback={<Icon iconClass="i-ri-arrow-down-circle-line" class="text-base" />}> 598 + <Icon iconClass="i-ri-loader-4-line animate-spin" class="text-base" /> 599 + </Show> 600 + <span>{props.loadingMore ? "Loading more..." : `Load more ${tabLabel(props.activeTab).toLowerCase()}`}</span> 601 + </button> 602 + </div> 603 + ); 604 + } 605 + 606 + function ProfileLoadingView() { 607 + return ( 608 + <div class="grid gap-4 p-6 max-[760px]:p-4 max-[520px]:p-3"> 609 + <div class="overflow-hidden rounded-4xl bg-white/3 p-6 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)]"> 610 + <ProfileSkeleton /> 611 + </div> 612 + <ProfileFeedSkeleton /> 613 + </div> 614 + ); 615 + } 616 + 617 + function ProfileErrorView(props: { error: string | null }) { 618 + return ( 619 + <div class="grid min-h-120 place-items-center p-6"> 620 + <ProfileFeedMessage body={props.error ?? "The profile could not be loaded."} title="Profile unavailable" /> 621 + </div> 622 + ); 623 + } 624 + 625 + function ProfileFeedSkeleton() { 626 + return ( 627 + <div class="grid gap-3"> 628 + <For each={Array.from({ length: 3 })}> 629 + {() => ( 630 + <div class="rounded-3xl bg-white/3 p-5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)]"> 631 + <div class="flex items-start gap-3"> 632 + <span class="skeleton-block h-11 w-11 rounded-full" /> 633 + <div class="grid min-w-0 flex-1 gap-2"> 634 + <span class="skeleton-block h-4 w-32 rounded-full" /> 635 + <span class="skeleton-block h-3 w-full rounded-full" /> 636 + <span class="skeleton-block h-3 w-2/3 rounded-full" /> 637 + </div> 638 + </div> 639 + </div> 640 + )} 641 + </For> 642 + </div> 643 + ); 644 + } 645 + 646 + function ProfileFeedMessage(props: { body: string; title: string }) { 647 + return ( 648 + <div class="grid place-items-center rounded-3xl bg-white/3 px-6 py-12 text-center shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)]"> 649 + <div class="grid max-w-lg gap-2"> 650 + <p class="m-0 text-lg font-semibold tracking-[-0.02em] text-on-surface">{props.title}</p> 651 + <p class="m-0 text-sm leading-relaxed text-on-surface-variant">{props.body}</p> 652 + </div> 653 + </div> 654 + ); 655 + } 656 + 657 + function tabLabel(tab: ProfileTab) { 658 + switch (tab) { 659 + case "posts": { 660 + return "Posts"; 661 + } 662 + case "replies": { 663 + return "Replies"; 664 + } 665 + case "media": { 666 + return "Media"; 667 + } 668 + default: { 669 + return "Likes"; 670 + } 671 + } 672 + } 673 + 674 + function formatJoinedDate(value?: string | null) { 675 + if (!value) { 676 + return null; 677 + } 678 + 679 + const parsed = new Date(value); 680 + if (Number.isNaN(parsed.getTime())) { 681 + return null; 682 + } 683 + 684 + return parsed.toLocaleDateString(undefined, { month: "long", year: "numeric" }); 685 + } 686 + 687 + function mergeFeedItems(current: FeedViewPost[], incoming: FeedViewPost[]) { 688 + const seen = new Set(current.map((item) => item.post.uri)); 689 + const merged = [...current]; 690 + 691 + for (const item of incoming) { 692 + if (!seen.has(item.post.uri)) { 693 + seen.add(item.post.uri); 694 + merged.push(item); 695 + } 696 + } 697 + 698 + return merged; 699 + } 700 + 701 + function clamp(value: number, min: number, max: number) { 702 + return Math.min(Math.max(value, min), max); 703 + }
+1
src/components/rail/AppRail.tsx
··· 33 33 when={props.hasSession} 34 34 fallback={<RailButton end compact={props.collapsed} href="/auth" label="Accounts" icon="profile" />}> 35 35 <RailButton end compact={props.collapsed} href="/timeline" label="Timeline" icon="timeline" /> 36 + <RailButton compact={props.collapsed} href="/profile" label="Profile" icon="profile" /> 36 37 <RailButton end compact={props.collapsed} href="/search" label="Search" icon="search" /> 37 38 <RailButton 38 39 end
+2
src/components/search/SearchPanel.tsx
··· 550 550 transition={{ duration: 0.2, delay: Math.min(index() * 0.03, 0.18) }} 551 551 role="listitem"> 552 552 <SearchResultCard 553 + authorDid={result.authorDid} 553 554 authorHandle={result.authorHandle ?? "unknown"} 554 555 source={result.source} 555 556 text={result.text ?? ""} ··· 581 582 transition={{ duration: 0.2, delay: Math.min(index() * 0.03, 0.18) }} 582 583 role="listitem"> 583 584 <SearchResultCard 585 + authorDid={post.author.did} 584 586 authorHandle={post.author.handle} 585 587 source="network" 586 588 text={typeof post.record.text === "string" ? post.record.text : ""}
+19 -4
src/components/search/SearchResultCard.tsx
··· 1 1 import { formatRelativeTime } from "$/lib/feeds"; 2 + import { buildProfileRoute } from "$/lib/profile"; 2 3 import { escapeForRegex } from "$/lib/utils/text"; 3 4 import { createMemo, type JSX, Show } from "solid-js"; 4 5 import { Icon } from "../shared/Icon"; ··· 7 8 props: { 8 9 avatarLabel: string; 9 10 authorHandle: string; 11 + profileHref: string; 10 12 time: string; 11 13 isSemantic?: boolean; 12 14 text: string | (string | JSX.Element)[]; ··· 17 19 ) { 18 20 return ( 19 21 <div class="flex gap-3"> 20 - <Avatar label={props.avatarLabel} /> 22 + <a class="shrink-0 no-underline" href={`#${props.profileHref}`}> 23 + <Avatar label={props.avatarLabel} /> 24 + </a> 21 25 <div class="min-w-0 flex-1"> 22 - <CardHeader handle={props.authorHandle} time={props.time} isSemantic={props.isSemantic} /> 26 + <CardHeader 27 + handle={props.authorHandle} 28 + profileHref={props.profileHref} 29 + time={props.time} 30 + isSemantic={props.isSemantic} /> 23 31 <TextContent text={props.text} /> 24 32 <CardFooter likes={props.likes} replies={props.replies} sourceLabel={props.sourceLabel} /> 25 33 </div> ··· 41 49 ); 42 50 } 43 51 44 - function CardHeader(props: { handle: string; time: string; isSemantic?: boolean }) { 52 + function CardHeader(props: { handle: string; profileHref: string; time: string; isSemantic?: boolean }) { 45 53 return ( 46 54 <header class="mb-2 flex flex-wrap items-center gap-x-2 gap-y-1"> 47 - <span class="wrap-break-word text-sm font-semibold text-on-surface">@{props.handle.replace(/^@/, "")}</span> 55 + <a 56 + class="wrap-break-word text-sm font-semibold text-on-surface no-underline transition hover:text-primary" 57 + href={`#${props.profileHref}`}> 58 + @{props.handle.replace(/^@/, "")} 59 + </a> 48 60 <span class="text-xs text-on-surface-variant">{props.time}</span> 49 61 <SemanticBadge isSemantic={props.isSemantic} /> 50 62 </header> ··· 97 109 } 98 110 99 111 type SearchResultCardProps = { 112 + authorDid?: string; 100 113 authorHandle: string; 101 114 source: "like" | "bookmark" | "network"; 102 115 text: string; ··· 110 123 export function SearchResultCard(props: SearchResultCardProps) { 111 124 const avatarLabel = createMemo(() => props.authorHandle.slice(0, 1).toUpperCase() || "?"); 112 125 const formattedTime = createMemo(() => (props.createdAt ? formatRelativeTime(props.createdAt) : "Unknown date")); 126 + const profileHref = createMemo(() => buildProfileRoute(props.authorHandle || props.authorDid)); 113 127 114 128 const sourceLabel = createMemo(() => { 115 129 switch (props.source) { ··· 153 167 <CardContent 154 168 avatarLabel={avatarLabel()} 155 169 authorHandle={props.authorHandle} 170 + profileHref={profileHref()} 156 171 time={formattedTime()} 157 172 isSemantic={props.isSemanticMatch} 158 173 text={highlightedText()}
+14
src/lib/api/profile.ts
··· 1 + import { parseProfile, parseProfileFeed } from "$/lib/profile"; 2 + import { invoke } from "@tauri-apps/api/core"; 3 + 4 + export async function getProfile(actor: string) { 5 + return parseProfile(await invoke("get_profile", { actor })); 6 + } 7 + 8 + export async function getAuthorFeed(actor: string, cursor?: string | null, limit?: number) { 9 + return parseProfileFeed(await invoke("get_author_feed", { actor, cursor: cursor ?? null, limit: limit ?? null })); 10 + } 11 + 12 + export async function getActorLikes(actor: string, cursor?: string | null, limit?: number) { 13 + return parseProfileFeed(await invoke("get_actor_likes", { actor, cursor: cursor ?? null, limit: limit ?? null })); 14 + }
+102
src/lib/profile.ts
··· 1 + import { isReplyItem, parseFeedResponse } from "$/lib/feeds"; 2 + import type { FeedResponse, FeedViewPost, ProfileViewDetailed } from "$/lib/types"; 3 + import { asRecord } from "./type-guards"; 4 + 5 + export type ProfileTab = "posts" | "replies" | "media" | "likes"; 6 + 7 + export function buildProfileRoute(actor?: string | null) { 8 + const trimmed = actor?.trim(); 9 + if (!trimmed) { 10 + return "/profile"; 11 + } 12 + 13 + return `/profile/${encodeURIComponent(trimmed)}`; 14 + } 15 + 16 + export function decodeProfileRouteActor(value?: string | null) { 17 + if (!value) { 18 + return null; 19 + } 20 + 21 + try { 22 + return decodeURIComponent(value); 23 + } catch { 24 + return value; 25 + } 26 + } 27 + 28 + export function getProfileRouteActor(actor: { did: string; handle?: string | null }) { 29 + return actor.handle?.trim() || actor.did; 30 + } 31 + 32 + export function parseProfile(value: unknown): ProfileViewDetailed { 33 + const record = asRecord(value); 34 + if (!record || typeof record.did !== "string" || typeof record.handle !== "string") { 35 + throw new Error("profile payload is invalid"); 36 + } 37 + 38 + const pinnedPost = asRecord(record.pinnedPost); 39 + 40 + return { 41 + avatar: optionalString(record.avatar), 42 + banner: optionalString(record.banner), 43 + createdAt: optionalString(record.createdAt), 44 + description: optionalString(record.description), 45 + did: record.did, 46 + displayName: optionalString(record.displayName), 47 + followersCount: optionalNumber(record.followersCount), 48 + followsCount: optionalNumber(record.followsCount), 49 + handle: record.handle, 50 + indexedAt: optionalString(record.indexedAt), 51 + pinnedPost: pinnedPost && typeof pinnedPost.uri === "string" 52 + ? { cid: optionalString(pinnedPost.cid), uri: pinnedPost.uri } 53 + : null, 54 + postsCount: optionalNumber(record.postsCount), 55 + pronouns: optionalString(record.pronouns), 56 + viewer: parseProfileViewer(record.viewer), 57 + website: optionalString(record.website), 58 + }; 59 + } 60 + 61 + export function parseProfileFeed(value: unknown): FeedResponse { 62 + return parseFeedResponse(value); 63 + } 64 + 65 + export function filterProfileFeed(items: FeedViewPost[], tab: ProfileTab) { 66 + switch (tab) { 67 + case "posts": { 68 + return items.filter((item) => !isReplyItem(item)); 69 + } 70 + case "replies": { 71 + return items.filter((item) => isReplyItem(item)); 72 + } 73 + case "media": { 74 + return items.filter((item) => !!item.post.embed); 75 + } 76 + default: { 77 + return items; 78 + } 79 + } 80 + } 81 + 82 + function parseProfileViewer(value: unknown) { 83 + const record = asRecord(value); 84 + if (!record) { 85 + return null; 86 + } 87 + 88 + return { 89 + blockedBy: typeof record.blockedBy === "boolean" ? record.blockedBy : null, 90 + followedBy: optionalString(record.followedBy), 91 + following: optionalString(record.following), 92 + muted: typeof record.muted === "boolean" ? record.muted : null, 93 + }; 94 + } 95 + 96 + function optionalNumber(value: unknown) { 97 + return typeof value === "number" ? value : null; 98 + } 99 + 100 + function optionalString(value: unknown) { 101 + return typeof value === "string" ? value : null; 102 + }
+21
src/lib/types.ts
··· 35 35 viewer?: AuthorViewerState | null; 36 36 }; 37 37 38 + export type ProfileViewerState = { 39 + blockedBy?: boolean | null; 40 + followedBy?: string | null; 41 + following?: string | null; 42 + muted?: boolean | null; 43 + }; 44 + 45 + export type ProfileViewDetailed = ProfileViewBasic & { 46 + banner?: string | null; 47 + createdAt?: string | null; 48 + description?: string | null; 49 + followersCount?: number | null; 50 + followsCount?: number | null; 51 + indexedAt?: string | null; 52 + pinnedPost?: { cid?: string | null; uri: string } | null; 53 + postsCount?: number | null; 54 + pronouns?: string | null; 55 + viewer?: ProfileViewerState | null; 56 + website?: string | null; 57 + }; 58 + 38 59 export type FeedGeneratorView = { 39 60 uri: string; 40 61 did: string;
+27 -1
src/router.test.tsx
··· 3 3 import type { Component, ParentProps } from "solid-js"; 4 4 import { beforeEach, describe, expect, it, vi } from "vitest"; 5 5 import { buildThreadRoute } from "./lib/feeds"; 6 + import { buildProfileRoute } from "./lib/profile"; 6 7 import { AppRouter } from "./router"; 7 8 8 9 const listenMock = vi.hoisted(() => vi.fn()); ··· 17 18 globalThis.location.hash = hash; 18 19 const renderComposer = vi.fn(() => <div data-testid="composer-view">composer</div>); 19 20 const renderNotifications = vi.fn(() => <div data-testid="notifications-view">notifications</div>); 21 + const renderProfile = vi.fn((props: { actor: string | null }) => ( 22 + <div data-testid="profile-view"> 23 + <span>{props.actor ?? "self-profile"}</span> 24 + </div> 25 + )); 20 26 const renderTimeline = vi.fn(( 21 27 props: { context: { onThreadRouteChange: (uri: string | null) => void; threadUri: string | null } }, 22 28 ) => ( ··· 36 42 renderAuth={() => <div>Auth</div>} 37 43 renderComposer={renderComposer} 38 44 renderNotifications={renderNotifications} 45 + renderProfile={renderProfile} 39 46 renderShell={Shell} 40 47 renderTimeline={renderTimeline} /> 41 48 </AppTestProviders> 42 49 )); 43 50 44 - return { renderComposer, renderNotifications, renderTimeline }; 51 + return { renderComposer, renderNotifications, renderProfile, renderTimeline }; 45 52 } 46 53 47 54 describe("AppRouter", () => { ··· 95 102 await screen.findByTestId("shell"); 96 103 97 104 expect(screen.getByTestId("shell")).toHaveAttribute("data-full-width", "true"); 105 + }); 106 + 107 + it("renders the logged-in profile route", async () => { 108 + const { renderProfile } = renderRouter("#/profile"); 109 + 110 + await screen.findByTestId("profile-view"); 111 + 112 + expect(renderProfile.mock.lastCall?.[0].actor).toBeNull(); 113 + expect(screen.getByText("self-profile")).toBeInTheDocument(); 114 + }); 115 + 116 + it("passes the decoded actor on other profile routes", async () => { 117 + const actor = "alice.bsky.social"; 118 + const { renderProfile } = renderRouter(`#${buildProfileRoute(actor)}`); 119 + 120 + await screen.findByTestId("profile-view"); 121 + 122 + expect(renderProfile.mock.lastCall?.[0].actor).toBe(actor); 123 + expect(screen.getByText(actor)).toBeInTheDocument(); 98 124 }); 99 125 });
+21
src/router.tsx
··· 8 8 import { SearchPanel } from "./components/search/SearchPanel"; 9 9 import { SettingsPanel } from "./components/settings/SettingsPanel"; 10 10 import { buildThreadRoute, decodeThreadRouteUri, TIMELINE_ROUTE } from "./lib/feeds"; 11 + import { decodeProfileRouteActor } from "./lib/profile"; 11 12 12 13 type TTimelineRouteProps = { context: { onThreadRouteChange: (uri: string | null) => void; threadUri: string | null } }; 14 + type TProfileRouteProps = { actor: string | null }; 13 15 14 16 type AppShellProps = ParentProps<{ fullWidth?: boolean }>; 15 17 ··· 17 19 renderAuth: () => JSX.Element; 18 20 renderComposer: () => JSX.Element; 19 21 renderNotifications: () => JSX.Element; 22 + renderProfile: Component<TProfileRouteProps>; 20 23 renderShell: Component<AppShellProps>; 21 24 renderTimeline: Component<TTimelineRouteProps>; 22 25 }; ··· 76 79 </ProtectedRouteView> 77 80 ); 78 81 82 + const ProfileRoute = () => ( 83 + <ProtectedRouteView> 84 + <Dynamic component={props.renderProfile} actor={null} /> 85 + </ProtectedRouteView> 86 + ); 87 + 88 + const ActorProfileRoute = () => { 89 + const params = useParams<{ actor: string }>(); 90 + 91 + return ( 92 + <ProtectedRouteView> 93 + <Dynamic component={props.renderProfile} actor={decodeProfileRouteActor(params.actor)} /> 94 + </ProtectedRouteView> 95 + ); 96 + }; 97 + 79 98 const NotificationsRoute = () => <ProtectedRouteView>{props.renderNotifications()}</ProtectedRouteView>; 80 99 81 100 const ComposerRoute = () => <ProtectedRouteView>{props.renderComposer()}</ProtectedRouteView>; ··· 104 123 <Route path="/auth" component={AuthRoute} /> 105 124 <Route path="/timeline" component={TimelineRoute} /> 106 125 <Route path="/timeline/thread/:threadUri" component={ThreadRoute} /> 126 + <Route path="/profile" component={ProfileRoute} /> 127 + <Route path="/profile/:actor" component={ActorProfileRoute} /> 107 128 <Route path="/composer" component={ComposerRoute} /> 108 129 <Route path="/search" component={SearchRoute} /> 109 130 <Route path="/notifications" component={NotificationsRoute} />