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.

docs: add profile features and reorient

* fix parallax

+197 -51
+6
docs/specs/multicolumn.md
··· 121 121 - Horizontal scroll: smooth scroll-snap with momentum 122 122 - Responsive collapse: `Presence` crossfade when switching between multicolumn and single-column modes 123 123 - Skeleton screens per column while content loads 124 + 125 + ## Responsive Behavior 126 + 127 + - On narrow windows (< 768px), collapse to single-column view with horizontal swipe navigation 128 + - On smaller widths, multiple columns should collapse into vertical, labeled panes within the single-column layout 129 + - Columns containing sensitive content (e.g., DMs) should support an autoblur option — allow marking any column as blurrable so content is obscured until hovered or clicked
+94
docs/specs/profile.md
··· 1 + # Profile Screens 2 + 3 + ## Profile View 4 + 5 + The profile view is the primary way to inspect any user on the network. It renders a hero section (banner, avatar, identity, stats) followed by tabbed content (posts, replies, media, likes). The hero section uses scroll-driven animation to condense into a compact header as the user scrolls down. 6 + 7 + ### Profile Data (XRPC via jacquard) 8 + 9 + | Action | Lexicon | 10 + | ------------------- | ------------------------------------ | 11 + | Get profile | `app.bsky.actor.getProfile` | 12 + | Author feed | `app.bsky.feed.getAuthorFeed` | 13 + | Actor likes | `app.bsky.feed.getActorLikes` | 14 + | Follow | `app.bsky.graph.follow` (create) | 15 + | Unfollow | `app.bsky.graph.follow` (delete) | 16 + | Get followers | `app.bsky.graph.getFollowers` | 17 + | Get following | `app.bsky.graph.getFollows` | 18 + 19 + ### Hero Section 20 + 21 + The hero contains the banner image, avatar, display name, handle, bio, metadata (website, join date, DID), and social stats. 22 + 23 + **Scroll-driven condensation:** As the user scrolls, the avatar shrinks and slides right while the display name and handle slide up to sit beside it, forming a compact sticky header. The banner parallax-scrolls behind. 24 + 25 + - Avatar starts at 128px, scales down to ~84px (`1 - progress * 0.34`) 26 + - Display name and handle translate upward and leftward to sit beside the shrunk avatar 27 + - The avatar + name + handle group sticks to the top of the scroll container 28 + - Banner offset: `scrollTop * 0.28` (capped at 88px), scale: `1 + scrollTop / 1600` (capped at 1.08) 29 + - All transforms use `translate3d` for GPU-accelerated compositing, `duration-100 ease-out` 30 + 31 + ### Tabs 32 + 33 + Four content tabs below the hero: **Posts**, **Replies**, **Media**, **Likes**. 34 + 35 + - Posts: author feed filtered to exclude replies 36 + - Replies: author feed filtered to replies only 37 + - Media: author feed filtered to posts with embeds 38 + - Likes: separate endpoint (`getActorLikes`) 39 + - Sticky tab bar with backdrop blur, sits below the condensed hero on scroll 40 + - Cursor-based pagination with "Load more" button 41 + 42 + ### Follow / Unfollow Actions 43 + 44 + - Follow button on other users' profiles (not on self) 45 + - Visual state: "Follow" (outline) / "Following" (filled) / "Unfollow" (on hover of Following) 46 + - Creates/deletes `app.bsky.graph.follow` record 47 + - Optimistic UI update with rollback on error 48 + 49 + ### Following & Follower Lists 50 + 51 + - Accessible from the follower/following stat counts on the profile hero 52 + - Paginated list using `app.bsky.graph.getFollowers` / `app.bsky.graph.getFollows` 53 + - Each entry is a compact actor card (avatar, name, handle, bio preview, follow button) 54 + - `Presence` slide-up overlay or route-based panel 55 + 56 + ### DMs 57 + 58 + - DM button on other users' profiles 59 + - Opens `chat.bsky.convo.*` conversation view 60 + - **Deferred** to post-MVP unless trivial alongside feed DMs 61 + 62 + ### Profile Edit Screen 63 + 64 + - Accessible only on self-profile 65 + - Controls for: display name, bio/description, avatar, banner, website, pronouns 66 + - Uses `com.atproto.repo.putRecord` on `app.bsky.actor.profile` 67 + - Image upload via `com.atproto.repo.uploadBlob` 68 + - Confirmation before discarding unsaved changes 69 + 70 + ## Keyboard Shortcuts 71 + 72 + | Key | Action | 73 + | --------- | ------------------------ | 74 + | `Escape` | Close overlay / go back | 75 + | `1`–`4` | Switch profile tabs | 76 + 77 + ## UX Polish 78 + 79 + - Avatar + name condensation: smooth scroll-driven transform (not IntersectionObserver snap) 80 + - Banner parallax: subtle depth via `translate3d` + `scale` 81 + - Tab switch: `Presence` crossfade between feed content 82 + - Skeleton screens for profile hero and feed content during load 83 + - Error state with retry button for network failures 84 + - Badge row for relationship indicators (Following, Follows you, Muted, etc.) 85 + 86 + ## Responsive Behavior 87 + 88 + - On narrow widths (< 520px), reduce horizontal padding, compress hero spacing 89 + - On medium widths (< 760px), reduce banner height from 256px to 224px 90 + 91 + ## Parking Lot 92 + 93 + - Profile edit screen (full settings exposure) 94 + - DM conversation view
+1 -1
docs/tasks/08-social-diagnostics.md docs/tasks/12-social-diagnostics.md
··· 1 - # Task 08: Social Diagnostics 1 + # Task 12: Social Diagnostics 2 2 3 3 Spec: [social-diagnostics.md](../specs/social-diagnostics.md) 4 4
+2 -2
docs/tasks/09-jetstream.md docs/tasks/10-jetstream.md
··· 1 - # Task 09: Jetstream 1 + # Task 10: Jetstream 2 2 3 3 Spec: [explorer.md](../specs/explorer.md) 4 4 ··· 26 26 These require update to the spec & more research before implementation. 27 27 28 28 - [ ] **Frontend**: Firehose Viewer 29 - - [ ] **Frontend**: Spacedust integration (see [Task 10](./10-spacedust.md)) 29 + - [ ] **Frontend**: Spacedust integration (see [Task 11](./11-spacedust.md))
+55
docs/tasks/09-profile.md
··· 1 + # Task 09: Profile 2 + 3 + Spec: [profile.md](../specs/profile.md) 4 + 5 + Depends on: Task 03 (Feeds - post card, feed loading), Task 02 (Auth - session, account context) 6 + 7 + ## Steps 8 + 9 + ### Backend - `src-tauri/src/commands/profile.rs` 10 + 11 + - [x] `get_profile(actor: String)` - `app.bsky.actor.getProfile` 12 + - [x] `get_author_feed(actor: String, cursor: Option<String>, limit: Option<u32>)` - `app.bsky.feed.getAuthorFeed` 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` 18 + 19 + ### Frontend - Profile Hero & Scroll Behavior 20 + 21 + - [x] Profile hero section: banner image with parallax, avatar, display name, handle, bio, metadata row, stat counters 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 24 + - [x] Badge row for relationship indicators (Following, Follows you, Muted, etc.) 25 + - [x] Responsive: reduced padding on narrow widths, shorter banner on medium widths 26 + 27 + ### Frontend - Profile Tabs & Feed 28 + 29 + - [x] Four-tab layout: Posts, Replies, Media, Likes 30 + - [x] Sticky tab bar with backdrop blur below the condensed hero 31 + - [x] Per-tab feed filtering (posts excludes replies, replies only, media only) 32 + - [x] Likes tab uses separate `getActorLikes` endpoint 33 + - [x] Cursor-based pagination with "Load more" button 34 + - [x] Skeleton loading states for both hero and feed content 35 + - [x] Error state with message for profile load failures 36 + 37 + ### Frontend - Follow / Unfollow 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 43 + 44 + ### Frontend - Following & Follower Lists 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" 50 + 51 + ### Parking Lot 52 + 53 + - [ ] DM button (requires `chat.bsky.convo.*` implementation) 54 + - [ ] Profile edit screen (display name, bio, avatar, banner, website, pronouns) 55 + - [ ] Mute / block actions from profile view
+2 -2
docs/tasks/10-spacedust.md docs/tasks/11-spacedust.md
··· 1 - # Task 10: Spacedust 1 + # Task 11: Spacedust 2 2 3 3 Spec: TBD (see [Spacedust API docs](../../.sandbox/spacedust.md)) 4 4 ··· 6 6 7 7 [Spacedust](https://spacedust.microcosm.blue/) is a configurable ATProto notifications firehose by microcosm.blue. It streams real-time backlink events (likes, reposts, follows, replies, etc.) for specific subjects, with a built-in 21-second debounce buffer to filter out quickly-undone interactions. 8 8 9 - Where Jetstream (Task 09) streams raw firehose records, Spacedust streams *resolved backlinks* - making it ideal for live notification feeds and real-time engagement counters. 9 + Where Jetstream (Task 10) streams raw firehose records, Spacedust streams *resolved backlinks* - making it ideal for live notification feeds and real-time engagement counters. 10 10 11 11 ## Tasks 12 12
+3 -3
docs/tasks/11-multicolumn.md docs/tasks/08-multicolumn.md
··· 1 - # Task 11: Multicolumn Views 1 + # Task 08: Multicolumn Views 2 2 3 3 Spec: [multicolumn.md](../specs/multicolumn.md) 4 4 ··· 8 8 9 9 ## Steps 10 10 11 - ### Backend - `src-tauri/src/columns.rs` 11 + ### Backend - `src-tauri/src/columns.rs` + `src-tauri/src/commands/columns.rs` 12 12 13 13 - [ ] SQLite migration: `columns` table (`id TEXT PRIMARY KEY, account_did TEXT, kind TEXT, config TEXT, position INTEGER, width TEXT, created_at TEXT`) 14 14 - `kind`: `feed` | `explorer` | `diagnostics` - determines the column type ··· 47 47 48 48 #### Diagnostics Column 49 49 50 - - [ ] Reuse social diagnostics panel from Task 08 50 + - [ ] Reuse social diagnostics panel from Task 12 51 51 - [ ] Tab navigation within column for lists/labels/blocks/starter packs/backlinks 52 52 - [ ] Compact card layout adapted to column width 53 53
+1 -1
docs/tasks/12-release.md docs/tasks/13-release.md
··· 1 - # Task 12: Release 1 + # Task 13: Release 2 2 3 3 ## Overview 4 4
+8 -7
docs/tasks/mvp.md
··· 20 20 ## Phase 4: Power Features 21 21 22 22 - [Search & Embeddings](./07-search.md) - FTS5, fastembed, sqlite-vec, sync pipeline, result animations 23 - - [Social Diagnostics](./08-social-diagnostics.md) - Constellation-powered lists, labels, blocks, starter packs, backlinks 23 + - [Multicolumn Views](./08-multicolumn.md) - TweetDeck-style side-by-side feeds, explorer, and diagnostics panels 24 + - [Profile](./09-profile.md) - Profile hero, follow/unfollow, follower/following lists, scroll-driven condensation 24 25 25 - ## Phase 5: Live Data 26 + ## Phase 5: Live Data & Diagnostics 26 27 27 - - [Jetstream](./09-jetstream.md) - WebSocket live-tail of AT Protocol firehose, filtered record streaming 28 - - [Spacedust](./10-spacedust.md) - Real-time backlink notifications via microcosm Spacedust 28 + - [Jetstream](./10-jetstream.md) - WebSocket live-tail of AT Protocol firehose, filtered record streaming 29 + - [Spacedust](./11-spacedust.md) - Real-time backlink notifications via microcosm Spacedust 30 + - [Social Diagnostics](./12-social-diagnostics.md) - Constellation-powered lists, labels, blocks, starter packs, backlinks (depends on Spacedust for live engagement) 29 31 30 - ## Phase 6: Polish & Release 32 + ## Phase 6: Release 31 33 32 - - [Multicolumn Views](./11-multicolumn.md) - TweetDeck-style side-by-side feeds, explorer, and diagnostics panels 33 - - [Release](./12-release.md) - Cross-platform build (macOS, Windows, Linux), code signing, auto-update, CI/CD 34 + - [Release](./13-release.md) - Cross-platform build (macOS, Windows, Linux), code signing, auto-update, CI/CD
+25 -35
src/components/profile/ProfilePanel.tsx
··· 8 8 import type { FeedResponse, FeedViewPost, ProfileViewDetailed } from "$/lib/types"; 9 9 import { formatCount, normalizeError } from "$/lib/utils/text"; 10 10 import { useNavigate } from "@solidjs/router"; 11 - import { createEffect, createMemo, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"; 11 + import { createEffect, createMemo, For, Match, Show, Switch } from "solid-js"; 12 12 import { createStore } from "solid-js/store"; 13 13 14 14 const FEED_PAGE_SIZE = 30; ··· 53 53 const navigate = useNavigate(); 54 54 const session = useAppSession(); 55 55 const [state, setState] = createStore<ProfilePanelState>(createProfilePanelState()); 56 - const [avatarTrackWidth, setAvatarTrackWidth] = createSignal(0); 57 - let avatarTrackRef: HTMLDivElement | undefined; 58 56 let requestSequence = 0; 59 57 60 58 const activeActor = createMemo(() => props.actor?.trim() || session.activeHandle || session.activeDid || ""); ··· 66 64 ); 67 65 const avatarProgress = createMemo(() => clamp((state.scrollTop - 18) / 180, 0, 1)); 68 66 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 67 const coverOffset = createMemo(() => Math.min(state.scrollTop * 0.28, 88)); 74 68 const coverScale = createMemo(() => 1 + Math.min(state.scrollTop / 1600, 0.08)); 75 69 const viewLabel = createMemo(() => isSelf() ? "Your profile" : "Viewing profile"); ··· 131 125 } 132 126 }); 133 127 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 128 async function loadProfile(sequence: number, actor: string) { 153 129 try { 154 130 const profile = await getProfile(actor); ··· 278 254 <ProfileHero 279 255 avatarProgress={avatarProgress()} 280 256 avatarScale={avatarScale()} 281 - avatarShift={avatarShift()} 282 - avatarTrackRef={(element) => { 283 - avatarTrackRef = element; 284 - }} 285 257 coverOffset={coverOffset()} 286 258 coverScale={coverScale()} 287 259 isSelf={isSelf()} ··· 315 287 props: { 316 288 avatarProgress: number; 317 289 avatarScale: number; 318 - avatarShift: number; 319 - avatarTrackRef: (element: HTMLDivElement) => void; 320 290 coverOffset: number; 321 291 coverScale: number; 322 292 isSelf: boolean; ··· 333 303 transform: `translate3d(0, ${props.coverOffset}px, 0) scale(${props.coverScale})`, 334 304 })); 335 305 const avatarStyle = createMemo(() => ({ 336 - transform: `translate3d(${props.avatarShift}px, ${-10 * props.avatarProgress}px, 0) scale(${props.avatarScale})`, 306 + transform: `translate3d(0, ${-10 * props.avatarProgress}px, 0) scale(${props.avatarScale})`, 337 307 })); 338 308 339 309 return ( ··· 353 323 </div> 354 324 355 325 <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}> 326 + <div class="sticky top-4 z-20 mb-4 flex items-end gap-4"> 357 327 <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" 328 + 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" 359 329 style={avatarStyle()}> 360 330 <Show 361 331 when={props.profile.avatar} ··· 367 337 {(avatar) => <img alt="" class="h-full w-full object-cover" src={avatar()} />} 368 338 </Show> 369 339 </div> 340 + 341 + <StickyIdentity displayName={displayName()} handle={props.profile.handle} progress={props.avatarProgress} /> 370 342 </div> 371 343 372 344 <div class="grid gap-5 pt-20"> 373 - <div class="flex flex-wrap items-start justify-between gap-4"> 345 + <div 346 + class="flex flex-wrap items-start justify-between gap-4 transition-opacity duration-100 ease-out" 347 + style={{ opacity: 1 - props.avatarProgress }}> 374 348 <ProfileIdentity 375 349 description={props.profile.description ?? null} 376 350 displayName={displayName()} ··· 401 375 <div class="grid gap-1"> 402 376 <span class="text-lg font-semibold tracking-[-0.02em] text-on-surface">{formatCount(props.value ?? 0)}</span> 403 377 <span class="text-xs uppercase tracking-[0.12em] text-on-surface-variant">{props.label}</span> 378 + </div> 379 + ); 380 + } 381 + 382 + function StickyIdentity(props: { displayName: string; handle: string; progress: number }) { 383 + const style = createMemo(() => ({ 384 + opacity: `${props.progress}`, 385 + transform: `translate3d(0, ${8 * (1 - props.progress)}px, 0)`, 386 + })); 387 + 388 + return ( 389 + <div class="mb-1 min-w-0 transition-[opacity,transform] duration-100 ease-out" style={style()}> 390 + <p class="m-0 truncate text-lg font-semibold leading-tight tracking-[-0.02em] text-on-surface"> 391 + {props.displayName} 392 + </p> 393 + <p class="m-0 truncate text-sm leading-tight text-on-surface-variant">@{props.handle.replace(/^@/, "")}</p> 404 394 </div> 405 395 ); 406 396 }