replies timeline only, appview-less bluesky client

virtual list

ptr.pet 10ca2e90 449fb9da

verified
+8
deno.lock
··· 22 22 "npm:@sveltejs/vite-plugin-svelte@^6.2.1": "6.2.1_svelte@5.46.1__acorn@8.15.0_vite@7.3.0__@types+node@25.0.3__picomatch@4.0.3_@types+node@25.0.3", 23 23 "npm:@tailwindcss/forms@~0.5.11": "0.5.11_tailwindcss@4.1.18", 24 24 "npm:@tailwindcss/vite@^4.1.18": "4.1.18_vite@7.3.0__@types+node@25.0.3__picomatch@4.0.3_@types+node@25.0.3", 25 + "npm:@tutorlatin/svelte-tiny-virtual-list@^3.0.17": "3.0.17_svelte@5.46.1__acorn@8.15.0", 25 26 "npm:@types/node@^25.0.3": "25.0.3", 26 27 "npm:@wora/cache-persist@^2.2.1": "2.2.1", 27 28 "npm:async-cache-dedupe@^3.4.0": "3.4.0", ··· 714 715 "@tailwindcss/oxide", 715 716 "tailwindcss", 716 717 "vite" 718 + ] 719 + }, 720 + "@tutorlatin/svelte-tiny-virtual-list@3.0.17_svelte@5.46.1__acorn@8.15.0": { 721 + "integrity": "sha512-OvFRITfbWdsFk7VR2FKVJiBMPlgbyc81hqbFORXdEcBXcT91XRdLXfhSbS8o14ntUBMFPWVv19fti+Ez50q45g==", 722 + "dependencies": [ 723 + "svelte" 717 724 ] 718 725 }, 719 726 "@types/cookie@0.6.0": { ··· 1814 1821 "npm:@sveltejs/vite-plugin-svelte@^6.2.1", 1815 1822 "npm:@tailwindcss/forms@~0.5.11", 1816 1823 "npm:@tailwindcss/vite@^4.1.18", 1824 + "npm:@tutorlatin/svelte-tiny-virtual-list@^3.0.17", 1817 1825 "npm:@types/node@^25.0.3", 1818 1826 "npm:@wora/cache-persist@^2.2.1", 1819 1827 "npm:async-cache-dedupe@^3.4.0",
+1
package.json
··· 27 27 "@atcute/tid": "^1.0.3", 28 28 "@floating-ui/dom": "^1.7.4", 29 29 "@soffinal/websocket": "^0.2.1", 30 + "@tutorlatin/svelte-tiny-virtual-list": "^3.0.17", 30 31 "@wora/cache-persist": "^2.2.1", 31 32 "async-cache-dedupe": "^3.4.0", 32 33 "hash-wasm": "^4.12.0",
+2 -1
src/app.html
··· 2 2 <html lang="en"> 3 3 <head> 4 4 <meta charset="utf-8" /> 5 - <meta name="viewport" content="width=device-width, initial-scale=1" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> 6 + <meta name="theme-color" content="#11001c" /> 6 7 %sveltekit.head% 7 8 </head> 8 9 <body data-sveltekit-preload-data="hover">
+55 -56
src/components/FollowingView.svelte
··· 6 6 import { getRelativeTime } from '$lib/date'; 7 7 import { generateColorForDid } from '$lib/accounts'; 8 8 import { type AtprotoDid } from '@atcute/lexicons/syntax'; 9 - import { flip } from 'svelte/animate'; 10 - import { cubicOut } from 'svelte/easing'; 9 + import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'; 11 10 12 11 interface Props { 13 12 selectedDid: Did; ··· 194 193 ); 195 194 </script> 196 195 197 - {#snippet followingItems()} 198 - {#each sortedFollowing as user (user.did)} 199 - {@const stats = user.data!} 200 - {@const lastPostAt = stats.lastPostAt} 201 - {@const relTime = getRelativeTime(lastPostAt, currentTime)} 202 - {@const color = generateColorForDid(user.did)} 203 - <div animate:flip={{ duration: 350, easing: cubicOut }}> 204 - <div 205 - class="group flex items-center gap-2 rounded-sm bg-(--nucleus-accent)/7 p-3 transition-colors hover:bg-(--post-color)/20" 206 - style={`--post-color: ${color};`} 207 - > 208 - <ProfilePicture client={selectedClient} did={user.did} size={10} /> 209 - <div class="min-w-0 flex-1 space-y-1"> 210 - <div 211 - class="flex items-baseline gap-2 font-bold transition-colors group-hover:text-(--post-color)" 212 - style={`--post-color: ${color};`} 213 - > 214 - {#await Promise.all([user.profile, user.handle]) then [displayName, handle]} 215 - <span class="truncate">{displayName || handle}</span> 216 - <span class="truncate text-sm opacity-60">@{handle}</span> 217 - {/await} 218 - </div> 219 - <div class="flex gap-2 text-xs opacity-70"> 220 - <span 221 - class={Date.now() - lastPostAt.getTime() < 1000 * 60 * 60 * 2 222 - ? 'text-(--nucleus-accent)' 223 - : ''} 224 - > 225 - posted {relTime} 226 - {relTime !== 'now' ? 'ago' : ''} 227 - </span> 228 - {#if stats.recentPostCount > 0} 229 - <span class="text-(--nucleus-accent2)"> 230 - {stats.recentPostCount} posts / 6h 231 - </span> 232 - {/if} 233 - {#if followingSort === 'conversational' && stats.conversationalScore > 0} 234 - <span class="ml-auto font-bold text-(--nucleus-accent)"> 235 - ★ {stats.conversationalScore.toFixed(1)} 236 - </span> 237 - {/if} 238 - </div> 239 - </div> 240 - </div> 241 - </div> 242 - {/each} 243 - {/snippet} 244 - 245 - <div class="p-2"> 246 - <div class="mb-4 flex flex-col justify-between gap-4 px-2 sm:flex-row sm:items-center"> 196 + <div class="flex h-full flex-col p-2"> 197 + <div class="mb-4 flex items-center justify-between gap-2 p-2 px-2 md:gap-4"> 247 198 <div> 248 - <h2 class="text-3xl font-bold">following</h2> 199 + <h2 class="text-2xl font-bold md:text-3xl">following</h2> 249 200 <div class="mt-2 flex gap-2"> 250 201 <div class="h-1 w-8 rounded-full bg-(--nucleus-accent)"></div> 251 202 <div class="h-1 w-11 rounded-full bg-(--nucleus-accent2)"></div> 252 203 </div> 253 204 </div> 254 - <div class="flex flex-wrap gap-2 text-sm"> 205 + <div class="flex gap-1 text-sm sm:gap-2"> 255 206 {#each ['recent', 'active', 'conversational'] as type (type)} 256 207 <button 257 208 class="rounded-sm px-2 py-1 transition-colors {followingSort === type ··· 265 216 </div> 266 217 </div> 267 218 268 - <div class="flex flex-col gap-2"> 219 + <div class="min-h-0 flex-1"> 269 220 {#if sortedFollowing.length === 0} 270 221 <div class="flex justify-center py-8"> 271 222 <div ··· 274 225 ></div> 275 226 </div> 276 227 {:else} 277 - {@render followingItems()} 228 + <VirtualList height="70vh" itemCount={sortedFollowing.length} itemSize={76}> 229 + {#snippet item({ index, style }: { index: number; style: string })} 230 + {@const user = sortedFollowing[index]} 231 + {@const stats = user.data!} 232 + {@const lastPostAt = stats.lastPostAt} 233 + {@const relTime = getRelativeTime(lastPostAt, currentTime)} 234 + {@const color = generateColorForDid(user.did)} 235 + <!-- box-border and pb-2 (0.5rem) simulates the gap-2 --> 236 + <div {style} class="box-border w-full pb-2"> 237 + <div 238 + class="group flex items-center gap-2 rounded-sm bg-(--nucleus-accent)/7 p-3 transition-colors hover:bg-(--post-color)/20" 239 + style={`--post-color: ${color};`} 240 + > 241 + <ProfilePicture client={selectedClient} did={user.did} size={10} /> 242 + <div class="min-w-0 flex-1 space-y-1"> 243 + <div 244 + class="flex items-baseline gap-2 font-bold transition-colors group-hover:text-(--post-color)" 245 + style={`--post-color: ${color};`} 246 + > 247 + {#await Promise.all([user.profile, user.handle]) then [displayName, handle]} 248 + <span class="truncate">{displayName || handle}</span> 249 + <span class="truncate text-sm opacity-60">@{handle}</span> 250 + {/await} 251 + </div> 252 + <div class="flex gap-2 text-xs opacity-70"> 253 + <span 254 + class={Date.now() - lastPostAt.getTime() < 1000 * 60 * 60 * 2 255 + ? 'text-(--nucleus-accent)' 256 + : ''} 257 + > 258 + posted {relTime} 259 + {relTime !== 'now' ? 'ago' : ''} 260 + </span> 261 + {#if stats.recentPostCount > 0} 262 + <span class="text-(--nucleus-accent2)"> 263 + {stats.recentPostCount} posts / 6h 264 + </span> 265 + {/if} 266 + {#if followingSort === 'conversational' && stats.conversationalScore > 0} 267 + <span class="ml-auto font-bold text-(--nucleus-accent)"> 268 + ★ {stats.conversationalScore.toFixed(1)} 269 + </span> 270 + {/if} 271 + </div> 272 + </div> 273 + </div> 274 + </div> 275 + {/snippet} 276 + </VirtualList> 278 277 {/if} 279 278 </div> 280 279 </div>
+2 -2
src/components/SettingsView.svelte
··· 155 155 </div> 156 156 157 157 <div 158 - use:portal={'#app-footer'} 158 + use:portal={'#footer-portal'} 159 159 class=" 160 - fixed bottom-[5dvh] z-20 w-full max-w-2xl p-4 pt-2 shadow-[0_-10px_20px_-5px_rgba(0,0,0,0.1)] 160 + z-20 w-full max-w-2xl bg-(--nucleus-bg) p-4 pt-2 pb-1 shadow-[0_-10px_20px_-5px_rgba(0,0,0,0.1)] 161 161 " 162 162 > 163 163 <Tabs
+17 -4
src/lib/settings.ts
··· 25 25 }; 26 26 27 27 const createSettingsStore = () => { 28 - const stored = localStorage.getItem('settings'); 28 + // Prevent SSR crash if localStorage is missing 29 + const stored = typeof localStorage !== 'undefined' ? localStorage.getItem('settings') : null; 29 30 30 31 const initial: Partial<Settings> = stored ? JSON.parse(stored) : defaultSettings; 31 32 initial.endpoints = { ...defaultSettings.endpoints, ...initial.endpoints }; ··· 35 36 const { subscribe, set, update } = writable<Settings>(initial as Settings); 36 37 37 38 subscribe((settings) => { 39 + if (typeof document === 'undefined') return; 38 40 const theme = settings.theme; 39 41 document.documentElement.style.setProperty('--nucleus-bg', theme.bg); 40 42 document.documentElement.style.setProperty('--nucleus-fg', theme.fg); 41 43 document.documentElement.style.setProperty('--nucleus-accent', theme.accent); 42 44 document.documentElement.style.setProperty('--nucleus-accent2', theme.accent2); 45 + 46 + const oldMeta = document.querySelector('meta[name="theme-color"]'); 47 + if (oldMeta) oldMeta.remove(); 48 + 49 + const metaThemeColor = document.createElement('meta'); 50 + metaThemeColor.setAttribute('name', 'theme-color'); 51 + metaThemeColor.setAttribute('content', theme.bg); 52 + document.head.appendChild(metaThemeColor); 43 53 }); 44 54 45 55 return { 46 56 subscribe, 47 57 set: (value: Settings) => { 48 - localStorage.setItem('settings', JSON.stringify(value)); 58 + if (typeof localStorage !== 'undefined') 59 + localStorage.setItem('settings', JSON.stringify(value)); 49 60 set(value); 50 61 }, 51 62 update: (fn: (value: Settings) => Settings) => { 52 63 update((value) => { 53 64 const newValue = fn(value); 54 - localStorage.setItem('settings', JSON.stringify(newValue)); 65 + if (typeof localStorage !== 'undefined') 66 + localStorage.setItem('settings', JSON.stringify(newValue)); 55 67 return newValue; 56 68 }); 57 69 }, 58 70 reset: () => { 59 - localStorage.setItem('settings', JSON.stringify(defaultSettings)); 71 + if (typeof localStorage !== 'undefined') 72 + localStorage.setItem('settings', JSON.stringify(defaultSettings)); 60 73 set(defaultSettings); 61 74 } 62 75 };
+4 -2
src/routes/+page.svelte
··· 352 352 353 353 <div 354 354 class=" 355 - {currentView === 'timeline' ? '' : 'hidden'} 356 - fixed bottom-[5.5dvh] z-20 w-full max-w-2xl p-2.5 px-4 transition-all 355 + {currentView === 'timeline' || currentView === 'following' ? '' : 'hidden'} 356 + z-20 w-full max-w-2xl p-2.5 px-4 pb-1 transition-all 357 357 " 358 358 > 359 359 <!-- composer and error disclaimer (above thread list, not scrollable) --> ··· 389 389 </div> 390 390 </div> 391 391 </div> 392 + 393 + <div id="footer-portal" class="contents"></div> 392 394 393 395 <div class="footer-border-bg rounded-t-sm px-0.5 pt-0.5"> 394 396 <div class="footer-bg rounded-t-sm">