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: avatar images

* fix nav effect

+183 -99
+33 -13
src-tauri/src/auth.rs
··· 1 1 use super::db::DbPool; 2 2 use super::error::{AppError, TypeaheadFetchError, TypeaheadFetchErrorKind}; 3 3 use super::state::{AccountSummary, ActiveSession}; 4 + use jacquard::api::app_bsky::actor::get_profile::GetProfile; 4 5 use jacquard::api::com_atproto::server::get_session::GetSession; 5 6 use jacquard::common::session::SessionStoreError; 6 7 use jacquard::oauth::atproto::AtprotoClientMetadata; ··· 44 45 pub handle: String, 45 46 pub pds_url: String, 46 47 pub active: bool, 48 + pub avatar: Option<String>, 47 49 } 48 50 49 51 #[derive(Clone, Debug, Serialize)] ··· 89 91 let connection = self.lock_connection()?; 90 92 let mut statement = connection.prepare( 91 93 " 92 - SELECT 93 - did, 94 - session_id, 95 - COALESCE(handle, ''), 96 - COALESCE(pds_url, ''), 97 - active 94 + SELECT 95 + did, 96 + session_id, 97 + COALESCE(handle, ''), 98 + COALESCE(pds_url, ''), 99 + active, 100 + avatar 98 101 FROM accounts 99 102 ORDER BY active DESC, handle COLLATE NOCASE ASC 100 103 ", ··· 107 110 handle: row.get(2)?, 108 111 pds_url: row.get(3)?, 109 112 active: row.get::<_, i64>(4)? == 1, 113 + avatar: row.get(5)?, 110 114 }) 111 115 })?; 112 116 ··· 128 132 session_id, 129 133 COALESCE(handle, ''), 130 134 COALESCE(pds_url, ''), 131 - active 135 + active, 136 + avatar 132 137 FROM accounts 133 138 WHERE did = ?1 134 139 ", ··· 140 145 handle: row.get(2)?, 141 146 pds_url: row.get(3)?, 142 147 active: row.get::<_, i64>(4)? == 1, 148 + avatar: row.get(5)?, 143 149 }) 144 150 }, 145 151 ) ··· 159 165 160 166 transaction.execute( 161 167 " 162 - INSERT INTO accounts(did, handle, pds_url, session_id, active) 163 - VALUES (?1, ?2, ?3, ?4, ?5) 168 + INSERT INTO accounts(did, handle, pds_url, session_id, active, avatar) 169 + VALUES (?1, ?2, ?3, ?4, ?5, ?6) 164 170 ON CONFLICT(did) DO UPDATE SET 165 171 handle = excluded.handle, 166 172 pds_url = excluded.pds_url, 167 173 session_id = excluded.session_id, 168 - active = excluded.active 174 + active = excluded.active, 175 + avatar = excluded.avatar 169 176 ", 170 177 params![ 171 178 account.did, 172 179 account.handle, 173 180 account.pds_url, 174 181 session_id, 175 - if make_active { 1_i64 } else { 0_i64 } 182 + if make_active { 1_i64 } else { 0_i64 }, 183 + account.avatar, 176 184 ], 177 185 )?; 178 186 ··· 399 407 let output = response 400 408 .into_output() 401 409 .map_err(|error| AppError::Validation(format!("failed to parse account session: {error}")))?; 410 + let avatar = session 411 + .send( 412 + GetProfile::new() 413 + .actor(jacquard::common::types::ident::AtIdentifier::Did(output.did.clone())) 414 + .build(), 415 + ) 416 + .await 417 + .ok() 418 + .and_then(|response| response.into_output().ok()) 419 + .and_then(|profile| profile.value.avatar.map(|uri| uri.as_str().to_owned())); 402 420 403 421 Ok(AccountSummary { 404 422 did: output.did.to_string(), 405 423 handle: output.handle.to_string(), 406 424 pds_url: session.endpoint().await.to_string(), 407 425 active, 426 + avatar, 408 427 }) 409 428 } 410 429 ··· 444 463 handle: account.handle.clone(), 445 464 pds_url: account.pds_url.clone(), 446 465 active: account.active, 466 + avatar: account.avatar.clone(), 447 467 }) 448 468 .collect() 449 469 } ··· 525 545 .query(&[("q", query), ("limit", "6")]) 526 546 .send() 527 547 .await 528 - .map_err(TypeaheadFetchError::transport)?; 548 + .map_err(|error| TypeaheadFetchError::transport(&error))?; 529 549 530 550 let status = response.status(); 531 551 if !status.is_success() { ··· 535 555 let payload = response 536 556 .json::<TypeaheadResponse>() 537 557 .await 538 - .map_err(TypeaheadFetchError::decode)?; 558 + .map_err(|error| TypeaheadFetchError::decode(&error))?; 539 559 540 560 Ok(payload 541 561 .actors
+1
src-tauri/src/commands.rs
··· 1 + #![allow(clippy::needless_pass_by_value)] 1 2 use super::auth::{self, LoginSuggestion}; 2 3 use super::error::AppError; 3 4 use super::feed::{self, CreateRecordResult, EmbedInput, ReplyRefInput, UserPreferences};
+11 -2
src-tauri/src/db.rs
··· 1 1 use std::collections::HashSet; 2 + use std::ffi::{c_char, c_int}; 2 3 use std::fs; 3 4 use std::path::PathBuf; 4 5 use std::sync::{Arc, Mutex}; ··· 11 12 use crate::error::AppError; 12 13 13 14 pub type DbPool = Arc<Mutex<Connection>>; 15 + type SqliteVecInit = unsafe extern "C" fn(); 16 + type SqliteAutoExtension = unsafe extern "C" fn( 17 + db: *mut rusqlite::ffi::sqlite3, 18 + pz_err_msg: *mut *mut c_char, 19 + api: *const rusqlite::ffi::sqlite3_api_routines, 20 + ) -> c_int; 14 21 15 22 struct Migration { 16 23 version: i64, ··· 32 39 "oauth_sessions_without_fk", 33 40 include_str!("migrations/003_oauth_sessions_without_fk.sql"), 34 41 ), 42 + Migration::new(4, "account_avatars", include_str!("migrations/004_account_avatars.sql")), 35 43 ]; 36 44 37 45 pub fn initialize_database(app: &AppHandle) -> Result<DbPool, AppError> { 38 - // Registers sqlite-vec for all future rusqlite connections. 39 46 unsafe { 40 - sqlite3_auto_extension(Some(std::mem::transmute(sqlite3_vec_init as *const ()))); 47 + let init: SqliteVecInit = sqlite3_vec_init; 48 + let auto_extension: SqliteAutoExtension = std::mem::transmute(init); 49 + sqlite3_auto_extension(Some(auto_extension)); 41 50 } 42 51 43 52 let database_path = resolve_database_path(app)?;
+2 -2
src-tauri/src/error.rs
··· 15 15 } 16 16 17 17 impl TypeaheadFetchError { 18 - pub fn decode(error: reqwest::Error) -> Self { 18 + pub fn decode(error: &reqwest::Error) -> Self { 19 19 Self { 20 20 kind: TypeaheadFetchErrorKind::Decode, 21 21 message: format!("failed to decode typeahead response: {error}"), ··· 29 29 } 30 30 } 31 31 32 - pub fn transport(error: reqwest::Error) -> Self { 32 + pub fn transport(error: &reqwest::Error) -> Self { 33 33 Self { 34 34 kind: TypeaheadFetchErrorKind::Transport, 35 35 message: format!("failed to reach typeahead endpoint: {error}"),
+1
src-tauri/src/migrations/004_account_avatars.sql
··· 1 + ALTER TABLE accounts ADD COLUMN avatar TEXT;
+1
src-tauri/src/state.rs
··· 35 35 pub handle: String, 36 36 pub pds_url: String, 37 37 pub active: bool, 38 + pub avatar: Option<String>, 38 39 } 39 40 40 41 pub struct AppState {
+1
src/App.tsx
··· 204 204 class="grid h-screen min-h-screen overflow-hidden grid-cols-(--app-rail-cols) transition-[grid-template-columns] duration-300 ease-out max-[1180px]:h-auto max-[1180px]:min-h-screen max-[1180px]:grid-cols-1 max-[1180px]:overflow-visible" 205 205 style={{ "--app-rail-cols": railColumns() }}> 206 206 <AppRail 207 + activeAccount={activeAccount()} 207 208 activeSession={app.activeSession} 208 209 accounts={app.accounts} 209 210 collapsed={railCompact()}
+5 -3
src/components/AppRail.tsx
··· 8 8 function RailHeader(props: { collapsed: boolean; onToggleCollapse: () => void }) { 9 9 return ( 10 10 <div 11 - class="flex items-center justify-between gap-3 max-[1180px]:items-center" 11 + class="flex shrink-0 items-center justify-between gap-3 max-[1180px]:min-w-0 max-[1180px]:items-center" 12 12 classList={{ "w-full flex-col gap-3": props.collapsed }}> 13 13 <Wordmark compact={props.collapsed} iconClass="text-primary" /> 14 14 <button ··· 27 27 28 28 function RailNavigation(props: { collapsed: boolean; hasSession: boolean }) { 29 29 return ( 30 - <div class="grid gap-1 max-[1180px]:flex max-[1180px]:items-center"> 30 + <div class="grid gap-1 max-[1180px]:min-w-0 max-[1180px]:flex-1 max-[1180px]:overflow-x-auto max-[1180px]:overscroll-contain max-[1180px]:[scrollbar-width:none] max-[1180px]:[&::-webkit-scrollbar]:hidden"> 31 31 <Show 32 32 when={props.hasSession} 33 33 fallback={<RailButton end compact={props.collapsed} href="/auth" label="Accounts" icon="profile" />}> ··· 42 42 43 43 export function AppRail( 44 44 props: { 45 + activeAccount: AccountSummary | null; 45 46 activeSession: ActiveSession | null; 46 47 accounts: AccountSummary[]; 47 48 collapsed: boolean; ··· 57 58 ) { 58 59 return ( 59 60 <aside 60 - class="flex min-h-screen flex-col gap-6 overflow-visible bg-surface-container-lowest px-6 pb-6 pt-6 transition-[padding,gap] duration-300 ease-out max-[1180px]:min-h-0 max-[1180px]:grid max-[1180px]:grid-cols-[auto_auto_minmax(18rem,1fr)] max-[1180px]:items-center max-[1180px]:gap-4 max-[1180px]:p-4 max-[760px]:grid-cols-1" 61 + class="flex min-h-screen min-w-0 flex-col gap-6 overflow-visible bg-surface-container-lowest px-6 pb-6 pt-6 transition-[padding,gap] duration-300 ease-out max-[1180px]:min-h-0 max-[1180px]:flex-row max-[1180px]:flex-wrap max-[1180px]:items-center max-[1180px]:gap-3 max-[1180px]:p-4 max-[760px]:items-stretch" 61 62 classList={{ "items-center px-4": props.collapsed, "gap-5": props.collapsed }} 62 63 aria-label="Primary navigation"> 63 64 <RailHeader collapsed={props.collapsed} onToggleCollapse={props.onToggleCollapse} /> 64 65 <RailNavigation collapsed={props.collapsed} hasSession={props.hasSession} /> 65 66 <AccountSwitcher 67 + activeAccount={props.activeAccount} 66 68 activeSession={props.activeSession} 67 69 accounts={props.accounts} 68 70 busyDid={props.switchingDid}
+3 -3
src/components/AvatarBadge.tsx
··· 1 1 import { createMemo } from "solid-js"; 2 2 3 - type AvatarBadgeProps = { label: string; tone?: "primary" | "muted" }; 3 + type AvatarBadgeProps = { label: string; src?: string | null; tone?: "primary" | "muted" }; 4 4 5 5 export function AvatarBadge(props: AvatarBadgeProps) { 6 6 const label = createMemo(() => { ··· 10 10 11 11 return ( 12 12 <span 13 - class="inline-flex h-10 w-10 items-center justify-center rounded-full text-sm font-bold tracking-[0.08em]" 13 + class="inline-flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-full text-sm font-bold tracking-[0.08em]" 14 14 classList={{ 15 15 "bg-primary text-[color:var(--on-primary-fixed)]": props.tone === "primary", 16 16 "bg-white/8 text-on-surface": props.tone !== "primary", 17 17 }}> 18 - {label()} 18 + {props.src ? <img class="h-full w-full object-cover" src={props.src} alt="" /> : label()} 19 19 </span> 20 20 ); 21 21 }
+1 -1
src/components/RailButton.tsx
··· 9 9 <A 10 10 href={props.href} 11 11 end={props.end} 12 - class="flex h-11 items-center gap-2.5 rounded-lg border-0 bg-transparent text-on-surface-variant no-underline transition duration-150 ease-out hover:-translate-y-px hover:bg-surface-bright hover:text-on-surface" 12 + class="flex h-11 shrink-0 items-center gap-2.5 rounded-lg border-0 bg-transparent text-on-surface-variant no-underline transition duration-150 ease-out hover:-translate-y-px hover:bg-surface-bright hover:text-on-surface" 13 13 activeClass="bg-surface-container text-primary" 14 14 inactiveClass="" 15 15 classList={{ "w-[2.75rem] justify-center": !!props.compact, "px-3": !props.compact }}
+9 -2
src/components/account/AccountSwitcher.tsx
··· 6 6 import { AccountSwitcherMenuList } from "./AccountSwitcherMenuList"; 7 7 8 8 type AccountSwitcherProps = { 9 + activeAccount: AccountSummary | null; 9 10 activeSession: ActiveSession | null; 10 11 accounts: AccountSummary[]; 11 12 busyDid: string | null; ··· 43 44 return ( 44 45 <div 45 46 class="relative mt-auto w-full transition-[width,max-width] duration-300 ease-out max-[1180px]:mt-0 max-[1180px]:max-w-[24rem] max-[1180px]:justify-self-end max-[760px]:max-w-none" 46 - classList={{ "w-auto": !!props.compact }} 47 + classList={{ 48 + "w-auto": !!props.compact, 49 + "max-[980px]:order-3 max-[980px]:w-full max-[980px]:max-w-none max-[980px]:justify-self-stretch": !props 50 + .compact, 51 + }} 47 52 ref={(element) => { 48 53 container = element; 49 54 }}> 50 55 <button 51 - class="relative w-full cursor-pointer border-0 bg-white/4 text-on-surface shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)] transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8" 56 + class="relative w-full min-w-0 cursor-pointer border-0 bg-white/4 text-on-surface shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)] transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8" 52 57 classList={{ 53 58 "rounded-xl py-[0.95rem] pr-10 pl-4": !props.compact, 54 59 "grid h-14 w-14 place-items-center rounded-full p-0": !!props.compact, ··· 63 68 keyed 64 69 fallback={ 65 70 <SwitcherIdentity 71 + avatar={null} 66 72 compact={props.compact} 67 73 label="?" 68 74 name="Sign in" ··· 71 77 }> 72 78 {(session) => ( 73 79 <SwitcherIdentity 80 + avatar={props.activeAccount?.avatar} 74 81 compact={props.compact} 75 82 label={session.handle} 76 83 name={session.handle}
+12 -5
src/components/account/AccountSwitcherIdentity.tsx
··· 3 3 import { AvatarBadge } from "../AvatarBadge"; 4 4 5 5 export function SwitcherIdentity( 6 - props: { compact?: boolean; label: string; name: string; meta: string; tone: "primary" | "muted" }, 6 + props: { 7 + avatar?: string | null; 8 + compact?: boolean; 9 + label: string; 10 + name: string; 11 + meta: string; 12 + tone: "primary" | "muted"; 13 + }, 7 14 ) { 8 15 return ( 9 16 <Motion.div 10 - class="flex items-center gap-3" 17 + class="flex min-w-0 items-center gap-3" 11 18 classList={{ "justify-center": !!props.compact }} 12 19 initial={{ opacity: 0, y: 8, scale: 0.96 }} 13 20 animate={{ opacity: 1, y: 0, scale: 1 }} 14 21 exit={{ opacity: 0, y: -6, scale: 0.94 }} 15 22 transition={{ duration: 0.24 }}> 16 - <AvatarBadge label={props.label} tone={props.tone} /> 23 + <AvatarBadge label={props.label} src={props.avatar} tone={props.tone} /> 17 24 <Show when={!props.compact}> 18 - <div class="grid"> 19 - <span class="text-[0.92rem] font-semibold">{props.name}</span> 25 + <div class="grid min-w-0"> 26 + <span class="truncate text-[0.92rem] font-semibold">{props.name}</span> 20 27 <span class="text-xs text-on-surface-variant">{props.meta}</span> 21 28 </div> 22 29 </Show>
+4 -3
src/components/account/AccountSwitcherRow.tsx
··· 16 16 return ( 17 17 <div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2" role="menuitem"> 18 18 <button 19 - class="flex items-center gap-3 rounded-2xl border-0 bg-white/2 p-3 text-left text-on-surface transition duration-150 ease-out hover:bg-surface-bright" 19 + class="flex min-w-0 items-center gap-3 rounded-2xl border-0 bg-white/2 p-3 text-left text-on-surface transition duration-150 ease-out hover:bg-surface-bright" 20 20 classList={{ "bg-primary/15": props.account.active }} 21 21 type="button" 22 22 disabled={isLocked()} 23 23 onClick={() => props.onSwitch(props.account.did)}> 24 24 <AvatarBadge 25 25 label={props.account.handle || props.account.did} 26 + src={props.account.avatar} 26 27 tone={props.account.active ? "primary" : "muted"} /> 27 - <span class="grid"> 28 - <span class="text-[0.92rem] font-semibold">{props.account.handle || props.account.did}</span> 28 + <span class="grid min-w-0"> 29 + <span class="truncate text-[0.92rem] font-semibold">{props.account.handle || props.account.did}</span> 29 30 <span class="text-xs text-on-surface-variant">{props.account.pdsUrl || "PDS unavailable"}</span> 30 31 </span> 31 32 </button>
+4 -4
src/components/feeds/FeedComposer.test.tsx
··· 2 2 import { describe, expect, it } from "vitest"; 3 3 import { FeedComposer } from "./FeedComposer"; 4 4 5 - const suggestions = Array.from({ length: 13 }, (_, index) => ({ 6 - label: `@handle-${index + 1}.test`, 7 - type: "handle" as const, 8 - })); 5 + const suggestions = Array.from( 6 + { length: 13 }, 7 + (_, index) => ({ label: `@handle-${index + 1}.test`, type: "handle" as const }), 8 + ); 9 9 10 10 describe("FeedComposer", () => { 11 11 it("renders a contained scroll region for typeahead suggestions", () => {
+2 -2
src/components/feeds/FeedComposer.tsx
··· 9 9 export function ComposerLauncher(props: { activeHandle: string; onCompose: () => void }) { 10 10 return ( 11 11 <button 12 - class="mb-4 flex w-full items-center gap-3 rounded-3xl border-0 bg-white/3 px-4 py-4 text-left text-on-surface-variant shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)] transition duration-150 ease-out hover:bg-white/5" 12 + class="mb-4 flex w-full min-w-0 items-center gap-3 rounded-3xl border-0 bg-white/3 px-4 py-4 text-left text-on-surface-variant shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)] transition duration-150 ease-out hover:bg-white/5 max-[760px]:gap-2 max-[760px]:px-3.5 max-[520px]:py-3.5" 13 13 type="button" 14 14 onClick={() => props.onCompose()}> 15 15 <div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[linear-gradient(135deg,rgba(125,175,255,0.9),rgba(0,115,222,0.72))] text-sm font-semibold text-on-primary-fixed"> ··· 18 18 <div class="min-w-0 flex-1"> 19 19 <p class="m-0 wrap-break-word text-[0.9rem] text-on-surface-variant">What's happening?</p> 20 20 </div> 21 - <div class="flex items-center gap-1 text-on-surface-variant"> 21 + <div class="flex items-center gap-1 text-on-surface-variant max-[520px]:hidden"> 22 22 <Icon aria-hidden="true" kind="at" /> 23 23 <Icon aria-hidden="true" kind="hashtag" /> 24 24 <Icon aria-hidden="true" kind="quote" />
+1 -1
src/components/feeds/FeedContent.tsx
··· 53 53 <For each={[props.activeFeedId]}> 54 54 {() => ( 55 55 <Motion.div 56 - class="grid gap-3" 56 + class="grid min-w-0 gap-3" 57 57 initial={{ opacity: 0 }} 58 58 animate={{ opacity: 1 }} 59 59 exit={{ opacity: 0 }}
+6 -6
src/components/feeds/FeedPane.tsx
··· 8 8 9 9 function FeedHeaderActions(props: { onCompose: () => void; onToggleDrawer: () => void }) { 10 10 return ( 11 - <div class="flex shrink-0 items-center gap-2 max-[640px]:justify-between"> 11 + <div class="flex shrink-0 flex-wrap items-center justify-end gap-2 max-[640px]:w-full max-[640px]:justify-between"> 12 12 <button 13 - class="inline-flex h-11 items-center gap-2 rounded-full border-0 bg-white/5 px-4 text-sm text-on-surface transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8" 13 + class="inline-flex h-11 items-center gap-2 rounded-full border-0 bg-white/5 px-4 text-sm text-on-surface transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8 max-[520px]:flex-1 max-[520px]:justify-center" 14 14 type="button" 15 15 onClick={() => props.onCompose()}> 16 16 <Icon aria-hidden="true" kind="quill" /> ··· 54 54 return ( 55 55 <div 56 56 ref={(element) => props.scrollerRef(element)} 57 - class="feed-scroll-region min-h-0 overflow-y-auto overscroll-contain px-6 pb-8 pt-4" 57 + class="feed-scroll-region min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain px-6 pb-8 pt-4 max-[760px]:px-4 max-[520px]:px-3" 58 58 onScroll={(event) => props.setScrollTop(event.currentTarget.scrollTop)}> 59 59 <ComposerLauncher activeHandle={props.activeHandle} onCompose={props.onCompose} /> 60 60 <FeedContent ··· 87 87 }, 88 88 ) { 89 89 return ( 90 - <div class="flex items-start justify-between gap-4 max-[640px]:flex-col max-[640px]:items-stretch"> 90 + <div class="flex min-w-0 items-start justify-between gap-4 max-[900px]:gap-3 max-[640px]:flex-col max-[640px]:items-stretch"> 91 91 <div class="min-w-0"> 92 92 <p class="m-0 text-xl font-semibold tracking-tight text-on-surface">Timeline</p> 93 93 <p class="mt-1 wrap-break-word text-xs uppercase tracking-[0.12em] text-on-surface-variant"> ··· 110 110 }, 111 111 ) { 112 112 return ( 113 - <header class="sticky top-0 z-20 rounded-t-4xl bg-[rgba(14,14,14,0.94)] px-6 pb-3 pt-5 backdrop-blur-[18px] shadow-[inset_0_-1px_0_rgba(255,255,255,0.04)]"> 113 + <header class="sticky top-0 z-20 overflow-hidden rounded-t-4xl bg-[rgba(14,14,14,0.94)] px-6 pb-3 pt-5 backdrop-blur-[18px] shadow-[inset_0_-1px_0_rgba(255,255,255,0.04)] max-[760px]:px-4 max-[760px]:pt-4 max-[520px]:px-3"> 114 114 <FeedPaneTitle 115 115 activeFeed={props.activeFeed} 116 116 generators={props.generators} ··· 156 156 }, 157 157 ) { 158 158 return ( 159 - <section class="grid min-h-0 grid-rows-[auto_minmax(0,1fr)] rounded-4xl bg-[rgba(8,8,8,0.32)] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)]"> 159 + <section class="grid min-h-0 min-w-0 overflow-hidden grid-rows-[auto_minmax(0,1fr)] rounded-4xl bg-[rgba(8,8,8,0.32)] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)]"> 160 160 <FeedPaneHeader 161 161 activeFeed={props.activeFeed} 162 162 generators={props.generators}
+33
src/components/feeds/FeedTabs.test.tsx
··· 1 + import { render, screen } from "@solidjs/testing-library"; 2 + import { describe, expect, it, vi } from "vitest"; 3 + import { FeedTabBar } from "./FeedTabs"; 4 + 5 + describe("FeedTabBar", () => { 6 + it("renders feed labels without numeric badges", () => { 7 + render(() => ( 8 + <FeedTabBar 9 + activeFeedId="for-you" 10 + generators={{ 11 + "at://feed/quiet": { 12 + avatar: null, 13 + did: "did:plc:quiet", 14 + displayName: "Quiet Posters", 15 + uri: "at://feed/quiet", 16 + }, 17 + }} 18 + onFeedSelect={vi.fn()} 19 + onToggleDrawer={vi.fn()} 20 + pinnedFeeds={[{ id: "for-you", pinned: true, type: "timeline", value: "following" }, { 21 + id: "quiet", 22 + pinned: true, 23 + type: "feed", 24 + value: "at://feed/quiet", 25 + }]} /> 26 + )); 27 + 28 + expect(screen.getByRole("button", { name: /following/i })).toBeInTheDocument(); 29 + expect(screen.getByRole("button", { name: /quiet posters/i })).toBeInTheDocument(); 30 + expect(screen.queryByText(/^1$/u)).not.toBeInTheDocument(); 31 + expect(screen.queryByText(/^2$/u)).not.toBeInTheDocument(); 32 + }); 33 + });
+9 -15
src/components/feeds/FeedTabs.tsx
··· 14 14 }, 15 15 ) { 16 16 return ( 17 - <div class="mt-4 flex items-end justify-between gap-3 max-[720px]:flex-col max-[720px]:items-stretch"> 18 - <div class="flex min-w-0 gap-1 overflow-x-auto overscroll-contain pb-1"> 17 + <div class="mt-4 flex items-start gap-3 max-[720px]:mt-3 max-[720px]:gap-2"> 18 + <div class="flex min-w-0 flex-1 gap-1.5 overflow-x-auto overscroll-contain pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"> 19 19 <For each={props.pinnedFeeds}> 20 - {(feed, index) => ( 20 + {(feed) => ( 21 21 <FeedTab 22 22 active={props.activeFeedId === feed.id} 23 23 feed={feed} 24 24 generator={props.generators[feed.value]} 25 - index={index() + 1} 26 25 onSelect={props.onFeedSelect} /> 27 26 )} 28 27 </For> 29 28 </div> 30 29 <button 31 - class="inline-flex h-11 items-center gap-2 rounded-full border-0 bg-white/5 px-4 text-sm text-on-surface transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8" 30 + class="inline-flex h-11 shrink-0 items-center gap-2 rounded-full border-0 bg-white/5 px-4 text-sm text-on-surface transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8 max-[1040px]:px-3 max-[1040px]:text-xs max-[920px]:hidden" 32 31 type="button" 33 32 onClick={() => props.onToggleDrawer()}> 34 33 <Icon aria-hidden="true" iconClass="i-ri-stack-line" /> ··· 39 38 } 40 39 41 40 function FeedTab( 42 - props: { 43 - active: boolean; 44 - feed: SavedFeedItem; 45 - generator?: FeedGeneratorView; 46 - index: number; 47 - onSelect: (feedId: string) => void; 48 - }, 41 + props: { active: boolean; feed: SavedFeedItem; generator?: FeedGeneratorView; onSelect: (feedId: string) => void }, 49 42 ) { 50 43 return ( 51 44 <button 52 - class="relative inline-flex min-h-12 max-w-full shrink-0 items-center gap-2 rounded-full border-0 px-4 text-sm font-medium text-on-surface-variant transition duration-150 ease-out hover:bg-white/5 hover:text-on-surface" 45 + class="relative inline-flex min-h-12 max-w-full shrink-0 items-center gap-2 rounded-full border-0 px-4 text-sm font-medium text-on-surface-variant transition duration-150 ease-out hover:bg-white/5 hover:text-on-surface max-[720px]:min-h-11 max-[720px]:gap-1.5 max-[720px]:px-3 max-[720px]:text-[0.82rem]" 53 46 classList={{ 54 47 "bg-[rgba(125,175,255,0.12)] text-primary shadow-[inset_0_0_0_1px_rgba(125,175,255,0.2)]": props.active, 55 48 }} 56 49 type="button" 57 50 onClick={() => props.onSelect(props.feed.id)}> 58 51 <FeedChipAvatar feed={props.feed} generator={props.generator} /> 59 - <span class="truncate">{getFeedName(props.feed, props.generator?.displayName)}</span> 60 - <span class="rounded-full bg-black/25 px-1.5 py-0.5 text-[0.65rem] text-on-surface-variant">{props.index}</span> 52 + <span class="max-w-[11rem] truncate max-[900px]:max-w-[9rem] max-[720px]:max-w-[7.5rem]"> 53 + {getFeedName(props.feed, props.generator?.displayName)} 54 + </span> 61 55 </button> 62 56 ); 63 57 }
+18 -4
src/components/feeds/FeedWorkspace.tsx
··· 89 89 let scroller: HTMLDivElement | undefined; 90 90 let sentinel: HTMLDivElement | undefined; 91 91 const postRefs = new Map<string, HTMLElement>(); 92 + let lastFocusedUri: string | null = null; 92 93 93 94 const savedFeeds = createMemo(() => { 94 95 const stored = workspace.preferences?.savedFeeds ?? []; ··· 184 185 createEffect(() => { 185 186 const item = visibleItems()[workspace.focusedIndex]; 186 187 if (!item) { 188 + lastFocusedUri = null; 189 + return; 190 + } 191 + 192 + if (lastFocusedUri === item.post.uri) { 187 193 return; 188 194 } 189 195 196 + lastFocusedUri = item.post.uri; 190 197 queueMicrotask(() => { 191 198 const element = postRefs.get(item.post.uri); 192 - element?.focus(); 193 - element?.scrollIntoView({ block: "nearest" }); 199 + if (!element?.isConnected) { 200 + return; 201 + } 202 + 203 + if (document.activeElement !== element) { 204 + element.focus(); 205 + } 206 + 207 + element.scrollIntoView({ block: "nearest" }); 194 208 }); 195 209 }); 196 210 ··· 563 577 564 578 return ( 565 579 <> 566 - <div class="grid h-full min-h-0 gap-6 xl:grid-cols-[minmax(0,1fr)_20rem]"> 580 + <div class="grid h-full min-h-0 min-w-0 gap-6 xl:grid-cols-[minmax(0,1fr)_20rem]"> 567 581 <FeedPane 568 582 activeFeed={activeFeed()} 569 583 activeFeedId={activeFeed().id} ··· 665 679 }, 666 680 ) { 667 681 return ( 668 - <aside class="grid min-h-0 gap-4 overflow-hidden xl:overflow-y-auto xl:overscroll-contain"> 682 + <aside class="grid min-h-0 min-w-0 gap-4 overflow-hidden xl:overflow-y-auto xl:overscroll-contain"> 669 683 <SavedFeedsCard drawerFeeds={props.drawerFeeds} generators={props.generators} onFeedSelect={props.onFeedSelect} /> 670 684 <DisplayFiltersCard activePref={props.activePref} onPrefChange={props.onPrefChange} /> 671 685 <ShortcutsCard />
+6 -6
src/components/feeds/PostCard.tsx
··· 47 47 return ( 48 48 <Motion.article 49 49 ref={(element) => props.registerRef?.(element)} 50 - class="group rounded-[1.6rem] bg-white/2.5 px-5 py-5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)] transition duration-150 ease-out hover:bg-white/4" 50 + class="group min-w-0 overflow-hidden rounded-[1.6rem] bg-white/2.5 px-5 py-5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)] transition duration-150 ease-out hover:bg-white/4 max-[760px]:px-4 max-[760px]:py-4 max-[520px]:rounded-[1.35rem] max-[520px]:px-3.5" 51 51 classList={{ 52 52 "bg-[linear-gradient(135deg,rgba(125,175,255,0.11),rgba(0,115,222,0.06))] shadow-[inset_0_0_0_1px_rgba(125,175,255,0.22),0_0_0_1px_rgba(125,175,255,0.08)]": 53 53 !!props.focused, ··· 71 71 </div> 72 72 </Show> 73 73 74 - <div class="flex gap-3"> 74 + <div class="flex min-w-0 gap-3"> 75 75 <AuthorAvatar avatar={props.post.author.avatar} label={getAvatarLabel(props.post.author)} /> 76 76 77 77 <div class="min-w-0 flex-1"> ··· 93 93 94 94 <PostEmbeds post={props.post} /> 95 95 96 - <footer class="mt-4 flex flex-wrap items-center gap-2"> 96 + <footer class="mt-4 flex min-w-0 flex-wrap items-center gap-2 max-[520px]:gap-1"> 97 97 <ActionButton 98 98 active={isLiked()} 99 99 busy={!!props.likePending} ··· 150 150 return ( 151 151 <button 152 152 aria-label={props.label} 153 - class="inline-flex items-center gap-1.5 rounded-full border-0 bg-transparent px-3 py-2 text-xs text-on-surface-variant transition duration-150 ease-out hover:-translate-y-px hover:bg-white/5 hover:text-primary disabled:cursor-wait disabled:opacity-70" 153 + class="inline-flex min-w-0 items-center gap-1.5 rounded-full border-0 bg-transparent px-3 py-2 text-xs text-on-surface-variant transition duration-150 ease-out hover:-translate-y-px hover:bg-white/5 hover:text-primary disabled:cursor-wait disabled:opacity-70 max-[520px]:px-2.5" 154 154 classList={{ "text-primary": !!props.active }} 155 155 type="button" 156 156 disabled={props.busy} ··· 205 205 206 206 function ImageEmbed(props: { embed: ImagesEmbedView }) { 207 207 return ( 208 - <div class="grid gap-2" classList={{ "grid-cols-2": props.embed.images.length > 1 }}> 208 + <div class="grid min-w-0 gap-2" classList={{ "grid-cols-2": props.embed.images.length > 1 }}> 209 209 <For each={props.embed.images.slice(0, 4)}> 210 210 {(image) => ( 211 211 <div class="overflow-hidden rounded-[1.2rem] bg-black/30 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]"> ··· 220 220 function ExternalEmbed(props: { description?: string; thumb?: string; title?: string; uri?: string }) { 221 221 return ( 222 222 <a 223 - class="grid gap-3 overflow-hidden rounded-[1.25rem] bg-black/30 p-3 text-inherit no-underline shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)] transition duration-150 ease-out hover:bg-black/40" 223 + class="grid min-w-0 gap-3 overflow-hidden rounded-[1.25rem] bg-black/30 p-3 text-inherit no-underline shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)] transition duration-150 ease-out hover:bg-black/40" 224 224 href={props.uri} 225 225 rel="noreferrer" 226 226 target="_blank">
+4 -19
src/lib/feeds.test.ts
··· 1 1 import { describe, expect, it } from "vitest"; 2 - import { 3 - applyFeedPreferences, 4 - buildThreadRoute, 5 - decodeThreadRouteUri, 6 - getFeedCommand, 7 - } from "./feeds"; 2 + import { applyFeedPreferences, buildThreadRoute, decodeThreadRouteUri, getFeedCommand } from "./feeds"; 8 3 import type { FeedViewPost, FeedViewPrefItem, SavedFeedItem } from "./types"; 9 4 10 5 function createFeedItem(overrides: Partial<FeedViewPost> = {}): FeedViewPost { ··· 54 49 const quote = createFeedItem({ 55 50 post: { 56 51 ...base.post, 57 - embed: { 58 - $type: "app.bsky.embed.record#view", 59 - record: { uri: "at://did:plc:bob/app.bsky.feed.post/9" }, 60 - }, 52 + embed: { $type: "app.bsky.embed.record#view", record: { uri: "at://did:plc:bob/app.bsky.feed.post/9" } }, 61 53 uri: "at://did:plc:alice/app.bsky.feed.post/4", 62 54 }, 63 55 }); ··· 86 78 value: "at://did:plc:alice/app.bsky.graph.list/list", 87 79 }; 88 80 89 - expect(getFeedCommand(timeline)).toEqual({ 90 - args: expect.any(Function), 91 - name: "get_timeline", 92 - }); 81 + expect(getFeedCommand(timeline)).toEqual({ args: expect.any(Function), name: "get_timeline" }); 93 82 expect(getFeedCommand(feed).name).toBe("get_feed"); 94 83 expect(getFeedCommand(list).name).toBe("get_list_feed"); 95 - expect(getFeedCommand(list).args("cursor-1", 30)).toEqual({ 96 - cursor: "cursor-1", 97 - limit: 30, 98 - uri: list.value, 99 - }); 84 + expect(getFeedCommand(list).args("cursor-1", 30)).toEqual({ cursor: "cursor-1", limit: 30, uri: list.value }); 100 85 }); 101 86 102 87 it("encodes and decodes thread routes", () => {
+1 -1
src/lib/types.ts
··· 1 1 export type Maybe<T> = T | null | undefined; 2 2 3 - export type AccountSummary = { did: string; handle: string; pdsUrl: string; active: boolean }; 3 + export type AccountSummary = { did: string; handle: string; pdsUrl: string; active: boolean; avatar?: string | null }; 4 4 5 5 export type ActiveSession = { did: string; handle: string }; 6 6
+14 -5
src/router.tsx
··· 1 - import { HashRouter, Navigate, Route, type RouteSectionProps, useLocation, useNavigate, useParams } from "@solidjs/router"; 1 + import { 2 + HashRouter, 3 + Navigate, 4 + Route, 5 + type RouteSectionProps, 6 + useLocation, 7 + useNavigate, 8 + useParams, 9 + } from "@solidjs/router"; 2 10 import { type Component, createEffect, type JSX, Show } from "solid-js"; 3 11 import { buildThreadRoute, decodeThreadRouteUri, TIMELINE_ROUTE } from "./lib/feeds"; 4 12 import type { ActiveSession } from "./lib/types"; ··· 134 142 135 143 return ( 136 144 <ProtectedRouteView bootstrapping={props.bootstrapping} session={props.session}> 137 - {(session) => props.renderTimeline(session, { 138 - onThreadRouteChange: (uri) => navigate(uri ? buildThreadRoute(uri) : TIMELINE_ROUTE), 139 - threadUri: props.threadUri, 140 - })} 145 + {(session) => 146 + props.renderTimeline(session, { 147 + onThreadRouteChange: (uri) => navigate(uri ? buildThreadRoute(uri) : TIMELINE_ROUTE), 148 + threadUri: props.threadUri, 149 + })} 141 150 </ProtectedRouteView> 142 151 ); 143 152 }
+1 -2
src/test/setup.ts
··· 7 7 "solid-motionone", 8 8 () => ({ 9 9 Motion: new Proxy({}, { 10 - get: (_, property) => 11 - (props: { children?: unknown }) => Dynamic({ ...props, component: String(property) }), 10 + get: (_, property) => (props: { children?: unknown }) => Dynamic({ ...props, component: String(property) }), 12 11 }), 13 12 Presence: (props: { children?: unknown }) => props.children as unknown, 14 13 }),