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: follow/unfollow interactions

+1159 -683
+13 -13
docs/tasks/09-profile.md
··· 11 11 - [x] `get_profile(actor: String)` - `app.bsky.actor.getProfile` 12 12 - [x] `get_author_feed(actor: String, cursor: Option<String>, limit: Option<u32>)` - `app.bsky.feed.getAuthorFeed` 13 13 - [x] `get_actor_likes(actor: String, cursor: Option<String>, limit: Option<u32>)` - `app.bsky.feed.getActorLikes` 14 - - [ ] `follow_actor(did: String)` - create `app.bsky.graph.follow` record, return record URI 15 - - [ ] `unfollow_actor(uri: String)` - delete follow record via `com.atproto.repo.deleteRecord` 16 - - [ ] `get_followers(actor: String, cursor: Option<String>, limit: Option<u32>)` - `app.bsky.graph.getFollowers` 17 - - [ ] `get_follows(actor: String, cursor: Option<String>, limit: Option<u32>)` - `app.bsky.graph.getFollows` 14 + - [x] `follow_actor(did: String)` - create `app.bsky.graph.follow` record, return record URI 15 + - [x] `unfollow_actor(uri: String)` - delete follow record via `com.atproto.repo.deleteRecord` 16 + - [x] `get_followers(actor: String, cursor: Option<String>, limit: Option<u32>)` - `app.bsky.graph.getFollowers` 17 + - [x] `get_follows(actor: String, cursor: Option<String>, limit: Option<u32>)` - `app.bsky.graph.getFollows` 18 18 19 19 ### Frontend - Profile Hero & Scroll Behavior 20 20 21 21 - [x] Profile hero section: banner image with parallax, avatar, display name, handle, bio, metadata row, stat counters 22 22 - [x] Scroll-driven avatar condensation: avatar shrinks from 128px and shifts right as user scrolls 23 - - [ ] **Fix**: Avatar, display name, and handle must move together as a joined group on scroll — as the avatar shrinks, the name and handle slide up beside it to form a compact sticky header 23 + - [x] **Fix**: Avatar, display name, and handle must move together as a joined group on scroll — as the avatar shrinks, the name and handle slide up beside it to form a compact sticky header 24 24 - [x] Badge row for relationship indicators (Following, Follows you, Muted, etc.) 25 25 - [x] Responsive: reduced padding on narrow widths, shorter banner on medium widths 26 26 ··· 36 36 37 37 ### Frontend - Follow / Unfollow 38 38 39 - - [ ] Follow/unfollow button on non-self profiles 40 - - [ ] Visual states: "Follow" (outline), "Following" (filled), "Unfollow" (hover state) 41 - - [ ] Optimistic UI update with rollback on error 42 - - [ ] Badge row updates immediately on follow/unfollow 39 + - [x] Follow/unfollow button on non-self profiles 40 + - [x] Visual states: "Follow" (outline), "Following" (filled), "Unfollow" (hover state) 41 + - [x] Optimistic UI update with rollback on error 42 + - [x] Badge row updates immediately on follow/unfollow 43 43 44 44 ### Frontend - Following & Follower Lists 45 45 46 - - [ ] Tappable follower/following stat counts open list overlay 47 - - [ ] Paginated actor list with compact cards (avatar, name, handle, bio snippet, follow button) 48 - - [ ] `Presence` slide-up overlay with backdrop blur 49 - - [ ] Cursor-based pagination with infinite scroll or "Load more" 46 + - [x] Tappable follower/following stat counts open list overlay 47 + - [x] Paginated actor list with compact cards (avatar, name, handle, bio snippet, follow button) 48 + - [x] `Presence` slide-up overlay with backdrop blur 49 + - [x] Cursor-based pagination with infinite scroll or "Load more" 50 50 51 51 ### Parking Lot 52 52
+1 -1
docs/tasks/12-social-diagnostics.md
··· 13 13 - [ ] `get_many_to_many_counts(subject: String, source: String, path_to_other: String)` - `blue.microcosm.links.getManyToManyCounts` 14 14 - [ ] `get_many_to_many(subject: String, source: String, path_to_other: String, limit: Option<u32>)` - `blue.microcosm.links.getManyToMany` 15 15 16 - ### Backend - Diagnostics Commands (`src-tauri/src/commands/diagnostics.rs`) 16 + ### Backend - Diagnostics Commands (`src-tauri/src/diagnostics.rs` & `src-tauri/src/commands/diagnostics.rs`) 17 17 18 18 - [ ] `get_account_lists(did: String)` - query Constellation for `app.bsky.graph.listitem:subject` backlinks, extract list URIs, hydrate via `app.bsky.graph.getList` 19 19 - [ ] `get_account_labels(did: String)` - query `com.atproto.label.queryLabels` (Bluesky API)
+1 -1
docs/tasks/13-release.md
··· 8 8 9 9 ### App Identity & Branding 10 10 11 - - [ ] Final app icon set: generate all required sizes from source SVG 11 + - [x] Final app icon set: generate all required sizes from source SVG 12 12 - macOS: `icon.icns` (16–1024px) 13 13 - Windows: `icon.ico` (16–256px) 14 14 - Linux: `icon.png` at 32, 128, 256, 512px
+269
src/components/profile/ProfileActorList.tsx
··· 1 + import { getAvatarLabel, getDisplayName } from "$/lib/feeds"; 2 + import type { ProfileViewBasic } from "$/lib/types"; 3 + import { createMemo, For, onMount, Show } from "solid-js"; 4 + import { Motion } from "solid-motionone"; 5 + import { Icon } from "../shared/Icon"; 6 + import type { ActorListState } from "./profile-state"; 7 + 8 + function ActorListHeader(props: { onClose: () => void; title: string }) { 9 + return ( 10 + <div class="flex shrink-0 items-center justify-between px-5 py-4"> 11 + <div class="grid gap-1"> 12 + <p class="m-0 text-[0.68rem] uppercase tracking-[0.12em] text-on-surface-variant">Profile graph</p> 13 + <p class="m-0 text-base font-semibold text-on-surface">{props.title}</p> 14 + </div> 15 + <button 16 + class="flex h-8 w-8 items-center justify-center rounded-full border-0 bg-white/6 text-on-surface-variant transition hover:bg-white/10 hover:text-on-surface" 17 + type="button" 18 + onClick={() => props.onClose()}> 19 + <Icon iconClass="i-ri-close-line" class="text-base" /> 20 + </button> 21 + </div> 22 + ); 23 + } 24 + 25 + function ActorListLoadMoreButton(props: { loadingMore: boolean; onLoadMore: () => void }) { 26 + return ( 27 + <div class="flex justify-center py-4"> 28 + <button 29 + class="inline-flex min-h-10 items-center gap-2 rounded-full border-0 bg-white/6 px-5 text-sm font-medium text-on-surface transition hover:-translate-y-px hover:bg-white/10 disabled:translate-y-0 disabled:opacity-70" 30 + disabled={props.loadingMore} 31 + type="button" 32 + onClick={() => props.onLoadMore()}> 33 + <Show when={props.loadingMore}> 34 + <Icon iconClass="i-ri-loader-4-line animate-spin" class="text-base" /> 35 + </Show> 36 + {props.loadingMore ? "Loading..." : "Load more"} 37 + </button> 38 + </div> 39 + ); 40 + } 41 + 42 + function ActorListContent( 43 + props: { 44 + actorList: ActorListState; 45 + onFollowActor: (actor: ProfileViewBasic) => void; 46 + onSelectActor: (actor: ProfileViewBasic) => void; 47 + onUnfollowActor: (actor: ProfileViewBasic) => void; 48 + sessionDid: string | null; 49 + title: string; 50 + }, 51 + ) { 52 + return ( 53 + <Show when={!props.actorList.loading} fallback={<ActorListSkeleton />}> 54 + <Show 55 + when={!props.actorList.error} 56 + fallback={ 57 + <div class="grid place-items-center py-12 text-sm text-on-surface-variant">{props.actorList.error}</div> 58 + }> 59 + <Show 60 + when={props.actorList.actors.length > 0} 61 + fallback={ 62 + <div class="grid place-items-center py-12 text-sm text-on-surface-variant"> 63 + No {props.title.toLowerCase()} yet. 64 + </div> 65 + }> 66 + <div class="grid gap-2 pt-1"> 67 + <For each={props.actorList.actors}> 68 + {(actor) => ( 69 + <ActorCard 70 + actor={actor} 71 + followLoading={!!props.actorList.followPendingByDid[actor.did]} 72 + isSelf={actor.did === props.sessionDid} 73 + onFollow={() => props.onFollowActor(actor)} 74 + onSelect={() => props.onSelectActor(actor)} 75 + onUnfollow={() => props.onUnfollowActor(actor)} /> 76 + )} 77 + </For> 78 + </div> 79 + </Show> 80 + </Show> 81 + </Show> 82 + ); 83 + } 84 + 85 + function ActorCard( 86 + props: { 87 + actor: ProfileViewBasic; 88 + followLoading: boolean; 89 + isSelf: boolean; 90 + onFollow: () => void; 91 + onSelect: () => void; 92 + onUnfollow: () => void; 93 + }, 94 + ) { 95 + const label = createMemo(() => getAvatarLabel(props.actor)); 96 + const name = createMemo(() => getDisplayName(props.actor)); 97 + const isFollowing = createMemo(() => !!props.actor.viewer?.following); 98 + 99 + return ( 100 + <article class="rounded-3xl bg-white/4 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)]"> 101 + <div class="flex items-start gap-3"> 102 + <button 103 + class="flex min-w-0 flex-1 items-start gap-3 border-0 bg-transparent p-0 text-left transition hover:opacity-90" 104 + type="button" 105 + onClick={() => props.onSelect()}> 106 + <div class="h-11 w-11 shrink-0 overflow-hidden rounded-full bg-surface-container-high"> 107 + <Show 108 + when={props.actor.avatar} 109 + fallback={ 110 + <div class="flex h-full w-full items-center justify-center text-sm font-semibold text-on-surface"> 111 + {label()} 112 + </div> 113 + }> 114 + {(avatar) => <img alt="" class="h-full w-full object-cover" src={avatar()} />} 115 + </Show> 116 + </div> 117 + <ActorCardDetails actor={props.actor} name={name()} /> 118 + </button> 119 + 120 + <Show when={!props.isSelf}> 121 + <ActorCardFollowButton 122 + isFollowing={isFollowing()} 123 + loading={props.followLoading} 124 + onFollow={props.onFollow} 125 + onUnfollow={props.onUnfollow} /> 126 + </Show> 127 + </div> 128 + </article> 129 + ); 130 + } 131 + 132 + function ActorCardDetails(props: { actor: ProfileViewBasic; name: string }) { 133 + return ( 134 + <div class="grid min-w-0 flex-1 gap-1"> 135 + <div class="flex flex-wrap items-center gap-2"> 136 + <p class="m-0 truncate text-sm font-medium text-on-surface">{props.name}</p> 137 + <p class="m-0 truncate text-xs text-on-surface-variant">@{props.actor.handle.replace(/^@/, "")}</p> 138 + </div> 139 + <Show when={props.actor.description}> 140 + {(description) => ( 141 + <p 142 + class="m-0 overflow-hidden text-sm leading-relaxed text-on-secondary-container" 143 + style={{ "-webkit-box-orient": "vertical", "-webkit-line-clamp": "2", display: "-webkit-box" }}> 144 + {description()} 145 + </p> 146 + )} 147 + </Show> 148 + </div> 149 + ); 150 + } 151 + 152 + function ActorCardFollowButton( 153 + props: { isFollowing: boolean; loading: boolean; onFollow: () => void; onUnfollow: () => void }, 154 + ) { 155 + return ( 156 + <Show 157 + when={props.isFollowing} 158 + fallback={ 159 + <button 160 + class="inline-flex min-h-9 shrink-0 items-center gap-2 rounded-full border border-white/16 bg-transparent px-4 text-sm font-medium text-on-surface transition hover:bg-white/6 disabled:opacity-50" 161 + disabled={props.loading} 162 + type="button" 163 + onClick={() => props.onFollow()}> 164 + <Show when={props.loading} fallback={<Icon kind="follow" class="text-sm" />}> 165 + <Icon iconClass="i-ri-loader-4-line animate-spin" class="text-sm" /> 166 + </Show> 167 + Follow 168 + </button> 169 + }> 170 + <button 171 + class="group inline-flex min-h-9 shrink-0 items-center gap-2 rounded-full bg-primary/15 px-4 text-sm font-medium text-primary shadow-[inset_0_0_0_1px_rgba(125,175,255,0.25)] transition hover:bg-red-500/15 hover:text-red-400 hover:shadow-[inset_0_0_0_1px_rgba(239,68,68,0.25)] disabled:opacity-50" 172 + disabled={props.loading} 173 + type="button" 174 + onClick={() => props.onUnfollow()}> 175 + <Show when={props.loading} fallback={<Icon iconClass="i-ri-check-line" class="text-sm" />}> 176 + <Icon iconClass="i-ri-loader-4-line animate-spin" class="text-sm" /> 177 + </Show> 178 + <span class="group-hover:hidden">Following</span> 179 + <span class="hidden group-hover:inline">Unfollow</span> 180 + </button> 181 + </Show> 182 + ); 183 + } 184 + 185 + function ActorListSkeleton() { 186 + return ( 187 + <div class="grid gap-2 pt-1"> 188 + <For each={Array.from({ length: 6 })}> 189 + {() => ( 190 + <div class="rounded-3xl bg-white/4 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)]"> 191 + <div class="flex items-start gap-3"> 192 + <span class="skeleton-block h-11 w-11 shrink-0 rounded-full" /> 193 + <div class="grid flex-1 gap-1.5"> 194 + <span class="skeleton-block h-3.5 w-32 rounded-full" /> 195 + <span class="skeleton-block h-3 w-24 rounded-full" /> 196 + <span class="skeleton-block h-3 w-full rounded-full" /> 197 + </div> 198 + </div> 199 + </div> 200 + )} 201 + </For> 202 + </div> 203 + ); 204 + } 205 + 206 + export function ActorListOverlay( 207 + props: { 208 + actorList: ActorListState; 209 + onClose: () => void; 210 + onFollowActor: (actor: ProfileViewBasic) => void; 211 + onLoadMore: () => void; 212 + onSelectActor: (actor: ProfileViewBasic) => void; 213 + onUnfollowActor: (actor: ProfileViewBasic) => void; 214 + sessionDid: string | null; 215 + }, 216 + ) { 217 + const title = createMemo(() => props.actorList.kind === "followers" ? "Followers" : "Following"); 218 + let overlayRef: HTMLDivElement | undefined; 219 + 220 + onMount(() => { 221 + queueMicrotask(() => overlayRef?.focus()); 222 + }); 223 + 224 + return ( 225 + <Motion.div 226 + ref={(element) => { 227 + overlayRef = element; 228 + }} 229 + aria-modal="true" 230 + class="absolute inset-0 z-50 flex items-end bg-[rgba(5,5,5,0.58)] p-3 backdrop-blur-xl" 231 + initial={{ opacity: 0 }} 232 + animate={{ opacity: 1 }} 233 + exit={{ opacity: 0 }} 234 + role="dialog" 235 + tabIndex={-1} 236 + transition={{ duration: 0.16 }} 237 + onClick={() => props.onClose()} 238 + onKeyDown={(event) => { 239 + if (event.key === "Escape") { 240 + event.preventDefault(); 241 + props.onClose(); 242 + } 243 + }}> 244 + <Motion.div 245 + class="flex max-h-[min(42rem,calc(100%-0.75rem))] w-full flex-col overflow-hidden rounded-4xl bg-[rgba(20,20,20,0.94)] shadow-[0_30px_80px_rgba(0,0,0,0.5),inset_0_0_0_1px_rgba(255,255,255,0.04)]" 246 + initial={{ opacity: 0.96, y: 40 }} 247 + animate={{ opacity: 1, y: 0 }} 248 + exit={{ opacity: 0.96, y: 40 }} 249 + transition={{ duration: 0.18 }} 250 + onClick={(event) => event.stopPropagation()}> 251 + <ActorListHeader onClose={props.onClose} title={title()} /> 252 + 253 + <div class="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 pb-3"> 254 + <ActorListContent 255 + actorList={props.actorList} 256 + onFollowActor={props.onFollowActor} 257 + onSelectActor={props.onSelectActor} 258 + onUnfollowActor={props.onUnfollowActor} 259 + sessionDid={props.sessionDid} 260 + title={title()} /> 261 + 262 + <Show when={props.actorList.cursor}> 263 + <ActorListLoadMoreButton loadingMore={props.actorList.loadingMore} onLoadMore={props.onLoadMore} /> 264 + </Show> 265 + </div> 266 + </Motion.div> 267 + </Motion.div> 268 + ); 269 + }
+123
src/components/profile/ProfileFeed.tsx
··· 1 + import type { ProfileTab } from "$/lib/profile"; 2 + import type { FeedViewPost } from "$/lib/types"; 3 + import { For, Match, Show, Switch } from "solid-js"; 4 + import { PostCard } from "../feeds/PostCard"; 5 + import { Icon } from "../shared/Icon"; 6 + import { tabLabel } from "./profile-state"; 7 + 8 + function ProfilePostList(props: { items: FeedViewPost[]; onOpenThread: (uri: string) => void }) { 9 + return ( 10 + <div class="grid gap-3"> 11 + <For each={props.items}> 12 + {(item) => ( 13 + <PostCard 14 + post={item.post} 15 + item={item} 16 + showActions={false} 17 + onOpenThread={() => props.onOpenThread(item.post.uri)} /> 18 + )} 19 + </For> 20 + </div> 21 + ); 22 + } 23 + 24 + function ProfileLoadMoreButton(props: { activeTab: ProfileTab; loadingMore: boolean; onLoadMore: () => void }) { 25 + return ( 26 + <div class="flex justify-center py-2"> 27 + <button 28 + 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" 29 + type="button" 30 + disabled={props.loadingMore} 31 + onClick={() => props.onLoadMore()}> 32 + <Show when={props.loadingMore} fallback={<Icon iconClass="i-ri-arrow-down-circle-line" class="text-base" />}> 33 + <Icon iconClass="i-ri-loader-4-line animate-spin" class="text-base" /> 34 + </Show> 35 + <span>{props.loadingMore ? "Loading more..." : `Load more ${tabLabel(props.activeTab).toLowerCase()}`}</span> 36 + </button> 37 + </div> 38 + ); 39 + } 40 + 41 + export function ProfileFeedSkeleton() { 42 + return ( 43 + <div class="grid gap-3"> 44 + <For each={Array.from({ length: 3 })}> 45 + {() => ( 46 + <div class="rounded-3xl bg-white/3 p-5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)]"> 47 + <div class="flex items-start gap-3"> 48 + <span class="skeleton-block h-11 w-11 rounded-full" /> 49 + <div class="grid min-w-0 flex-1 gap-2"> 50 + <span class="skeleton-block h-4 w-32 rounded-full" /> 51 + <span class="skeleton-block h-3 w-full rounded-full" /> 52 + <span class="skeleton-block h-3 w-2/3 rounded-full" /> 53 + </div> 54 + </div> 55 + </div> 56 + )} 57 + </For> 58 + </div> 59 + ); 60 + } 61 + 62 + export function ProfileFeedMessage(props: { body: string; title: string }) { 63 + return ( 64 + <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)]"> 65 + <div class="grid max-w-lg gap-2"> 66 + <p class="m-0 text-lg font-semibold tracking-[-0.02em] text-on-surface">{props.title}</p> 67 + <p class="m-0 text-sm leading-relaxed text-on-surface-variant">{props.body}</p> 68 + </div> 69 + </div> 70 + ); 71 + } 72 + 73 + export function ProfileFeedSection( 74 + props: { 75 + activeTab: ProfileTab; 76 + cursor: string | null; 77 + error: string | null; 78 + items: FeedViewPost[]; 79 + loading: boolean; 80 + loadingMore: boolean; 81 + onLoadMore: () => void; 82 + onOpenThread: (uri: string) => void; 83 + }, 84 + ) { 85 + return ( 86 + <section class="grid gap-3 px-3 pb-4 max-[520px]:px-2"> 87 + <Show when={!props.loading} fallback={<ProfileFeedSkeleton />}> 88 + <Switch> 89 + <Match when={props.error}> 90 + <ProfileFeedMessage 91 + body={props.error ?? "The feed could not be loaded."} 92 + title={`Could not load ${tabLabel(props.activeTab).toLowerCase()}.`} /> 93 + </Match> 94 + 95 + <Match when={props.items.length > 0}> 96 + <ProfilePostList items={props.items} onOpenThread={props.onOpenThread} /> 97 + </Match> 98 + 99 + <Match when={props.cursor}> 100 + <ProfileFeedMessage 101 + body={`No ${ 102 + tabLabel(props.activeTab).toLowerCase() 103 + } in the loaded window yet. Load more to keep scanning.`} 104 + title={`Still looking for ${tabLabel(props.activeTab).toLowerCase()}.`} /> 105 + </Match> 106 + 107 + <Match when={true}> 108 + <ProfileFeedMessage 109 + body={`This profile does not have any visible ${tabLabel(props.activeTab).toLowerCase()} yet.`} 110 + title={`No ${tabLabel(props.activeTab)} available`} /> 111 + </Match> 112 + </Switch> 113 + </Show> 114 + 115 + <Show when={props.cursor}> 116 + <ProfileLoadMoreButton 117 + activeTab={props.activeTab} 118 + loadingMore={props.loadingMore} 119 + onLoadMore={props.onLoadMore} /> 120 + </Show> 121 + </section> 122 + ); 123 + }
+310
src/components/profile/ProfileHero.tsx
··· 1 + import { Icon } from "$/components/shared/Icon"; 2 + import { getAvatarLabel, getDisplayName } from "$/lib/feeds"; 3 + import type { ProfileViewDetailed } from "$/lib/types"; 4 + import { formatCount } from "$/lib/utils/text"; 5 + import { createMemo, For, Show } from "solid-js"; 6 + 7 + function ProfileHeroActions( 8 + props: { 9 + badges: string[]; 10 + followLoading: boolean; 11 + isFollowing: boolean; 12 + isSelf: boolean; 13 + onFollow: () => void; 14 + onMessage: () => void; 15 + onUnfollow: () => void; 16 + }, 17 + ) { 18 + return ( 19 + <div class="flex flex-col items-end gap-2"> 20 + <Show when={!props.isSelf}> 21 + <div class="flex flex-wrap justify-end gap-2"> 22 + <MessageButton onClick={props.onMessage} /> 23 + <FollowButton 24 + isFollowing={props.isFollowing} 25 + loading={props.followLoading} 26 + onFollow={props.onFollow} 27 + onUnfollow={props.onUnfollow} /> 28 + </div> 29 + </Show> 30 + <ProfileBadgeRow badges={props.badges} isSelf={props.isSelf} /> 31 + </div> 32 + ); 33 + } 34 + 35 + function ProfileStat(props: { label: string; onClick?: () => void; value?: number | null }) { 36 + return ( 37 + <Show 38 + when={props.onClick} 39 + fallback={ 40 + <div class="grid gap-1"> 41 + <span class="text-lg font-semibold tracking-[-0.02em] text-on-surface">{formatCount(props.value ?? 0)}</span> 42 + <span class="text-xs uppercase tracking-[0.12em] text-on-surface-variant">{props.label}</span> 43 + </div> 44 + }> 45 + {(onClick) => ( 46 + <button 47 + class="grid gap-1 text-left transition duration-150 ease-out hover:opacity-80" 48 + type="button" 49 + onClick={onClick()}> 50 + <span class="text-lg font-semibold tracking-[-0.02em] text-on-surface">{formatCount(props.value ?? 0)}</span> 51 + <span class="text-xs uppercase tracking-[0.12em] text-on-surface-variant">{props.label}</span> 52 + </button> 53 + )} 54 + </Show> 55 + ); 56 + } 57 + 58 + function StickyIdentity(props: { displayName: string; handle: string; progress: number }) { 59 + const style = createMemo(() => ({ 60 + opacity: `${Math.min(1, props.progress * 1.35)}`, 61 + transform: `translate3d(${(1 - props.progress) * -18}px, ${(1 - props.progress) * 52}px, 0) scale(${ 62 + 0.96 + props.progress * 0.04 63 + })`, 64 + })); 65 + 66 + return ( 67 + <div class="mb-1 min-w-0 transition-[opacity,transform] duration-100 ease-out" style={style()}> 68 + <p class="m-0 truncate text-lg font-semibold leading-tight tracking-[-0.02em] text-on-surface"> 69 + {props.displayName} 70 + </p> 71 + <p class="m-0 truncate text-sm leading-tight text-on-surface-variant">@{props.handle.replace(/^@/, "")}</p> 72 + </div> 73 + ); 74 + } 75 + 76 + function ProfileIdentity( 77 + props: { description: string | null; displayName: string; handle: string; viewLabel: string }, 78 + ) { 79 + return ( 80 + <div class="grid gap-3"> 81 + <div class="grid gap-1"> 82 + <p class="overline-copy text-[0.68rem] text-on-surface-variant">{props.viewLabel}</p> 83 + <h1 class="m-0 text-[clamp(2rem,4vw,3rem)] font-semibold leading-[0.96] tracking-[-0.04em] text-on-surface"> 84 + {props.displayName} 85 + </h1> 86 + <p class="m-0 text-sm text-on-surface-variant">@{props.handle.replace(/^@/, "")}</p> 87 + </div> 88 + <Show when={props.description}> 89 + {(description) => ( 90 + <p class="m-0 max-w-3xl whitespace-pre-wrap text-[0.98rem] leading-[1.7] text-on-secondary-container"> 91 + {description()} 92 + </p> 93 + )} 94 + </Show> 95 + </div> 96 + ); 97 + } 98 + 99 + function ProfileBadgeRow(props: { badges: string[]; isSelf: boolean }) { 100 + return ( 101 + <div class="flex flex-wrap items-center justify-end gap-2"> 102 + <For each={props.badges}> 103 + {(badge) => ( 104 + <span class="inline-flex items-center rounded-full bg-white/6 px-3 py-2 text-xs font-medium text-on-surface"> 105 + {badge} 106 + </span> 107 + )} 108 + </For> 109 + <Show when={props.badges.length === 0}> 110 + <span class="inline-flex items-center rounded-full bg-white/5 px-3 py-2 text-xs font-medium text-on-surface-variant"> 111 + {props.isSelf ? "Signed in" : "Public profile"} 112 + </span> 113 + </Show> 114 + </div> 115 + ); 116 + } 117 + 118 + function ProfileMetaRow( 119 + props: { did: string; joinedLabel: string | null; pinnedPostHref: string | null; website: string | null }, 120 + ) { 121 + return ( 122 + <div class="flex flex-wrap items-center gap-4 text-sm text-on-surface-variant"> 123 + <Show when={props.website}> 124 + {(website) => ( 125 + <a 126 + class="inline-flex items-center gap-2 text-primary no-underline transition hover:text-on-surface" 127 + href={website()} 128 + rel="noreferrer" 129 + target="_blank"> 130 + <Icon iconClass="i-ri-link" class="text-base" /> 131 + <span>{website().replace(/^https?:\/\//, "")}</span> 132 + </a> 133 + )} 134 + </Show> 135 + 136 + <Show when={props.joinedLabel}> 137 + {(joined) => ( 138 + <span class="inline-flex items-center gap-2"> 139 + <Icon iconClass="i-ri-calendar-line" class="text-base" /> 140 + <span>Joined {joined()}</span> 141 + </span> 142 + )} 143 + </Show> 144 + 145 + <span class="inline-flex items-center gap-2"> 146 + <Icon iconClass="i-ri-at-line" class="text-base" /> 147 + <span class="max-w-full break-all">{props.did}</span> 148 + </span> 149 + 150 + <Show when={props.pinnedPostHref}> 151 + {(href) => ( 152 + <a 153 + 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" 154 + href={`#${href()}`}> 155 + <Icon iconClass="i-ri-pushpin-2-line" class="text-base" /> 156 + <span>Pinned post</span> 157 + </a> 158 + )} 159 + </Show> 160 + </div> 161 + ); 162 + } 163 + 164 + function FollowButton(props: { isFollowing: boolean; loading: boolean; onFollow: () => void; onUnfollow: () => void }) { 165 + return ( 166 + <Show 167 + when={props.isFollowing} 168 + fallback={ 169 + <button 170 + class="inline-flex min-h-9 items-center gap-2 rounded-full border border-white/20 bg-transparent px-5 text-sm font-medium text-on-surface transition duration-150 ease-out hover:bg-white/5 disabled:opacity-50" 171 + disabled={props.loading} 172 + type="button" 173 + onClick={props.onFollow}> 174 + <Show when={props.loading}> 175 + <Icon iconClass="i-ri-loader-4-line animate-spin" class="text-base" /> 176 + </Show> 177 + Follow 178 + </button> 179 + }> 180 + <button 181 + class="group inline-flex min-h-9 items-center gap-2 rounded-full bg-primary/15 px-5 text-sm font-medium text-primary shadow-[inset_0_0_0_1px_rgba(125,175,255,0.25)] transition duration-150 ease-out hover:bg-red-500/15 hover:text-red-400 hover:shadow-[inset_0_0_0_1px_rgba(239,68,68,0.25)] disabled:opacity-50" 182 + disabled={props.loading} 183 + type="button" 184 + onClick={() => props.onUnfollow()}> 185 + <Show when={props.loading}> 186 + <Icon iconClass="i-ri-loader-4-line animate-spin" class="text-base" /> 187 + </Show> 188 + <span class="group-hover:hidden">Following</span> 189 + <span class="hidden group-hover:inline">Unfollow</span> 190 + </button> 191 + </Show> 192 + ); 193 + } 194 + 195 + function MessageButton(props: { onClick: () => void }) { 196 + return ( 197 + <button 198 + class="inline-flex min-h-9 items-center gap-2 rounded-full border border-white/12 bg-white/6 px-4 text-sm font-medium text-on-surface transition duration-150 ease-out hover:bg-white/10" 199 + type="button" 200 + onClick={() => props.onClick()}> 201 + <Icon kind="messages" class="text-base" /> 202 + Message 203 + </button> 204 + ); 205 + } 206 + 207 + export function ProfileHero( 208 + props: { 209 + avatarProgress: number; 210 + avatarScale: number; 211 + coverOffset: number; 212 + coverScale: number; 213 + followLoading: boolean; 214 + isSelf: boolean; 215 + joinedLabel: string | null; 216 + onFollow: () => void; 217 + onMessage: () => void; 218 + onOpenFollowers: () => void; 219 + onOpenFollows: () => void; 220 + onUnfollow: () => void; 221 + pinnedPostHref: string | null; 222 + profile: ProfileViewDetailed; 223 + profileBadges: string[]; 224 + viewLabel: string; 225 + }, 226 + ) { 227 + const avatarLabel = createMemo(() => getAvatarLabel(props.profile)); 228 + const displayName = createMemo(() => getDisplayName(props.profile)); 229 + const isFollowing = createMemo(() => !!props.profile.viewer?.following); 230 + const bannerStyle = createMemo(() => ({ 231 + transform: `translate3d(0, ${props.coverOffset}px, 0) scale(${props.coverScale})`, 232 + })); 233 + const avatarStyle = createMemo(() => ({ 234 + transform: `translate3d(${28 * props.avatarProgress}px, 0, 0) scale(${props.avatarScale})`, 235 + "transform-origin": "bottom left", 236 + })); 237 + 238 + return ( 239 + <header class="relative"> 240 + <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"> 241 + <Show 242 + when={props.profile.banner} 243 + fallback={ 244 + <div 245 + 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))]" 246 + style={bannerStyle()} /> 247 + }> 248 + {(banner) => ( 249 + <img alt="" class="absolute inset-0 h-full w-full object-cover" src={banner()} style={bannerStyle()} /> 250 + )} 251 + </Show> 252 + </div> 253 + 254 + <div class="relative z-10 -mt-16 px-6 pb-6 max-[760px]:px-4 max-[520px]:px-3"> 255 + <div class="sticky top-4 z-20 mb-4 flex items-center gap-3"> 256 + <div 257 + 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 transition-transform duration-100 ease-out" 258 + style={avatarStyle()}> 259 + <Show 260 + when={props.profile.avatar} 261 + fallback={ 262 + <div class="flex h-full w-full items-center justify-center text-[2rem] font-semibold text-on-surface"> 263 + {avatarLabel()} 264 + </div> 265 + }> 266 + {(avatar) => <img alt="" class="h-full w-full object-cover" src={avatar()} />} 267 + </Show> 268 + </div> 269 + 270 + <StickyIdentity displayName={displayName()} handle={props.profile.handle} progress={props.avatarProgress} /> 271 + </div> 272 + 273 + <div class="grid gap-5 pt-20"> 274 + <div 275 + class="flex flex-wrap items-start justify-between gap-4 transition-opacity duration-100 ease-out" 276 + style={{ 277 + opacity: 1 - props.avatarProgress, 278 + transform: `translate3d(0, ${props.avatarProgress * -12}px, 0)`, 279 + }}> 280 + <ProfileIdentity 281 + description={props.profile.description ?? null} 282 + displayName={displayName()} 283 + handle={props.profile.handle} 284 + viewLabel={props.viewLabel} /> 285 + <ProfileHeroActions 286 + badges={props.profileBadges} 287 + followLoading={props.followLoading} 288 + isFollowing={isFollowing()} 289 + isSelf={props.isSelf} 290 + onFollow={props.onFollow} 291 + onMessage={props.onMessage} 292 + onUnfollow={props.onUnfollow} /> 293 + </div> 294 + 295 + <ProfileMetaRow 296 + did={props.profile.did} 297 + joinedLabel={props.joinedLabel} 298 + pinnedPostHref={props.pinnedPostHref} 299 + website={props.profile.website ?? null} /> 300 + 301 + <div class="flex flex-wrap gap-6"> 302 + <ProfileStat label="Following" value={props.profile.followsCount} onClick={props.onOpenFollows} /> 303 + <ProfileStat label="Followers" value={props.profile.followersCount} onClick={props.onOpenFollowers} /> 304 + <ProfileStat label="Posts" value={props.profile.postsCount} /> 305 + </div> 306 + </div> 307 + </div> 308 + </header> 309 + ); 310 + }
+172
src/components/profile/ProfilePanel.test.tsx
··· 1 + import { AppTestProviders } from "$/test/providers"; 2 + import { fireEvent, render, screen, waitFor, within } from "@solidjs/testing-library"; 3 + import { beforeEach, describe, expect, it, vi } from "vitest"; 4 + import { ProfilePanel } from "./ProfilePanel"; 5 + 6 + const followActorMock = vi.hoisted(() => vi.fn()); 7 + const getActorLikesMock = vi.hoisted(() => vi.fn()); 8 + const getAuthorFeedMock = vi.hoisted(() => vi.fn()); 9 + const getFollowersMock = vi.hoisted(() => vi.fn()); 10 + const getFollowsMock = vi.hoisted(() => vi.fn()); 11 + const getProfileMock = vi.hoisted(() => vi.fn()); 12 + const navigateMock = vi.hoisted(() => vi.fn()); 13 + const unfollowActorMock = vi.hoisted(() => vi.fn()); 14 + 15 + vi.mock( 16 + "$/lib/api/profile", 17 + () => ({ 18 + followActor: followActorMock, 19 + getActorLikes: getActorLikesMock, 20 + getAuthorFeed: getAuthorFeedMock, 21 + getFollowers: getFollowersMock, 22 + getFollows: getFollowsMock, 23 + getProfile: getProfileMock, 24 + unfollowActor: unfollowActorMock, 25 + }), 26 + ); 27 + 28 + vi.mock("@solidjs/router", () => ({ useNavigate: () => navigateMock })); 29 + 30 + function deferred<T>() { 31 + let resolve!: (value: T) => void; 32 + let reject!: (error?: unknown) => void; 33 + const promise = new Promise<T>((innerResolve, innerReject) => { 34 + resolve = innerResolve; 35 + reject = innerReject; 36 + }); 37 + 38 + return { promise, reject, resolve }; 39 + } 40 + 41 + function createProfile() { 42 + return { 43 + avatar: "https://example.com/bob.png", 44 + createdAt: "2024-01-15T12:00:00.000Z", 45 + description: "Building small, durable social software.", 46 + did: "did:plc:bob", 47 + displayName: "Bob Example", 48 + followersCount: 8, 49 + followsCount: 14, 50 + handle: "bob.test", 51 + postsCount: 22, 52 + viewer: { followedBy: null, following: null, muted: false }, 53 + website: "https://bob.example.com", 54 + }; 55 + } 56 + 57 + function renderProfilePanel(actor = "bob.test") { 58 + render(() => ( 59 + <AppTestProviders 60 + session={{ 61 + activeDid: "did:plc:alice", 62 + activeHandle: "alice.test", 63 + activeSession: { did: "did:plc:alice", handle: "alice.test" }, 64 + }}> 65 + <ProfilePanel actor={actor} /> 66 + </AppTestProviders> 67 + )); 68 + } 69 + 70 + function getHeroFollowingButton() { 71 + return screen.getAllByRole("button").find((button) => button.className.includes("group inline-flex min-h-9")); 72 + } 73 + 74 + describe("ProfilePanel", () => { 75 + beforeEach(() => { 76 + vi.resetAllMocks(); 77 + getProfileMock.mockResolvedValue(createProfile()); 78 + getAuthorFeedMock.mockResolvedValue({ cursor: null, feed: [] }); 79 + getActorLikesMock.mockResolvedValue({ cursor: null, feed: [] }); 80 + getFollowersMock.mockResolvedValue({ actors: [], cursor: null }); 81 + getFollowsMock.mockResolvedValue({ actors: [], cursor: null }); 82 + followActorMock.mockResolvedValue({ cid: "cid-follow", uri: "at://did:plc:alice/app.bsky.graph.follow/1" }); 83 + unfollowActorMock.mockResolvedValue(void 0); 84 + }); 85 + 86 + it("optimistically follows and unfollows from the hero while keeping badges in sync", async () => { 87 + const followRequest = deferred<{ cid: string; uri: string }>(); 88 + followActorMock.mockReturnValueOnce(followRequest.promise); 89 + 90 + renderProfilePanel(); 91 + 92 + expect(await screen.findByRole("button", { name: "Follow" })).toBeInTheDocument(); 93 + const followersStat = screen.getByRole("button", { name: /followers/i }); 94 + expect(followersStat).toHaveTextContent("8"); 95 + 96 + fireEvent.click(screen.getByRole("button", { name: "Follow" })); 97 + 98 + await waitFor(() => { 99 + expect(followActorMock).toHaveBeenCalledWith("did:plc:bob"); 100 + expect(getHeroFollowingButton()).toBeDefined(); 101 + expect(screen.getAllByText("Following").length).toBeGreaterThanOrEqual(2); 102 + expect(followersStat).toHaveTextContent("9"); 103 + }); 104 + 105 + followRequest.resolve({ cid: "cid-follow", uri: "at://did:plc:alice/app.bsky.graph.follow/1" }); 106 + 107 + await waitFor(() => { 108 + expect(getHeroFollowingButton()).toBeDefined(); 109 + }); 110 + 111 + fireEvent.click(getHeroFollowingButton() as HTMLButtonElement); 112 + 113 + await waitFor(() => { 114 + expect(unfollowActorMock).toHaveBeenCalledWith("at://did:plc:alice/app.bsky.graph.follow/1"); 115 + expect(screen.getByRole("button", { name: "Follow" })).toBeInTheDocument(); 116 + expect(followersStat).toHaveTextContent("8"); 117 + }); 118 + }); 119 + 120 + it("opens the followers sheet with bios, inline follow controls, pagination, and escape-to-close", async () => { 121 + getFollowersMock.mockResolvedValueOnce({ 122 + actors: [{ 123 + description: "Writes about decentralised UI and protocol design.", 124 + did: "did:plc:charlie", 125 + displayName: "Charlie", 126 + handle: "charlie.test", 127 + viewer: { following: null }, 128 + }], 129 + cursor: "cursor-2", 130 + }).mockResolvedValueOnce({ 131 + actors: [{ 132 + description: "Focuses on moderation tooling and trust signals.", 133 + did: "did:plc:dana", 134 + displayName: "Dana", 135 + handle: "dana.test", 136 + viewer: { following: null }, 137 + }], 138 + cursor: null, 139 + }); 140 + 141 + renderProfilePanel(); 142 + 143 + expect(await screen.findByRole("button", { name: "Follow" })).toBeInTheDocument(); 144 + 145 + fireEvent.click(screen.getByRole("button", { name: /followers/i })); 146 + 147 + const dialog = await screen.findByRole("dialog"); 148 + expect(dialog).toBeInTheDocument(); 149 + expect(await within(dialog).findByText("Followers")).toBeInTheDocument(); 150 + expect(await within(dialog).findByText("Writes about decentralised UI and protocol design.")).toBeInTheDocument(); 151 + 152 + fireEvent.click(within(dialog).getByRole("button", { name: "Follow" })); 153 + 154 + await waitFor(() => { 155 + expect(followActorMock).toHaveBeenCalledWith("did:plc:charlie"); 156 + expect(within(dialog).getByRole("button", { name: /following/i })).toBeInTheDocument(); 157 + }); 158 + 159 + fireEvent.click(within(dialog).getByRole("button", { name: /load more/i })); 160 + 161 + await waitFor(() => { 162 + expect(getFollowersMock).toHaveBeenNthCalledWith(2, "bob.test", "cursor-2"); 163 + expect(within(dialog).getByText("Dana")).toBeInTheDocument(); 164 + }); 165 + 166 + fireEvent.keyDown(dialog, { key: "Escape" }); 167 + 168 + await waitFor(() => { 169 + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); 170 + }); 171 + }); 172 + });
+96 -657
src/components/profile/ProfilePanel.tsx
··· 1 - import { PostCard } from "$/components/feeds/PostCard"; 2 1 import { ProfileSkeleton } from "$/components/ProfileSkeleton"; 3 - import { Icon } from "$/components/shared/Icon"; 4 2 import { useAppSession } from "$/contexts/app-session"; 5 3 import { 6 4 followActor, ··· 12 10 unfollowActor, 13 11 } from "$/lib/api/profile"; 14 12 import { buildMessagesRoute } from "$/lib/conversations"; 15 - import { buildThreadRoute, getAvatarLabel, getDisplayName } from "$/lib/feeds"; 13 + import { buildThreadRoute } from "$/lib/feeds"; 16 14 import { buildProfileRoute, filterProfileFeed, getProfileRouteActor, type ProfileTab } from "$/lib/profile"; 17 - import type { ActorListResponse, FeedResponse, FeedViewPost, ProfileViewBasic, ProfileViewDetailed } from "$/lib/types"; 18 - import { formatCount, normalizeError } from "$/lib/utils/text"; 15 + import type { ActorListResponse, FeedResponse, FeedViewPost, ProfileViewBasic } from "$/lib/types"; 16 + import { clamp } from "$/lib/utils/numbers"; 17 + import { formatJoinedDate, normalizeError } from "$/lib/utils/text"; 19 18 import { useNavigate } from "@solidjs/router"; 20 - import { createEffect, createMemo, For, Match, Show, Switch } from "solid-js"; 19 + import { createEffect, createMemo, For, Show } from "solid-js"; 21 20 import { createStore } from "solid-js/store"; 21 + import { Presence } from "solid-motionone"; 22 + import { createActorListState, createFeedState, createProfilePanelState, tabLabel } from "./profile-state"; 23 + import type { ProfilePanelState } from "./profile-state"; 24 + import { ActorListOverlay } from "./ProfileActorList"; 25 + import { ProfileFeedMessage, ProfileFeedSection, ProfileFeedSkeleton } from "./ProfileFeed"; 26 + import { ProfileHero } from "./ProfileHero"; 22 27 23 28 const FEED_PAGE_SIZE = 30; 24 - const PROFILE_TABS: ProfileTab[] = ["posts", "replies", "media", "likes"]; 25 29 26 - type FeedState = { 27 - cursor: string | null; 28 - error: string | null; 29 - items: FeedViewPost[]; 30 - loaded: boolean; 31 - loading: boolean; 32 - loadingMore: boolean; 33 - }; 34 - 35 - type ActorListState = { 36 - actors: ProfileViewBasic[]; 37 - cursor: string | null; 38 - error: string | null; 39 - kind: "followers" | "follows" | null; 40 - loading: boolean; 41 - loadingMore: boolean; 42 - }; 43 - 44 - type ProfilePanelState = { 45 - activeTab: ProfileTab; 46 - actorList: ActorListState; 47 - authorFeed: FeedState; 48 - followLoading: boolean; 49 - likesFeed: FeedState; 50 - profile: ProfileViewDetailed | null; 51 - profileError: string | null; 52 - profileLoading: boolean; 53 - scrollTop: number; 54 - }; 55 - 56 - function createFeedState(): FeedState { 57 - return { cursor: null, error: null, items: [], loaded: false, loading: false, loadingMore: false }; 58 - } 59 - 60 - function createActorListState(): ActorListState { 61 - return { actors: [], cursor: null, error: null, kind: null, loading: false, loadingMore: false }; 62 - } 63 - 64 - function createProfilePanelState(): ProfilePanelState { 65 - return { 66 - activeTab: "posts", 67 - actorList: createActorListState(), 68 - authorFeed: createFeedState(), 69 - followLoading: false, 70 - likesFeed: createFeedState(), 71 - profile: null, 72 - profileError: null, 73 - profileLoading: true, 74 - scrollTop: 0, 75 - }; 76 - } 30 + const PROFILE_TABS: ProfileTab[] = ["posts", "replies", "media", "likes"]; 77 31 78 32 export function ProfilePanel(props: { actor: string | null; embedded?: boolean }) { 79 33 const navigate = useNavigate(); ··· 327 281 return; 328 282 } 329 283 330 - setState("actorList", { actors: [], cursor: null, error: null, kind, loading: true, loadingMore: false }); 284 + setState("actorList", { ...createActorListState(), kind, loading: true }); 331 285 void loadActorListPage(actor, kind, false); 332 286 } 333 287 ··· 351 305 actors: nextActors, 352 306 cursor: response.cursor ?? null, 353 307 error: null, 308 + followPendingByDid: current.followPendingByDid, 354 309 kind, 355 310 loading: false, 356 311 loadingMore: false, ··· 372 327 void loadActorListPage(actor, kind, true); 373 328 } 374 329 330 + function setActorListFollowPending(did: string, pending: boolean) { 331 + setState("actorList", "followPendingByDid", (current) => ({ ...current, [did]: pending })); 332 + } 333 + 334 + function updateActorListActor(did: string, updater: (actor: ProfileViewBasic) => ProfileViewBasic) { 335 + setState("actorList", "actors", (actors) => actors.map((actor) => actor.did === did ? updater(actor) : actor)); 336 + } 337 + 338 + async function handleActorListFollow(actor: ProfileViewBasic) { 339 + if (actor.did === session.activeDid || state.actorList.followPendingByDid[actor.did]) { 340 + return; 341 + } 342 + 343 + const previousViewer = actor.viewer ?? null; 344 + setActorListFollowPending(actor.did, true); 345 + updateActorListActor(actor.did, (current) => withActorFollowing(current, "optimistic")); 346 + 347 + try { 348 + const result = await followActor(actor.did); 349 + updateActorListActor(actor.did, (current) => withActorFollowing(current, result.uri)); 350 + } catch { 351 + updateActorListActor(actor.did, (current) => ({ ...current, viewer: previousViewer })); 352 + } finally { 353 + setActorListFollowPending(actor.did, false); 354 + } 355 + } 356 + 357 + async function handleActorListUnfollow(actor: ProfileViewBasic) { 358 + const followUri = actor.viewer?.following; 359 + if ( 360 + actor.did === session.activeDid 361 + || !followUri 362 + || followUri === "optimistic" 363 + || state.actorList.followPendingByDid[actor.did] 364 + ) { 365 + return; 366 + } 367 + 368 + const previousViewer = actor.viewer ?? null; 369 + setActorListFollowPending(actor.did, true); 370 + updateActorListActor(actor.did, (current) => withActorFollowing(current, null)); 371 + 372 + try { 373 + await unfollowActor(followUri); 374 + } catch { 375 + updateActorListActor(actor.did, (current) => ({ ...current, viewer: previousViewer })); 376 + } finally { 377 + setActorListFollowPending(actor.did, false); 378 + } 379 + } 380 + 375 381 return ( 376 382 <section 377 383 class="relative grid min-h-0 overflow-hidden bg-[rgba(8,8,8,0.32)]" ··· 420 426 </Show> 421 427 </div> 422 428 423 - <Show when={state.actorList.kind}> 424 - <ActorListOverlay 425 - actorList={state.actorList} 426 - onClose={closeActorList} 427 - onLoadMore={handleActorListLoadMore} 428 - onSelectActor={(actor) => { 429 - closeActorList(); 430 - navigate(buildProfileRoute(getProfileRouteActor(actor))); 431 - }} /> 432 - </Show> 429 + <Presence> 430 + <Show when={state.actorList.kind}> 431 + <ActorListOverlay 432 + actorList={state.actorList} 433 + onClose={closeActorList} 434 + onFollowActor={handleActorListFollow} 435 + onLoadMore={handleActorListLoadMore} 436 + onSelectActor={(actor) => { 437 + closeActorList(); 438 + navigate(buildProfileRoute(getProfileRouteActor(actor))); 439 + }} 440 + onUnfollowActor={handleActorListUnfollow} 441 + sessionDid={session.activeDid} /> 442 + </Show> 443 + </Presence> 433 444 </section> 434 445 ); 435 446 } 436 447 437 - function ProfileHero( 438 - props: { 439 - avatarProgress: number; 440 - avatarScale: number; 441 - coverOffset: number; 442 - coverScale: number; 443 - followLoading: boolean; 444 - isSelf: boolean; 445 - joinedLabel: string | null; 446 - onFollow: () => void; 447 - onMessage: () => void; 448 - onOpenFollowers: () => void; 449 - onOpenFollows: () => void; 450 - onUnfollow: () => void; 451 - pinnedPostHref: string | null; 452 - profile: ProfileViewDetailed; 453 - profileBadges: string[]; 454 - viewLabel: string; 455 - }, 456 - ) { 457 - const avatarLabel = createMemo(() => getAvatarLabel(props.profile)); 458 - const displayName = createMemo(() => getDisplayName(props.profile)); 459 - const isFollowing = createMemo(() => !!props.profile.viewer?.following); 460 - const bannerStyle = createMemo(() => ({ 461 - transform: `translate3d(0, ${props.coverOffset}px, 0) scale(${props.coverScale})`, 462 - })); 463 - const avatarStyle = createMemo(() => ({ 464 - transform: `scale(${props.avatarScale})`, 465 - "transform-origin": "bottom left", 466 - })); 467 - 448 + function ProfileLoadingView() { 468 449 return ( 469 - <header class="relative"> 470 - <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"> 471 - <Show 472 - when={props.profile.banner} 473 - fallback={ 474 - <div 475 - 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))]" 476 - style={bannerStyle()} /> 477 - }> 478 - {(banner) => ( 479 - <img alt="" class="absolute inset-0 h-full w-full object-cover" src={banner()} style={bannerStyle()} /> 480 - )} 481 - </Show> 450 + <div class="grid gap-4 p-6 max-[760px]:p-4 max-[520px]:p-3"> 451 + <div class="overflow-hidden rounded-4xl bg-white/3 p-6 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)]"> 452 + <ProfileSkeleton /> 482 453 </div> 483 - 484 - <div class="relative z-10 -mt-16 px-6 pb-6 max-[760px]:px-4 max-[520px]:px-3"> 485 - <div class="sticky top-4 z-20 mb-4 flex items-center gap-3"> 486 - <div 487 - 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 transition-transform duration-100 ease-out" 488 - style={avatarStyle()}> 489 - <Show 490 - when={props.profile.avatar} 491 - fallback={ 492 - <div class="flex h-full w-full items-center justify-center text-[2rem] font-semibold text-on-surface"> 493 - {avatarLabel()} 494 - </div> 495 - }> 496 - {(avatar) => <img alt="" class="h-full w-full object-cover" src={avatar()} />} 497 - </Show> 498 - </div> 499 - 500 - <StickyIdentity displayName={displayName()} handle={props.profile.handle} progress={props.avatarProgress} /> 501 - </div> 502 - 503 - <div class="grid gap-5 pt-20"> 504 - <div 505 - class="flex flex-wrap items-start justify-between gap-4 transition-opacity duration-100 ease-out" 506 - style={{ opacity: 1 - props.avatarProgress }}> 507 - <ProfileIdentity 508 - description={props.profile.description ?? null} 509 - displayName={displayName()} 510 - handle={props.profile.handle} 511 - viewLabel={props.viewLabel} /> 512 - <ProfileHeroActions 513 - badges={props.profileBadges} 514 - followLoading={props.followLoading} 515 - isFollowing={isFollowing()} 516 - isSelf={props.isSelf} 517 - onFollow={props.onFollow} 518 - onMessage={props.onMessage} 519 - onUnfollow={props.onUnfollow} /> 520 - </div> 521 - 522 - <ProfileMetaRow 523 - did={props.profile.did} 524 - joinedLabel={props.joinedLabel} 525 - pinnedPostHref={props.pinnedPostHref} 526 - website={props.profile.website ?? null} /> 527 - 528 - <div class="flex flex-wrap gap-6"> 529 - <ProfileStat label="Following" value={props.profile.followsCount} onClick={props.onOpenFollows} /> 530 - <ProfileStat label="Followers" value={props.profile.followersCount} onClick={props.onOpenFollowers} /> 531 - <ProfileStat label="Posts" value={props.profile.postsCount} /> 532 - </div> 533 - </div> 534 - </div> 535 - </header> 536 - ); 537 - } 538 - 539 - function FollowButton(props: { isFollowing: boolean; loading: boolean; onFollow: () => void; onUnfollow: () => void }) { 540 - return ( 541 - <Show 542 - when={props.isFollowing} 543 - fallback={ 544 - <button 545 - class="inline-flex min-h-9 items-center gap-2 rounded-full border border-white/20 bg-transparent px-5 text-sm font-medium text-on-surface transition duration-150 ease-out hover:bg-white/5 disabled:opacity-50" 546 - disabled={props.loading} 547 - type="button" 548 - onClick={props.onFollow}> 549 - <Show when={props.loading}> 550 - <Icon iconClass="i-ri-loader-4-line animate-spin" class="text-base" /> 551 - </Show> 552 - Follow 553 - </button> 554 - }> 555 - <button 556 - class="group inline-flex min-h-9 items-center gap-2 rounded-full bg-primary/15 px-5 text-sm font-medium text-primary shadow-[inset_0_0_0_1px_rgba(125,175,255,0.25)] transition duration-150 ease-out hover:bg-red-500/15 hover:text-red-400 hover:shadow-[inset_0_0_0_1px_rgba(239,68,68,0.25)] disabled:opacity-50" 557 - disabled={props.loading} 558 - type="button" 559 - onClick={() => props.onUnfollow()}> 560 - <Show when={props.loading}> 561 - <Icon iconClass="i-ri-loader-4-line animate-spin" class="text-base" /> 562 - </Show> 563 - <span class="group-hover:hidden">Following</span> 564 - <span class="hidden group-hover:inline">Unfollow</span> 565 - </button> 566 - </Show> 567 - ); 568 - } 569 - 570 - function MessageButton(props: { onClick: () => void }) { 571 - return ( 572 - <button 573 - class="inline-flex min-h-9 items-center gap-2 rounded-full border border-white/12 bg-white/6 px-4 text-sm font-medium text-on-surface transition duration-150 ease-out hover:bg-white/10" 574 - type="button" 575 - onClick={() => props.onClick()}> 576 - <Icon kind="messages" class="text-base" /> 577 - Message 578 - </button> 579 - ); 580 - } 581 - 582 - function ProfileHeroActions( 583 - props: { 584 - badges: string[]; 585 - followLoading: boolean; 586 - isFollowing: boolean; 587 - isSelf: boolean; 588 - onFollow: () => void; 589 - onMessage: () => void; 590 - onUnfollow: () => void; 591 - }, 592 - ) { 593 - return ( 594 - <div class="flex flex-col items-end gap-2"> 595 - <Show when={!props.isSelf}> 596 - <div class="flex flex-wrap justify-end gap-2"> 597 - <MessageButton onClick={props.onMessage} /> 598 - <FollowButton 599 - isFollowing={props.isFollowing} 600 - loading={props.followLoading} 601 - onFollow={props.onFollow} 602 - onUnfollow={props.onUnfollow} /> 603 - </div> 604 - </Show> 605 - <ProfileBadgeRow badges={props.badges} isSelf={props.isSelf} /> 454 + <ProfileFeedSkeleton /> 606 455 </div> 607 456 ); 608 457 } 609 458 610 - function ProfileStat(props: { label: string; onClick?: () => void; value?: number | null }) { 611 - return ( 612 - <Show 613 - when={props.onClick} 614 - fallback={ 615 - <div class="grid gap-1"> 616 - <span class="text-lg font-semibold tracking-[-0.02em] text-on-surface">{formatCount(props.value ?? 0)}</span> 617 - <span class="text-xs uppercase tracking-[0.12em] text-on-surface-variant">{props.label}</span> 618 - </div> 619 - }> 620 - {(onClick) => ( 621 - <button 622 - class="grid gap-1 text-left transition duration-150 ease-out hover:opacity-80" 623 - type="button" 624 - onClick={onClick()}> 625 - <span class="text-lg font-semibold tracking-[-0.02em] text-on-surface">{formatCount(props.value ?? 0)}</span> 626 - <span class="text-xs uppercase tracking-[0.12em] text-on-surface-variant">{props.label}</span> 627 - </button> 628 - )} 629 - </Show> 630 - ); 631 - } 632 - 633 - function StickyIdentity(props: { displayName: string; handle: string; progress: number }) { 634 - const style = createMemo(() => ({ 635 - opacity: `${props.progress}`, 636 - transform: `translate3d(0, ${8 * (1 - props.progress)}px, 0)`, 637 - })); 638 - 459 + function ProfileErrorView(props: { error: string | null }) { 460 + const error = () => props.error ?? "The profile could not be loaded."; 639 461 return ( 640 - <div class="mb-1 min-w-0 transition-[opacity,transform] duration-100 ease-out" style={style()}> 641 - <p class="m-0 truncate text-lg font-semibold leading-tight tracking-[-0.02em] text-on-surface"> 642 - {props.displayName} 643 - </p> 644 - <p class="m-0 truncate text-sm leading-tight text-on-surface-variant">@{props.handle.replace(/^@/, "")}</p> 645 - </div> 646 - ); 647 - } 648 - 649 - function ProfileIdentity( 650 - props: { description: string | null; displayName: string; handle: string; viewLabel: string }, 651 - ) { 652 - return ( 653 - <div class="grid gap-3"> 654 - <div class="grid gap-1"> 655 - <p class="overline-copy text-[0.68rem] text-on-surface-variant">{props.viewLabel}</p> 656 - <h1 class="m-0 text-[clamp(2rem,4vw,3rem)] font-semibold leading-[0.96] tracking-[-0.04em] text-on-surface"> 657 - {props.displayName} 658 - </h1> 659 - <p class="m-0 text-sm text-on-surface-variant">@{props.handle.replace(/^@/, "")}</p> 660 - </div> 661 - <Show when={props.description}> 662 - {(description) => ( 663 - <p class="m-0 max-w-3xl whitespace-pre-wrap text-[0.98rem] leading-[1.7] text-on-secondary-container"> 664 - {description()} 665 - </p> 666 - )} 667 - </Show> 668 - </div> 669 - ); 670 - } 671 - 672 - function ProfileBadgeRow(props: { badges: string[]; isSelf: boolean }) { 673 - return ( 674 - <div class="flex flex-wrap items-center justify-end gap-2"> 675 - <For each={props.badges}> 676 - {(badge) => ( 677 - <span class="inline-flex items-center rounded-full bg-white/6 px-3 py-2 text-xs font-medium text-on-surface"> 678 - {badge} 679 - </span> 680 - )} 681 - </For> 682 - <Show when={props.badges.length === 0}> 683 - <span class="inline-flex items-center rounded-full bg-white/5 px-3 py-2 text-xs font-medium text-on-surface-variant"> 684 - {props.isSelf ? "Signed in" : "Public profile"} 685 - </span> 686 - </Show> 687 - </div> 688 - ); 689 - } 690 - 691 - function ProfileMetaRow( 692 - props: { did: string; joinedLabel: string | null; pinnedPostHref: string | null; website: string | null }, 693 - ) { 694 - return ( 695 - <div class="flex flex-wrap items-center gap-4 text-sm text-on-surface-variant"> 696 - <Show when={props.website}> 697 - {(website) => ( 698 - <a 699 - class="inline-flex items-center gap-2 text-primary no-underline transition hover:text-on-surface" 700 - href={website()} 701 - rel="noreferrer" 702 - target="_blank"> 703 - <Icon iconClass="i-ri-link" class="text-base" /> 704 - <span>{website().replace(/^https?:\/\//, "")}</span> 705 - </a> 706 - )} 707 - </Show> 708 - 709 - <Show when={props.joinedLabel}> 710 - {(joined) => ( 711 - <span class="inline-flex items-center gap-2"> 712 - <Icon iconClass="i-ri-calendar-line" class="text-base" /> 713 - <span>Joined {joined()}</span> 714 - </span> 715 - )} 716 - </Show> 717 - 718 - <span class="inline-flex items-center gap-2"> 719 - <Icon iconClass="i-ri-at-line" class="text-base" /> 720 - <span class="max-w-full break-all">{props.did}</span> 721 - </span> 722 - 723 - <Show when={props.pinnedPostHref}> 724 - {(href) => ( 725 - <a 726 - 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" 727 - href={`#${href()}`}> 728 - <Icon iconClass="i-ri-pushpin-2-line" class="text-base" /> 729 - <span>Pinned post</span> 730 - </a> 731 - )} 732 - </Show> 462 + <div class="grid min-h-120 place-items-center p-6"> 463 + <ProfileFeedMessage body={error()} title="Profile unavailable" /> 733 464 </div> 734 465 ); 735 466 } 736 467 737 468 function ProfileTabs(props: { activeTab: ProfileTab; onSelect: (tab: ProfileTab) => void }) { 738 469 return ( 739 - <div class="sticky top-0 z-30 px-3 pb-3 pt-1 backdrop-blur-[18px] max-[520px]:px-2"> 470 + <div class="sticky top-22 z-30 px-3 pb-3 pt-1 backdrop-blur-[18px] max-[520px]:px-2"> 740 471 <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)]"> 741 472 <div class="flex flex-wrap gap-2"> 742 473 <For each={PROFILE_TABS}> ··· 759 490 ); 760 491 } 761 492 762 - function ProfileFeedSection( 763 - props: { 764 - activeTab: ProfileTab; 765 - cursor: string | null; 766 - error: string | null; 767 - items: FeedViewPost[]; 768 - loading: boolean; 769 - loadingMore: boolean; 770 - onLoadMore: () => void; 771 - onOpenThread: (uri: string) => void; 772 - }, 773 - ) { 774 - return ( 775 - <section class="grid gap-3 px-3 pb-4 max-[520px]:px-2"> 776 - <Show when={!props.loading} fallback={<ProfileFeedSkeleton />}> 777 - <Switch> 778 - <Match when={props.error}> 779 - <ProfileFeedMessage 780 - body={props.error ?? "The feed could not be loaded."} 781 - title={`Could not load ${tabLabel(props.activeTab).toLowerCase()}.`} /> 782 - </Match> 783 - 784 - <Match when={props.items.length > 0}> 785 - <ProfilePostList items={props.items} onOpenThread={props.onOpenThread} /> 786 - </Match> 787 - 788 - <Match when={props.cursor}> 789 - <ProfileFeedMessage 790 - body={`No ${ 791 - tabLabel(props.activeTab).toLowerCase() 792 - } in the loaded window yet. Load more to keep scanning.`} 793 - title={`Still looking for ${tabLabel(props.activeTab).toLowerCase()}.`} /> 794 - </Match> 795 - 796 - <Match when={true}> 797 - <ProfileFeedMessage 798 - body={`This profile does not have any visible ${tabLabel(props.activeTab).toLowerCase()} yet.`} 799 - title={`No ${tabLabel(props.activeTab)} available`} /> 800 - </Match> 801 - </Switch> 802 - </Show> 803 - 804 - <Show when={props.cursor}> 805 - <ProfileLoadMoreButton 806 - activeTab={props.activeTab} 807 - loadingMore={props.loadingMore} 808 - onLoadMore={props.onLoadMore} /> 809 - </Show> 810 - </section> 811 - ); 812 - } 813 - 814 - function ProfilePostList(props: { items: FeedViewPost[]; onOpenThread: (uri: string) => void }) { 815 - return ( 816 - <div class="grid gap-3"> 817 - <For each={props.items}> 818 - {(item) => ( 819 - <PostCard 820 - post={item.post} 821 - item={item} 822 - showActions={false} 823 - onOpenThread={() => props.onOpenThread(item.post.uri)} /> 824 - )} 825 - </For> 826 - </div> 827 - ); 828 - } 829 - 830 - function ProfileLoadMoreButton(props: { activeTab: ProfileTab; loadingMore: boolean; onLoadMore: () => void }) { 831 - return ( 832 - <div class="flex justify-center py-2"> 833 - <button 834 - 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" 835 - type="button" 836 - disabled={props.loadingMore} 837 - onClick={() => props.onLoadMore()}> 838 - <Show when={props.loadingMore} fallback={<Icon iconClass="i-ri-arrow-down-circle-line" class="text-base" />}> 839 - <Icon iconClass="i-ri-loader-4-line animate-spin" class="text-base" /> 840 - </Show> 841 - <span>{props.loadingMore ? "Loading more..." : `Load more ${tabLabel(props.activeTab).toLowerCase()}`}</span> 842 - </button> 843 - </div> 844 - ); 845 - } 846 - 847 - function ProfileLoadingView() { 848 - return ( 849 - <div class="grid gap-4 p-6 max-[760px]:p-4 max-[520px]:p-3"> 850 - <div class="overflow-hidden rounded-4xl bg-white/3 p-6 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)]"> 851 - <ProfileSkeleton /> 852 - </div> 853 - <ProfileFeedSkeleton /> 854 - </div> 855 - ); 856 - } 857 - 858 - function ProfileErrorView(props: { error: string | null }) { 859 - return ( 860 - <div class="grid min-h-120 place-items-center p-6"> 861 - <ProfileFeedMessage body={props.error ?? "The profile could not be loaded."} title="Profile unavailable" /> 862 - </div> 863 - ); 864 - } 865 - 866 - function ProfileFeedSkeleton() { 867 - return ( 868 - <div class="grid gap-3"> 869 - <For each={Array.from({ length: 3 })}> 870 - {() => ( 871 - <div class="rounded-3xl bg-white/3 p-5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)]"> 872 - <div class="flex items-start gap-3"> 873 - <span class="skeleton-block h-11 w-11 rounded-full" /> 874 - <div class="grid min-w-0 flex-1 gap-2"> 875 - <span class="skeleton-block h-4 w-32 rounded-full" /> 876 - <span class="skeleton-block h-3 w-full rounded-full" /> 877 - <span class="skeleton-block h-3 w-2/3 rounded-full" /> 878 - </div> 879 - </div> 880 - </div> 881 - )} 882 - </For> 883 - </div> 884 - ); 885 - } 886 - 887 - function ProfileFeedMessage(props: { body: string; title: string }) { 888 - return ( 889 - <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)]"> 890 - <div class="grid max-w-lg gap-2"> 891 - <p class="m-0 text-lg font-semibold tracking-[-0.02em] text-on-surface">{props.title}</p> 892 - <p class="m-0 text-sm leading-relaxed text-on-surface-variant">{props.body}</p> 893 - </div> 894 - </div> 895 - ); 896 - } 897 - 898 - function ActorListOverlay( 899 - props: { 900 - actorList: ActorListState; 901 - onClose: () => void; 902 - onLoadMore: () => void; 903 - onSelectActor: (actor: ProfileViewBasic) => void; 904 - }, 905 - ) { 906 - const title = createMemo(() => props.actorList.kind === "followers" ? "Followers" : "Following"); 907 - 908 - return ( 909 - <div class="absolute inset-0 z-50 flex flex-col overflow-hidden rounded-4xl bg-[rgba(8,8,8,0.88)] backdrop-blur-xl"> 910 - <ActorListHeader onClose={props.onClose} title={title()} /> 911 - 912 - <div class="min-h-0 flex-1 overflow-y-auto overscroll-contain"> 913 - <ActorListContent actorList={props.actorList} onSelectActor={props.onSelectActor} title={title()} /> 914 - 915 - <Show when={props.actorList.cursor}> 916 - <ActorListLoadMoreButton loadingMore={props.actorList.loadingMore} onLoadMore={props.onLoadMore} /> 917 - </Show> 918 - </div> 919 - </div> 920 - ); 921 - } 922 - 923 - function ActorListHeader(props: { onClose: () => void; title: string }) { 924 - return ( 925 - <div class="flex shrink-0 items-center justify-between border-b border-white/5 px-5 py-4"> 926 - <p class="m-0 text-base font-semibold text-on-surface">{props.title}</p> 927 - <button 928 - class="flex h-8 w-8 items-center justify-center rounded-full border-0 bg-white/6 text-on-surface-variant transition hover:bg-white/10 hover:text-on-surface" 929 - type="button" 930 - onClick={() => props.onClose()}> 931 - <Icon iconClass="i-ri-close-line" class="text-base" /> 932 - </button> 933 - </div> 934 - ); 935 - } 936 - 937 - function ActorListLoadMoreButton(props: { loadingMore: boolean; onLoadMore: () => void }) { 938 - return ( 939 - <div class="flex justify-center py-4"> 940 - <button 941 - class="inline-flex min-h-10 items-center gap-2 rounded-full border-0 bg-white/6 px-5 text-sm font-medium text-on-surface transition hover:-translate-y-px hover:bg-white/10 disabled:translate-y-0 disabled:opacity-70" 942 - disabled={props.loadingMore} 943 - type="button" 944 - onClick={() => props.onLoadMore()}> 945 - <Show when={props.loadingMore}> 946 - <Icon iconClass="i-ri-loader-4-line animate-spin" class="text-base" /> 947 - </Show> 948 - {props.loadingMore ? "Loading..." : "Load more"} 949 - </button> 950 - </div> 951 - ); 952 - } 953 - 954 - function ActorListContent( 955 - props: { actorList: ActorListState; onSelectActor: (actor: ProfileViewBasic) => void; title: string }, 956 - ) { 957 - return ( 958 - <Show when={!props.actorList.loading} fallback={<ActorListSkeleton />}> 959 - <Show 960 - when={!props.actorList.error} 961 - fallback={ 962 - <div class="grid place-items-center py-12 text-sm text-on-surface-variant">{props.actorList.error}</div> 963 - }> 964 - <Show 965 - when={props.actorList.actors.length > 0} 966 - fallback={ 967 - <div class="grid place-items-center py-12 text-sm text-on-surface-variant"> 968 - No {props.title.toLowerCase()} yet. 969 - </div> 970 - }> 971 - <div class="divide-y divide-white/5"> 972 - <For each={props.actorList.actors}> 973 - {(actor) => <ActorCard actor={actor} onSelect={() => props.onSelectActor(actor)} />} 974 - </For> 975 - </div> 976 - </Show> 977 - </Show> 978 - </Show> 979 - ); 980 - } 981 - 982 - function ActorCard(props: { actor: ProfileViewBasic; onSelect: () => void }) { 983 - const label = createMemo(() => getAvatarLabel(props.actor)); 984 - const name = createMemo(() => getDisplayName(props.actor)); 985 - 986 - return ( 987 - <button 988 - class="flex w-full items-center gap-3 border-0 bg-transparent px-5 py-3 text-left transition hover:bg-white/4" 989 - type="button" 990 - onClick={() => props.onSelect()}> 991 - <div class="h-10 w-10 shrink-0 overflow-hidden rounded-full bg-surface-container-high"> 992 - <Show 993 - when={props.actor.avatar} 994 - fallback={ 995 - <div class="flex h-full w-full items-center justify-center text-sm font-semibold text-on-surface"> 996 - {label()} 997 - </div> 998 - }> 999 - {(avatar) => <img alt="" class="h-full w-full object-cover" src={avatar()} />} 1000 - </Show> 1001 - </div> 1002 - <div class="min-w-0 flex-1"> 1003 - <p class="m-0 truncate text-sm font-medium text-on-surface">{name()}</p> 1004 - <p class="m-0 truncate text-xs text-on-surface-variant">@{props.actor.handle}</p> 1005 - </div> 1006 - </button> 1007 - ); 1008 - } 1009 - 1010 - function ActorListSkeleton() { 1011 - return ( 1012 - <div class="divide-y divide-white/5"> 1013 - <For each={Array.from({ length: 6 })}> 1014 - {() => ( 1015 - <div class="flex items-center gap-3 px-5 py-3"> 1016 - <span class="skeleton-block h-10 w-10 shrink-0 rounded-full" /> 1017 - <div class="grid flex-1 gap-1.5"> 1018 - <span class="skeleton-block h-3.5 w-32 rounded-full" /> 1019 - <span class="skeleton-block h-3 w-24 rounded-full" /> 1020 - </div> 1021 - </div> 1022 - )} 1023 - </For> 1024 - </div> 1025 - ); 1026 - } 1027 - 1028 - function tabLabel(tab: ProfileTab) { 1029 - switch (tab) { 1030 - case "posts": { 1031 - return "Posts"; 1032 - } 1033 - case "replies": { 1034 - return "Replies"; 1035 - } 1036 - case "media": { 1037 - return "Media"; 1038 - } 1039 - default: { 1040 - return "Likes"; 1041 - } 1042 - } 1043 - } 1044 - 1045 - function formatJoinedDate(value?: string | null) { 1046 - if (!value) { 1047 - return null; 1048 - } 1049 - 1050 - const parsed = new Date(value); 1051 - if (Number.isNaN(parsed.getTime())) { 1052 - return null; 1053 - } 1054 - 1055 - return parsed.toLocaleDateString(undefined, { month: "long", year: "numeric" }); 1056 - } 1057 - 1058 493 function mergeFeedItems(current: FeedViewPost[], incoming: FeedViewPost[]) { 1059 494 const seen = new Set(current.map((item) => item.post.uri)); 1060 495 const merged = [...current]; ··· 1069 504 return merged; 1070 505 } 1071 506 1072 - function clamp(value: number, min: number, max: number) { 1073 - return Math.min(Math.max(value, min), max); 507 + function withActorFollowing(actor: ProfileViewBasic, following: string | null) { 508 + if (actor.viewer) { 509 + return { ...actor, viewer: { ...actor.viewer, following } }; 510 + } 511 + 512 + return { ...actor, viewer: { following } }; 1074 513 }
+80
src/components/profile/profile-state.ts
··· 1 + import type { ProfileTab } from "$/lib/profile"; 2 + import type { FeedViewPost, ProfileViewBasic, ProfileViewDetailed } from "$/lib/types"; 3 + 4 + export type FeedState = { 5 + cursor: string | null; 6 + error: string | null; 7 + items: FeedViewPost[]; 8 + loaded: boolean; 9 + loading: boolean; 10 + loadingMore: boolean; 11 + }; 12 + 13 + export type ActorListState = { 14 + actors: ProfileViewBasic[]; 15 + cursor: string | null; 16 + error: string | null; 17 + followPendingByDid: Record<string, boolean>; 18 + kind: "followers" | "follows" | null; 19 + loading: boolean; 20 + loadingMore: boolean; 21 + }; 22 + 23 + export type ProfilePanelState = { 24 + activeTab: ProfileTab; 25 + actorList: ActorListState; 26 + authorFeed: FeedState; 27 + followLoading: boolean; 28 + likesFeed: FeedState; 29 + profile: ProfileViewDetailed | null; 30 + profileError: string | null; 31 + profileLoading: boolean; 32 + scrollTop: number; 33 + }; 34 + 35 + export function createFeedState(): FeedState { 36 + return { cursor: null, error: null, items: [], loaded: false, loading: false, loadingMore: false }; 37 + } 38 + 39 + export function createActorListState(): ActorListState { 40 + return { 41 + actors: [], 42 + cursor: null, 43 + error: null, 44 + followPendingByDid: {}, 45 + kind: null, 46 + loading: false, 47 + loadingMore: false, 48 + }; 49 + } 50 + 51 + export function createProfilePanelState(): ProfilePanelState { 52 + return { 53 + activeTab: "posts", 54 + actorList: createActorListState(), 55 + authorFeed: createFeedState(), 56 + followLoading: false, 57 + likesFeed: createFeedState(), 58 + profile: null, 59 + profileError: null, 60 + profileLoading: true, 61 + scrollTop: 0, 62 + }; 63 + } 64 + 65 + export function tabLabel(tab: ProfileTab) { 66 + switch (tab) { 67 + case "posts": { 68 + return "Posts"; 69 + } 70 + case "replies": { 71 + return "Replies"; 72 + } 73 + case "media": { 74 + return "Media"; 75 + } 76 + default: { 77 + return "Likes"; 78 + } 79 + } 80 + }
+67
src/lib/profile.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { filterProfileFeed, parseActorList } from "./profile"; 3 + import type { FeedViewPost } from "./types"; 4 + 5 + function createFeedItem(overrides: Partial<FeedViewPost> = {}): FeedViewPost { 6 + return { 7 + post: { 8 + author: { did: "did:plc:alice", handle: "alice.test" }, 9 + cid: "cid-1", 10 + indexedAt: "2026-03-28T12:00:00.000Z", 11 + record: { createdAt: "2026-03-28T12:00:00.000Z", text: "hello world" }, 12 + uri: "at://did:plc:alice/app.bsky.feed.post/1", 13 + }, 14 + ...overrides, 15 + }; 16 + } 17 + 18 + describe("profile helpers", () => { 19 + it("parses actor lists with bios and follow state", () => { 20 + const response = parseActorList({ 21 + cursor: "cursor-1", 22 + followers: [{ 23 + avatar: "https://example.com/avatar.png", 24 + description: "Writes about protocol design.", 25 + did: "did:plc:bob", 26 + displayName: "Bob", 27 + handle: "bob.test", 28 + viewer: { following: "at://did:plc:alice/app.bsky.graph.follow/1" }, 29 + }], 30 + }, "followers"); 31 + 32 + expect(response).toEqual({ 33 + cursor: "cursor-1", 34 + actors: [{ 35 + avatar: "https://example.com/avatar.png", 36 + description: "Writes about protocol design.", 37 + did: "did:plc:bob", 38 + displayName: "Bob", 39 + handle: "bob.test", 40 + viewer: { following: "at://did:plc:alice/app.bsky.graph.follow/1" }, 41 + }], 42 + }); 43 + }); 44 + 45 + it("filters profile feeds by tab semantics", () => { 46 + const base = createFeedItem(); 47 + const reply = createFeedItem({ 48 + post: { ...base.post, uri: "at://did:plc:alice/app.bsky.feed.post/2" }, 49 + reply: { 50 + parent: { $type: "app.bsky.feed.defs#postView", ...base.post }, 51 + root: { $type: "app.bsky.feed.defs#postView", ...base.post }, 52 + }, 53 + }); 54 + const media = createFeedItem({ 55 + post: { 56 + ...base.post, 57 + embed: { $type: "app.bsky.embed.images#view", images: [{ thumb: "https://example.com/thumb.png" }] }, 58 + uri: "at://did:plc:alice/app.bsky.feed.post/3", 59 + }, 60 + }); 61 + 62 + expect(filterProfileFeed([base, reply, media], "posts")).toEqual([base, media]); 63 + expect(filterProfileFeed([base, reply, media], "replies")).toEqual([reply]); 64 + expect(filterProfileFeed([base, reply, media], "media")).toEqual([media]); 65 + expect(filterProfileFeed([base, reply, media], "likes")).toEqual([base, reply, media]); 66 + }); 67 + });
+2 -9
src/lib/profile.ts
··· 1 1 import { isReplyItem, parseFeedResponse } from "$/lib/feeds"; 2 2 import type { ActorListResponse, FeedResponse, FeedViewPost, ProfileViewBasic, ProfileViewDetailed } from "$/lib/types"; 3 - import { asArray, asRecord } from "./type-guards"; 3 + import { asArray, asRecord, optionalNumber, optionalString } from "./type-guards"; 4 4 5 5 export type ProfileTab = "posts" | "replies" | "media" | "likes"; 6 6 ··· 85 85 handle: record.handle, 86 86 displayName: optionalString(record.displayName), 87 87 avatar: optionalString(record.avatar), 88 + description: optionalString(record.description), 88 89 viewer: asRecord(record.viewer) ? { following: optionalString(asRecord(record.viewer)?.following) } : null, 89 90 }; 90 91 } ··· 119 120 muted: typeof record.muted === "boolean" ? record.muted : null, 120 121 }; 121 122 } 122 - 123 - function optionalNumber(value: unknown) { 124 - return typeof value === "number" ? value : null; 125 - } 126 - 127 - function optionalString(value: unknown) { 128 - return typeof value === "string" ? value : null; 129 - }
+8
src/lib/type-guards.ts
··· 28 28 export function asArray(value: unknown) { 29 29 return Array.isArray(value) ? value : null; 30 30 } 31 + 32 + export function optionalNumber(value: unknown) { 33 + return typeof value === "number" ? value : null; 34 + } 35 + 36 + export function optionalString(value: unknown) { 37 + return typeof value === "string" ? value : null; 38 + }
+1 -2
src/lib/types.ts
··· 32 32 handle: string; 33 33 displayName?: string | null; 34 34 avatar?: string | null; 35 + description?: string | null; 35 36 viewer?: AuthorViewerState | null; 36 37 }; 37 38 ··· 251 252 export type RefreshInterval = 30 | 60 | 120 | 300 | 0; 252 253 253 254 export type Theme = "light" | "dark" | "auto"; 254 - 255 - // ── DMs / Conversations ────────────────────────────────────────────────────── 256 255 257 256 export type MessageViewSender = { did: string }; 258 257
+3
src/lib/utils/numbers.ts
··· 1 + export function clamp(value: number, min: number, max: number) { 2 + return Math.min(Math.max(value, min), max); 3 + }
+13
src/lib/utils/text.ts
··· 73 73 const prefix = [formatLogTimestamp(log.timestamp), log.level, log.target ?? "app"].join(" "); 74 74 return `${prefix} ${log.message}`; 75 75 } 76 + 77 + export function formatJoinedDate(value?: string | null) { 78 + if (!value) { 79 + return null; 80 + } 81 + 82 + const parsed = new Date(value); 83 + if (Number.isNaN(parsed.getTime())) { 84 + return null; 85 + } 86 + 87 + return parsed.toLocaleDateString(undefined, { month: "long", year: "numeric" }); 88 + }