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: feeds, threads, and post actions

+845 -426
+11 -10
docs/tasks/03-feeds.md
··· 19 19 20 20 ### Frontend — Feed Tabs & Content 21 21 22 - - [ ] Feed tab bar — pinned feeds as tabs, hydrated with generator display names/avatars; `1`–`9` keyboard shortcuts to switch 23 - - [ ] Feed content loader — dispatches to correct endpoint based on feed type (`timeline` / `feed` / `list`) 24 - - [ ] Infinite scroll with cursor pagination and scroll-position preservation 25 - - [ ] `Presence` crossfade animation on tab switch 26 - - [ ] Skeleton screens while feeds load 22 + - [x] Feed tab bar — pinned feeds as tabs, hydrated with generator display names/avatars; `1`–`9` keyboard shortcuts to switch 23 + - [x] Feed content loader — dispatches to correct endpoint based on feed type (`timeline` / `feed` / `list`) 24 + - [x] Infinite scroll with cursor pagination and scroll-position preservation 25 + - [x] `Presence` crossfade animation on tab switch 26 + - [x] Skeleton screens while feeds load 27 27 28 28 ### Frontend — Post Card & Actions 29 29 30 - - [ ] Post card component (author, text, embeds, timestamps, action bar) — `Motion` fade-in on viewport enter 31 - - [ ] Like/repost icon `Motion` scale pop animation (1.0 -> 1.3 -> 1.0) 32 - - [ ] `j/k` keyboard navigation between posts, `l` like, `r` reply, `t` repost, `o` open thread 30 + - [x] Post card component (author, text, embeds, timestamps, action bar) — `Motion` fade-in on viewport enter 31 + - [x] Like/repost icon `Motion` scale pop animation (1.0 -> 1.3 -> 1.0) 32 + - [x] `j/k` keyboard navigation between posts, `l` like, `r` reply, `t` repost, `o` open thread 33 33 34 34 ### Frontend — Thread View 35 35 36 - - [ ] Thread view with nested replies 37 - - [ ] Navigate into thread from post card (`o` / `Enter`) 36 + - [x] Thread view with nested replies 37 + - [x] Navigate into thread from post card (`o` / `Enter`) with route-backed thread URLs 38 38 39 39 ### Frontend — Post Composer 40 40 ··· 47 47 48 48 - [ ] Per-feed display toggles (hide reposts/replies/quotes) via `feedViewPref` 49 49 - [ ] Feeds drawer for accessing saved (unpinned) feeds 50 + - [ ] Feed generator management (pin/unpin, reorder) via `savedFeedsPrefV2`
+1 -1
dprint.json
··· 1 1 { 2 2 "typescript": { "preferSingleLine": true, "jsx.bracketPosition": "sameLine", "jsxElement.preferSingleLine": true }, 3 3 "json": { "preferSingleLine": true, "lineWidth": 120, "indentWidth": 2 }, 4 - "excludes": ["**/node_modules", "apps/web/public/pdf.worker.min.mjs"], 4 + "excludes": ["**/node_modules", "apps/web/public/pdf.worker.min.mjs", "src-tauri/**/*"], 5 5 "plugins": ["https://plugins.dprint.dev/typescript-0.95.8.wasm", "https://plugins.dprint.dev/json-0.20.0.wasm"] 6 6 }
+5 -1
package.json
··· 8 8 "dev": "vite", 9 9 "build": "vite build", 10 10 "serve": "vite preview", 11 - "tauri": "tauri" 11 + "tauri": "tauri", 12 + "check": "tsc --noEmit", 13 + "typecheck": "tsc --noEmit", 14 + "lint": "eslint . --ext .ts,.tsx --fix", 15 + "format": "dprint fmt ." 12 16 }, 13 17 "license": "MIT", 14 18 "dependencies": {
+9 -81
src/App.tsx
··· 5 5 import "@fontsource-variable/google-sans"; 6 6 import "./App.css"; 7 7 import { AccountLedger } from "./components/AccountLedger"; 8 - import { AccountSwitcher } from "./components/AccountSwitcher"; 8 + import { AppRail } from "./components/AppRail"; 9 9 import { FeedWorkspace } from "./components/feeds/FeedWorkspace"; 10 10 import { LoginPanel } from "./components/LoginPanel"; 11 11 import { HeaderPanel } from "./components/panels/Header"; 12 - import { RailButton } from "./components/RailButton"; 13 12 import { SessionSpotlight } from "./components/Session"; 14 13 import { ErrorToast } from "./components/shared/ErrorToast"; 15 - import { ArrowIcon } from "./components/shared/Icon"; 16 - import { Wordmark } from "./components/Wordmark"; 17 14 import type { AccountSummary, ActiveSession, AppBootstrap } from "./lib/types"; 18 15 import { AppRouter } from "./router"; 19 16 ··· 204 201 return ( 205 202 <> 206 203 <main 207 - class="grid min-h-screen grid-cols-(--app-rail-cols) transition-[grid-template-columns] duration-300 ease-out max-[1180px]:grid-cols-1" 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" 208 205 style={{ "--app-rail-cols": railColumns() }}> 209 206 <AppRail 210 207 activeSession={app.activeSession} ··· 220 217 onToggleSwitcher={() => setApp("showSwitcher", (open) => !open)} /> 221 218 222 219 <section 223 - class="m-5 grid gap-6 rounded-2xl bg-surface p-6 shadow-[0_24px_40px_rgba(125,175,255,0.05)] max-[1360px]:p-6 max-[1180px]:m-0 max-[1180px]:min-h-[calc(100vh-5.5rem)] max-[1180px]:rounded-none max-[1180px]:p-5 max-[760px]:gap-5 max-[760px]:p-4" 220 + class="m-5 grid min-h-0 overflow-hidden gap-6 rounded-2xl bg-surface p-6 shadow-[0_24px_40px_rgba(125,175,255,0.05)] max-[1360px]:p-6 max-[1180px]:m-0 max-[1180px]:min-h-[calc(100vh-5.5rem)] max-[1180px]:rounded-none max-[1180px]:p-5 max-[1180px]:overflow-visible max-[760px]:gap-5 max-[760px]:p-4" 224 221 aria-busy={app.bootstrapping}> 225 222 {props.children} 226 223 </section> ··· 257 254 onSwitch={(did) => void switchAccount(did)} /> 258 255 )} 259 256 renderShell={AppShell} 260 - renderTimeline={(session) => ( 261 - <FeedWorkspace activeSession={session} onError={(message) => setApp("errorMessage", message)} /> 257 + renderTimeline={(session, context) => ( 258 + <FeedWorkspace 259 + activeSession={session} 260 + onError={(message) => setApp("errorMessage", message)} 261 + onThreadRouteChange={context.onThreadRouteChange} 262 + threadUri={context.threadUri} /> 262 263 )} /> 263 - ); 264 - } 265 - 266 - function AppRail( 267 - props: { 268 - activeSession: ActiveSession | null; 269 - accounts: AccountSummary[]; 270 - collapsed: boolean; 271 - hasSession: boolean; 272 - logoutDid: string | null; 273 - openSwitcher: boolean; 274 - switchingDid: string | null; 275 - onLogout: (did: string) => void; 276 - onSwitch: (did: string) => void; 277 - onToggleCollapse: () => void; 278 - onToggleSwitcher: () => void; 279 - }, 280 - ) { 281 - return ( 282 - <aside 283 - 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" 284 - classList={{ "items-center px-4": props.collapsed, "gap-5": props.collapsed }} 285 - aria-label="Primary navigation"> 286 - <RailHeader collapsed={props.collapsed} onToggleCollapse={props.onToggleCollapse} /> 287 - <RailNavigation collapsed={props.collapsed} hasSession={props.hasSession} /> 288 - <AccountSwitcher 289 - activeSession={props.activeSession} 290 - accounts={props.accounts} 291 - busyDid={props.switchingDid} 292 - compact={props.collapsed} 293 - logoutDid={props.logoutDid} 294 - open={props.openSwitcher} 295 - onToggle={props.onToggleSwitcher} 296 - onSwitch={props.onSwitch} 297 - onLogout={props.onLogout} /> 298 - </aside> 299 - ); 300 - } 301 - 302 - function RailHeader(props: { collapsed: boolean; onToggleCollapse: () => void }) { 303 - return ( 304 - <div 305 - class="flex items-center justify-between gap-3 max-[1180px]:items-center" 306 - classList={{ "w-full flex-col gap-3": props.collapsed }}> 307 - <Wordmark compact={props.collapsed} iconClass="text-primary" /> 308 - <button 309 - class="inline-flex h-10 w-10 items-center justify-center rounded-full border-0 bg-white/4 text-on-surface-variant 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 hover:text-on-surface max-[1180px]:hidden" 310 - type="button" 311 - aria-label={props.collapsed ? "Expand app rail" : "Collapse app rail"} 312 - aria-pressed={props.collapsed} 313 - onClick={() => props.onToggleCollapse()}> 314 - <Show when={props.collapsed} fallback={<ArrowIcon direction="left" />}> 315 - <ArrowIcon direction="right" /> 316 - </Show> 317 - </button> 318 - </div> 319 - ); 320 - } 321 - 322 - function RailNavigation(props: { collapsed: boolean; hasSession: boolean }) { 323 - return ( 324 - <div class="grid gap-1 max-[1180px]:flex max-[1180px]:items-center"> 325 - <Show 326 - when={props.hasSession} 327 - fallback={<RailButton end compact={props.collapsed} href="/auth" label="Accounts" icon="profile" />}> 328 - <> 329 - <RailButton end compact={props.collapsed} href="/timeline" label="Timeline" icon="timeline" /> 330 - <RailButton end compact={props.collapsed} href="/search" label="Search" icon="search" /> 331 - <RailButton end compact={props.collapsed} href="/notifications" label="Notifications" icon="notifications" /> 332 - <RailButton end compact={props.collapsed} href="/explorer" label="Explorer" icon="explorer" /> 333 - </> 334 - </Show> 335 - </div> 336 264 ); 337 265 } 338 266
+1 -3
src/components/AccountLedger.tsx
··· 24 24 <Show 25 25 when={props.accounts.length > 0} 26 26 fallback={ 27 - <p class="overline-copy text-[0.72rem] text-on-surface-variant"> 28 - Your accounts will appear here once you sign in. 29 - </p> 27 + <p class="overline-copy text-xs text-on-surface-variant">Your accounts will appear here once you sign in.</p> 30 28 }> 31 29 <div class="grid gap-3" role="list"> 32 30 <For each={props.accounts}>
+1 -1
src/components/AccountSwitcher.tsx
··· 82 82 class="absolute flex items-center text-on-surface-variant" 83 83 classList={{ 84 84 "right-[0.95rem] top-1/2 -translate-y-1/2": !props.compact, 85 - "bottom-0 right-0 h-5 w-5 rounded-full bg-surface-container text-[0.7rem] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]": 85 + "bottom-0 right-0 h-5 w-5 rounded-full bg-surface-container text-xs shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]": 86 86 !!props.compact, 87 87 }} 88 88 aria-hidden="true">
+1 -3
src/components/AccountSwitcherMenuList.tsx
··· 14 14 return ( 15 15 <Show 16 16 when={props.accounts.length > 0} 17 - fallback={ 18 - <p class="overline-copy mt-[0.9rem] text-[0.72rem] text-on-surface-variant">No stored accounts yet.</p> 19 - }> 17 + fallback={<p class="overline-copy mt-[0.9rem] text-xs text-on-surface-variant">No stored accounts yet.</p>}> 20 18 <div class="mt-[0.9rem] grid gap-2"> 21 19 <For each={props.accounts}> 22 20 {(account) => (
+77
src/components/AppRail.tsx
··· 1 + import { AccountSummary, ActiveSession } from "$/lib/types"; 2 + import { Show } from "solid-js"; 3 + import { AccountSwitcher } from "./AccountSwitcher"; 4 + import { RailButton } from "./RailButton"; 5 + import { ArrowIcon } from "./shared/Icon"; 6 + import { Wordmark } from "./Wordmark"; 7 + 8 + function RailHeader(props: { collapsed: boolean; onToggleCollapse: () => void }) { 9 + return ( 10 + <div 11 + class="flex items-center justify-between gap-3 max-[1180px]:items-center" 12 + classList={{ "w-full flex-col gap-3": props.collapsed }}> 13 + <Wordmark compact={props.collapsed} iconClass="text-primary" /> 14 + <button 15 + class="inline-flex h-10 w-10 items-center justify-center rounded-full border-0 bg-white/4 text-on-surface-variant 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 hover:text-on-surface max-[1180px]:hidden" 16 + type="button" 17 + aria-label={props.collapsed ? "Expand app rail" : "Collapse app rail"} 18 + aria-pressed={props.collapsed} 19 + onClick={() => props.onToggleCollapse()}> 20 + <Show when={props.collapsed} fallback={<ArrowIcon direction="left" />}> 21 + <ArrowIcon direction="right" /> 22 + </Show> 23 + </button> 24 + </div> 25 + ); 26 + } 27 + 28 + function RailNavigation(props: { collapsed: boolean; hasSession: boolean }) { 29 + return ( 30 + <div class="grid gap-1 max-[1180px]:flex max-[1180px]:items-center"> 31 + <Show 32 + when={props.hasSession} 33 + fallback={<RailButton end compact={props.collapsed} href="/auth" label="Accounts" icon="profile" />}> 34 + <RailButton end compact={props.collapsed} href="/timeline" label="Timeline" icon="timeline" /> 35 + <RailButton end compact={props.collapsed} href="/search" label="Search" icon="search" /> 36 + <RailButton end compact={props.collapsed} href="/notifications" label="Notifications" icon="notifications" /> 37 + <RailButton end compact={props.collapsed} href="/explorer" label="Explorer" icon="explorer" /> 38 + </Show> 39 + </div> 40 + ); 41 + } 42 + 43 + export function AppRail( 44 + props: { 45 + activeSession: ActiveSession | null; 46 + accounts: AccountSummary[]; 47 + collapsed: boolean; 48 + hasSession: boolean; 49 + logoutDid: string | null; 50 + openSwitcher: boolean; 51 + switchingDid: string | null; 52 + onLogout: (did: string) => void; 53 + onSwitch: (did: string) => void; 54 + onToggleCollapse: () => void; 55 + onToggleSwitcher: () => void; 56 + }, 57 + ) { 58 + return ( 59 + <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 + classList={{ "items-center px-4": props.collapsed, "gap-5": props.collapsed }} 62 + aria-label="Primary navigation"> 63 + <RailHeader collapsed={props.collapsed} onToggleCollapse={props.onToggleCollapse} /> 64 + <RailNavigation collapsed={props.collapsed} hasSession={props.hasSession} /> 65 + <AccountSwitcher 66 + activeSession={props.activeSession} 67 + accounts={props.accounts} 68 + busyDid={props.switchingDid} 69 + compact={props.collapsed} 70 + logoutDid={props.logoutDid} 71 + open={props.openSwitcher} 72 + onToggle={props.onToggleSwitcher} 73 + onSwitch={props.onSwitch} 74 + onLogout={props.onLogout} /> 75 + </aside> 76 + ); 77 + }
+1 -1
src/components/AvatarBadge.tsx
··· 10 10 11 11 return ( 12 12 <span 13 - class="inline-flex h-10 w-10 items-center justify-center rounded-full text-[0.82rem] font-bold tracking-[0.08em]" 13 + class="inline-flex h-10 w-10 items-center justify-center 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",
+2 -2
src/components/RailButton.tsx
··· 9 9 <A 10 10 href={props.href} 11 11 end={props.end} 12 - class="flex h-[2.75rem] 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 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 }} ··· 17 17 title={props.label}> 18 18 <Icon kind={props.icon} name={props.label} aria-hidden="true" class="shrink-0 text-[1.25rem]" /> 19 19 <Show when={!props.compact}> 20 - <span class="text-[0.82rem] font-medium leading-none">{props.label}</span> 20 + <span class="text-sm font-medium leading-none">{props.label}</span> 21 21 </Show> 22 22 </A> 23 23 );
+1 -1
src/components/ReauthBanner.tsx
··· 11 11 exit={{ opacity: 0, y: 8 }} 12 12 transition={{ duration: 1.8, repeat: Number.POSITIVE_INFINITY, easing: "ease-in-out" }}> 13 13 <div class="grid gap-[0.2rem]"> 14 - <p class="m-0 text-[0.95rem] font-semibold">Your session expired.</p> 14 + <p class="m-0 text-base font-semibold">Your session expired.</p> 15 15 <p class="m-0 text-xs text-on-surface-variant">Sign in again to reconnect your account.</p> 16 16 </div> 17 17 <button class="pill-action border-0 bg-white/8 text-on-surface" type="button" onClick={() => props.onReauth()}>
+31
src/components/feeds/FeedChipAvatar.tsx
··· 1 + import { Icon } from "$/components/shared/Icon"; 2 + import { FeedGeneratorView, SavedFeedItem } from "$/lib/types"; 3 + import { createMemo, Show } from "solid-js"; 4 + 5 + export function FeedChipAvatar(props: { feed: SavedFeedItem; generator?: FeedGeneratorView }) { 6 + const icon = createMemo(() => { 7 + switch (props.feed.type) { 8 + case "list": { 9 + return "i-ri-list-check-2"; 10 + } 11 + case "timeline": { 12 + return "i-ri-home-5-line"; 13 + } 14 + default: { 15 + return "i-ri-rss-line"; 16 + } 17 + } 18 + }); 19 + 20 + return ( 21 + <Show 22 + when={props.generator?.avatar} 23 + fallback={ 24 + <div class="flex h-8 w-8 items-center justify-center rounded-full bg-white/6 text-primary"> 25 + <Icon aria-hidden="true" iconClass={icon()} /> 26 + </div> 27 + }> 28 + {(avatar) => <img class="h-8 w-8 rounded-full object-cover" src={avatar()} alt="" />} 29 + </Show> 30 + ); 31 + }
+36
src/components/feeds/FeedComposer.test.tsx
··· 1 + import { render, screen } from "@solidjs/testing-library"; 2 + import { describe, expect, it } from "vitest"; 3 + import { FeedComposer } from "./FeedComposer"; 4 + 5 + const suggestions = Array.from({ length: 13 }, (_, index) => ({ 6 + label: `@handle-${index + 1}.test`, 7 + type: "handle" as const, 8 + })); 9 + 10 + describe("FeedComposer", () => { 11 + it("renders a contained scroll region for typeahead suggestions", () => { 12 + render(() => ( 13 + <FeedComposer 14 + activeHandle="alice.test" 15 + open 16 + pending={false} 17 + quoteTarget={null} 18 + replyTarget={null} 19 + suggestions={suggestions} 20 + text="@ha" 21 + onApplySuggestion={() => {}} 22 + onClearQuote={() => {}} 23 + onClearReply={() => {}} 24 + onClose={() => {}} 25 + onSubmit={() => {}} 26 + onTextChange={() => {}} /> 27 + )); 28 + 29 + expect(screen.getByText("@handle-12.test")).toBeInTheDocument(); 30 + expect(screen.queryByText("@handle-13.test")).not.toBeInTheDocument(); 31 + 32 + const suggestionsHeading = screen.getByText("Suggestions"); 33 + const scrollRegion = suggestionsHeading.nextElementSibling as HTMLElement; 34 + expect(scrollRegion.className).toContain("overflow-y-auto"); 35 + }); 36 + });
+20 -16
src/components/feeds/FeedComposer.tsx
··· 50 50 return ( 51 51 <div class="relative z-10 flex min-h-screen items-end justify-center p-4 pt-16"> 52 52 <Motion.section 53 - class="w-full max-w-3xl overflow-hidden rounded-[1.8rem] bg-surface-container-high shadow-[0_25px_70px_rgba(0,0,0,0.7),0_0_0_1px_rgba(125,175,255,0.14)]" 53 + class="grid max-h-[calc(100vh-2rem)] w-full max-w-3xl grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden rounded-[1.8rem] bg-surface-container-high shadow-[0_25px_70px_rgba(0,0,0,0.7),0_0_0_1px_rgba(125,175,255,0.14)]" 54 54 initial={{ opacity: 0, y: 36 }} 55 55 animate={{ opacity: 1, y: 0 }} 56 56 exit={{ opacity: 0, y: 30 }} ··· 110 110 function ComposerTitle(props: { activeHandle: string | null }) { 111 111 return ( 112 112 <div> 113 - <p class="m-0 text-[0.95rem] font-semibold text-on-surface">New Post</p> 113 + <p class="m-0 text-base font-semibold text-on-surface">New Post</p> 114 114 <Show when={props.activeHandle}> 115 115 {(handle) => <p class="m-0 text-[0.76rem] text-on-surface-variant">@{handle().replace(/^@/, "")}</p>} 116 116 </Show> ··· 145 145 }, 146 146 ) { 147 147 return ( 148 - <div class="p-6"> 149 - <div class="flex gap-4"> 148 + <div class="min-h-0 overflow-y-auto overscroll-contain p-6"> 149 + <div class="flex gap-4 max-[640px]:flex-col"> 150 150 <ComposerAvatar activeHandle={props.activeHandle} /> 151 151 <div class="min-w-0 flex-1"> 152 152 <ComposerContexts ··· 204 204 function ComposerTextarea(props: { text: string; onTextChange: (value: string) => void }) { 205 205 return ( 206 206 <textarea 207 - class="min-h-40 w-full resize-none border-0 bg-transparent p-0 text-[1.08rem] leading-[1.65] text-on-surface placeholder:text-white/25 focus:outline-none" 207 + class="min-h-40 w-full resize-none border-0 bg-transparent p-0 text-[1.08rem] leading-[1.65] text-on-surface placeholder:text-white/25 focus:outline-none wrap-anywhere" 208 208 placeholder="What's happening?" 209 209 value={props.text} 210 210 onInput={(event) => props.onTextChange(event.currentTarget.value)} /> ··· 216 216 <Show when={props.post}> 217 217 {(post) => ( 218 218 <div class="mt-4 rounded-[1.25rem] bg-black/30 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]"> 219 - <p class="m-0 text-[0.72rem] uppercase tracking-[0.12em] text-on-surface-variant">Quote preview</p> 220 - <p class="mt-2 text-[0.84rem] font-semibold text-on-surface"> 219 + <p class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant">Quote preview</p> 220 + <p class="mt-2 text-sm font-semibold text-on-surface"> 221 221 {getDisplayName(post().author)} 222 222 <span class="ml-1 text-xs font-normal text-on-surface-variant"> 223 223 @{post().author.handle.replace(/^@/, "")} ··· 233 233 } 234 234 235 235 function SuggestionPanel(props: { suggestions: ComposerSuggestion[]; onApplySuggestion: (value: string) => void }) { 236 + const suggestions = () => props.suggestions.slice(0, 12); 236 237 return ( 237 238 <Show when={props.suggestions.length > 0}> 238 239 <div class="mt-4 rounded-[1.25rem] bg-black/35 p-3 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]"> 239 - <p class="m-0 text-[0.7rem] uppercase tracking-[0.12em] text-on-surface-variant">Suggestions</p> 240 - <div class="mt-3 flex flex-wrap gap-2"> 241 - <For each={props.suggestions.slice(0, 6)}> 242 - {(suggestion) => <SuggestionChip suggestion={suggestion} onApplySuggestion={props.onApplySuggestion} />} 243 - </For> 240 + <p class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant">Suggestions</p> 241 + <div class="mt-3 max-h-44 overflow-y-auto overscroll-contain pr-1"> 242 + <div class="grid gap-2"> 243 + <For each={suggestions()}> 244 + {(suggestion) => <SuggestionChip suggestion={suggestion} onApplySuggestion={props.onApplySuggestion} />} 245 + </For> 246 + </div> 244 247 </div> 245 248 </div> 246 249 </Show> ··· 248 251 } 249 252 250 253 function SuggestionChip(props: { suggestion: ComposerSuggestion; onApplySuggestion: (value: string) => void }) { 254 + const iconKind = () => (props.suggestion.type === "handle" ? "at" : "hashtag"); 251 255 return ( 252 256 <button 253 - class="inline-flex items-center gap-2 rounded-full border-0 bg-white/6 px-3 py-2 text-[0.8rem] text-on-surface transition duration-150 ease-out hover:-translate-y-px hover:bg-white/10" 257 + class="inline-flex w-full items-center gap-2 rounded-2xl border-0 bg-white/6 px-3 py-2 text-left text-[0.8rem] text-on-surface transition duration-150 ease-out hover:-translate-y-px hover:bg-white/10" 254 258 type="button" 255 259 onClick={() => props.onApplySuggestion(props.suggestion.label)}> 256 - <Icon aria-hidden="true" iconClass={props.suggestion.type === "handle" ? "i-ri-at-line" : "i-ri-hashtag"} /> 257 - <span>{props.suggestion.label}</span> 260 + <Icon aria-hidden="true" kind={iconKind()} /> 261 + <span class="min-w-0 break-all">{props.suggestion.label}</span> 258 262 </button> 259 263 ); 260 264 } ··· 313 317 class="inline-flex h-7 w-7 items-center justify-center rounded-full border-0 bg-transparent text-on-surface-variant transition duration-150 ease-out hover:bg-white/6 hover:text-on-surface" 314 318 type="button" 315 319 onClick={() => props.onClear()}> 316 - <Icon aria-hidden="true" iconClass="i-ri-close-line" /> 320 + <Icon aria-hidden="true" kind="close" /> 317 321 </button> 318 322 </div> 319 323 );
+63
src/components/feeds/FeedTabs.tsx
··· 1 + import { getFeedName } from "$/lib/feeds"; 2 + import type { FeedGeneratorView, SavedFeedItem } from "$/lib/types"; 3 + import { For } from "solid-js"; 4 + import { Icon } from "../shared/Icon"; 5 + import { FeedChipAvatar } from "./FeedChipAvatar"; 6 + 7 + export function FeedTabBar( 8 + props: { 9 + activeFeedId: string; 10 + generators: Record<string, FeedGeneratorView>; 11 + onFeedSelect: (feedId: string) => void; 12 + onToggleDrawer: () => void; 13 + pinnedFeeds: SavedFeedItem[]; 14 + }, 15 + ) { 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"> 19 + <For each={props.pinnedFeeds}> 20 + {(feed, index) => ( 21 + <FeedTab 22 + active={props.activeFeedId === feed.id} 23 + feed={feed} 24 + generator={props.generators[feed.value]} 25 + index={index() + 1} 26 + onSelect={props.onFeedSelect} /> 27 + )} 28 + </For> 29 + </div> 30 + <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" 32 + type="button" 33 + onClick={() => props.onToggleDrawer()}> 34 + <Icon aria-hidden="true" iconClass="i-ri-stack-line" /> 35 + <span>Saved feeds</span> 36 + </button> 37 + </div> 38 + ); 39 + } 40 + 41 + function FeedTab( 42 + props: { 43 + active: boolean; 44 + feed: SavedFeedItem; 45 + generator?: FeedGeneratorView; 46 + index: number; 47 + onSelect: (feedId: string) => void; 48 + }, 49 + ) { 50 + return ( 51 + <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" 53 + classList={{ 54 + "bg-[rgba(125,175,255,0.12)] text-primary shadow-[inset_0_0_0_1px_rgba(125,175,255,0.2)]": props.active, 55 + }} 56 + type="button" 57 + onClick={() => props.onSelect(props.feed.id)}> 58 + <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> 61 + </button> 62 + ); 63 + }
+126 -219
src/components/feeds/FeedWorkspace.tsx
··· 1 1 import { Icon } from "$/components/shared/Icon"; 2 2 import { 3 + applyFeedPreferences, 3 4 extractHandles, 4 5 extractHashtags, 6 + getFeedCommand, 5 7 getFeedName, 6 - isQuoteEmbed, 7 - isReplyItem, 8 - isRepostReason, 8 + getReplyRootPost, 9 9 patchFeedItems, 10 10 patchThreadNode, 11 11 toStrongRef, ··· 25 25 ThreadResponse, 26 26 UserPreferences, 27 27 } from "$/lib/types"; 28 + import { shouldIgnoreKey } from "$/lib/utils/events"; 29 + import { escapeForRegex } from "$/lib/utils/text"; 28 30 import { invoke } from "@tauri-apps/api/core"; 29 31 import { createEffect, createMemo, For, type JSX, onCleanup, onMount, Show } from "solid-js"; 30 32 import { createStore, reconcile } from "solid-js/store"; 31 33 import { Motion, Presence } from "solid-motionone"; 34 + import { FeedChipAvatar } from "./FeedChipAvatar"; 32 35 import { FeedComposer } from "./FeedComposer"; 36 + import { FeedTabBar } from "./FeedTabs"; 33 37 import { PostCard } from "./PostCard"; 34 38 import { ThreadPanel } from "./ThreadPanel"; 35 39 36 - type FeedWorkspaceProps = { activeSession: ActiveSession; onError: (message: string) => void }; 40 + type FeedWorkspaceProps = { 41 + activeSession: ActiveSession; 42 + onError: (message: string) => void; 43 + onThreadRouteChange: (uri: string | null) => void; 44 + threadUri: string | null; 45 + }; 37 46 38 47 type FeedState = { 39 48 cursor: string | null; ··· 100 109 repostPendingByUri: {}, 101 110 repostPulseUri: null, 102 111 showFeedsDrawer: false, 103 - thread: { data: null, error: null, loading: false, uri: null }, 112 + thread: createDefaultThreadState(), 104 113 }; 105 114 } 106 115 116 + function createDefaultThreadState() { 117 + return { data: null, error: null, loading: false, uri: null } satisfies FeedWorkspaceState["thread"]; 118 + } 119 + 107 120 export function FeedWorkspace(props: FeedWorkspaceProps) { 108 121 const [workspace, setWorkspace] = createStore<FeedWorkspaceState>(createInitialWorkspaceState()); 109 122 ··· 177 190 }); 178 191 179 192 createEffect(() => { 193 + const uri = props.threadUri; 194 + if (!uri) { 195 + if (workspace.thread.uri || workspace.thread.data || workspace.thread.error || workspace.thread.loading) { 196 + setWorkspace("thread", reconcile(createDefaultThreadState())); 197 + } 198 + return; 199 + } 200 + 201 + if (workspace.thread.uri === uri && (workspace.thread.data || workspace.thread.error || workspace.thread.loading)) { 202 + return; 203 + } 204 + 205 + void loadThread(uri); 206 + }); 207 + 208 + createEffect(() => { 180 209 const items = visibleItems(); 181 210 if (items.length === 0) { 182 211 setWorkspace("focusedIndex", 0); ··· 224 253 }); 225 254 226 255 function handleGlobalKeydown(event: KeyboardEvent) { 227 - if (shouldIgnoreKey(event)) { 256 + if (workspace.composer.open || shouldIgnoreKey(event)) { 228 257 return; 229 258 } 230 259 ··· 316 345 reconcile(Object.fromEntries(nextPreferences.feedViewPrefs.map((pref) => [pref.feed, pref]))), 317 346 ); 318 347 319 - const uris = nextPreferences.savedFeeds.filter((feed) => feed.type === "feed").map((feed) => feed.value); 348 + const uris = [ 349 + ...new Set(nextPreferences.savedFeeds.filter((feed) => feed.type === "feed").map((feed) => feed.value)), 350 + ]; 320 351 if (uris.length > 0) { 321 352 const hydrated = await invoke<{ feeds: FeedGeneratorView[] }>("get_feed_generators", { uris }); 322 353 setWorkspace( ··· 371 402 } 372 403 } 373 404 405 + async function loadThread(uri: string) { 406 + setWorkspace("thread", { data: null, error: null, loading: true, uri }); 407 + 408 + try { 409 + const payload = await invoke<ThreadResponse>("get_post_thread", { uri }); 410 + if (props.threadUri === uri) { 411 + setWorkspace("thread", { data: payload.thread, error: null, loading: false, uri }); 412 + } 413 + } catch (error) { 414 + if (props.threadUri === uri) { 415 + setWorkspace("thread", { data: null, error: String(error), loading: false, uri }); 416 + } 417 + props.onError(`Failed to open thread: ${String(error)}`); 418 + } 419 + } 420 + 374 421 function switchFeed(feedId: string) { 375 422 const current = activeFeed(); 376 423 if (current && scroller) { ··· 386 433 } 387 434 388 435 async function openThread(uri: string) { 389 - setWorkspace("thread", "uri", uri); 390 - setWorkspace("thread", "loading", true); 391 - setWorkspace("thread", "error", null); 436 + if (props.threadUri === uri) { 437 + await loadThread(uri); 438 + return; 439 + } 392 440 393 - try { 394 - const payload = await invoke<ThreadResponse>("get_post_thread", { uri }); 395 - setWorkspace("thread", "data", payload.thread); 396 - } catch (error) { 397 - setWorkspace("thread", "error", String(error)); 398 - props.onError(`Failed to open thread: ${String(error)}`); 399 - } finally { 400 - setWorkspace("thread", "loading", false); 401 - } 441 + props.onThreadRouteChange(uri); 402 442 } 403 443 404 444 function openComposer() { ··· 454 494 try { 455 495 await invoke<CreateRecordResult>("create_post", { embed, replyTo, text }); 456 496 resetComposer(); 457 - setWorkspace("thread", "uri", null); 458 - setWorkspace("thread", "data", null); 497 + props.onThreadRouteChange(null); 459 498 await loadFeed(activeFeed(), false); 460 499 if (scroller) { 461 500 scroller.scrollTop = 0; ··· 558 597 559 598 return ( 560 599 <> 561 - <div class="grid h-full gap-6 xl:grid-cols-[minmax(0,1fr)_20rem]"> 600 + <div class="grid h-full min-h-0 gap-6 xl:grid-cols-[minmax(0,1fr)_20rem]"> 562 601 <FeedPane 563 602 activeFeed={activeFeed()} 603 + activeFeedId={activeFeed().id} 564 604 activeFeedState={activeFeedState()} 565 605 activeHandle={props.activeSession.handle} 566 606 focusedIndex={workspace.focusedIndex} ··· 609 649 onSelectFeed={switchFeed} /> 610 650 611 651 <ThreadPanel 612 - activeUri={workspace.thread.uri} 652 + activeUri={props.threadUri} 613 653 error={workspace.thread.error} 614 654 loading={workspace.thread.loading} 615 - onClose={() => setWorkspace("thread", "uri", null)} 655 + onClose={() => props.onThreadRouteChange(null)} 616 656 onLike={(post) => void toggleLike(post)} 617 657 onOpenThread={(uri) => void openThread(uri)} 618 658 onQuote={(post) => openQuoteComposer(post)} ··· 649 689 function FeedPane( 650 690 props: { 651 691 activeFeed: SavedFeedItem; 692 + activeFeedId: string; 652 693 activeFeedState: FeedState | undefined; 653 694 activeHandle: string; 654 695 focusedIndex: number; ··· 675 716 }, 676 717 ) { 677 718 return ( 678 - <section class="min-h-0 rounded-4xl bg-[rgba(8,8,8,0.32)] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)]"> 719 + <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)]"> 679 720 <FeedPaneHeader 680 721 activeFeed={props.activeFeed} 681 722 generators={props.generators} ··· 684 725 onToggleDrawer={props.onToggleDrawer} 685 726 pinnedFeeds={props.pinnedFeeds} /> 686 727 <FeedScroller 728 + activeFeedId={props.activeFeedId} 687 729 activeFeedState={props.activeFeedState} 688 730 activeHandle={props.activeHandle} 689 731 focusedIndex={props.focusedIndex} ··· 744 786 }, 745 787 ) { 746 788 return ( 747 - <div class="flex items-start justify-between gap-4"> 748 - <div> 749 - <p class="m-0 text-[1.35rem] font-semibold tracking-[-0.03em] text-on-surface">Timeline</p> 750 - <p class="mt-1 text-xs uppercase tracking-[0.12em] text-on-surface-variant"> 789 + <div class="flex items-start justify-between gap-4 max-[640px]:flex-col max-[640px]:items-stretch"> 790 + <div class="min-w-0"> 791 + <p class="m-0 text-xl font-semibold tracking-tight text-on-surface">Timeline</p> 792 + <p class="mt-1 wrap-break-word text-xs uppercase tracking-[0.12em] text-on-surface-variant"> 751 793 {getFeedName(props.activeFeed, props.generators[props.activeFeed.value]?.displayName)} 752 794 </p> 753 795 </div> ··· 758 800 759 801 function FeedHeaderActions(props: { onCompose: () => void; onToggleDrawer: () => void }) { 760 802 return ( 761 - <div class="flex items-center gap-2"> 803 + <div class="flex shrink-0 items-center gap-2 max-[640px]:justify-between"> 762 804 <button 763 - class="inline-flex h-11 items-center gap-2 rounded-full border-0 bg-white/5 px-4 text-[0.82rem] text-on-surface transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8" 805 + 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" 764 806 type="button" 765 807 onClick={() => props.onCompose()}> 766 - <Icon aria-hidden="true" iconClass="i-ri-quill-pen-line" /> 808 + <Icon aria-hidden="true" kind="quill" /> 767 809 <span>New post</span> 768 810 </button> 769 811 <button 770 812 class="inline-flex h-11 w-11 items-center justify-center rounded-full border-0 bg-white/5 text-on-surface transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8" 771 813 type="button" 772 814 onClick={() => props.onToggleDrawer()}> 773 - <Icon aria-hidden="true" iconClass="i-ri-menu-line" /> 774 - </button> 775 - </div> 776 - ); 777 - } 778 - 779 - function FeedTabBar( 780 - props: { 781 - activeFeedId: string; 782 - generators: Record<string, FeedGeneratorView>; 783 - onFeedSelect: (feedId: string) => void; 784 - onToggleDrawer: () => void; 785 - pinnedFeeds: SavedFeedItem[]; 786 - }, 787 - ) { 788 - return ( 789 - <div class="mt-4 flex items-end justify-between gap-3 max-[720px]:flex-col max-[720px]:items-stretch"> 790 - <div class="flex min-w-0 gap-1 overflow-x-auto pb-1"> 791 - <For each={props.pinnedFeeds}> 792 - {(feed, index) => ( 793 - <FeedTab 794 - active={props.activeFeedId === feed.id} 795 - feed={feed} 796 - generator={props.generators[feed.value]} 797 - index={index() + 1} 798 - onSelect={props.onFeedSelect} /> 799 - )} 800 - </For> 801 - </div> 802 - <button 803 - class="inline-flex h-11 items-center gap-2 rounded-full border-0 bg-white/5 px-4 text-[0.82rem] text-on-surface transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8" 804 - type="button" 805 - onClick={() => props.onToggleDrawer()}> 806 - <Icon aria-hidden="true" iconClass="i-ri-stack-line" /> 807 - <span>Saved feeds</span> 815 + <Icon aria-hidden="true" kind="menu" /> 808 816 </button> 809 817 </div> 810 818 ); 811 819 } 812 820 813 - function FeedTab( 814 - props: { 815 - active: boolean; 816 - feed: SavedFeedItem; 817 - generator?: FeedGeneratorView; 818 - index: number; 819 - onSelect: (feedId: string) => void; 820 - }, 821 - ) { 822 - return ( 823 - <button 824 - class="relative inline-flex min-h-12 shrink-0 items-center gap-2 rounded-full border-0 px-4 text-[0.84rem] font-medium text-on-surface-variant transition duration-150 ease-out hover:bg-white/5 hover:text-on-surface" 825 - classList={{ 826 - "bg-[rgba(125,175,255,0.12)] text-primary shadow-[inset_0_0_0_1px_rgba(125,175,255,0.2)]": props.active, 827 - }} 828 - type="button" 829 - onClick={() => props.onSelect(props.feed.id)}> 830 - <FeedChipAvatar feed={props.feed} generator={props.generator} /> 831 - <span class="truncate">{getFeedName(props.feed, props.generator?.displayName)}</span> 832 - <span class="rounded-full bg-black/25 px-1.5 py-0.5 text-[0.65rem] text-on-surface-variant">{props.index}</span> 833 - </button> 834 - ); 835 - } 836 - 837 821 function FeedScroller( 838 822 props: { 823 + activeFeedId: string; 839 824 activeFeedState: FeedState | undefined; 840 825 activeHandle: string; 841 826 focusedIndex: number; ··· 861 846 return ( 862 847 <div 863 848 ref={(element) => props.scrollerRef(element)} 864 - class="feed-scroll-region h-[calc(100vh-14rem)] overflow-y-auto px-6 pb-8 pt-4 max-[1180px]:h-[calc(100vh-12.5rem)]" 849 + class="feed-scroll-region min-h-0 overflow-y-auto overscroll-contain px-6 pb-8 pt-4" 865 850 onScroll={(event) => props.setScrollTop(event.currentTarget.scrollTop)}> 866 851 <ComposerLauncher activeHandle={props.activeHandle} onCompose={props.onCompose} /> 867 852 <FeedContent 853 + activeFeedId={props.activeFeedId} 868 854 activeFeedState={props.activeFeedState} 869 855 focusedIndex={props.focusedIndex} 870 856 likePendingByUri={props.likePendingByUri} ··· 894 880 {props.activeHandle.slice(0, 1).toUpperCase()} 895 881 </div> 896 882 <div class="min-w-0 flex-1"> 897 - <p class="m-0 text-[0.9rem] text-on-surface-variant">What's happening?</p> 883 + <p class="m-0 wrap-break-word text-[0.9rem] text-on-surface-variant">What's happening?</p> 898 884 </div> 899 885 <div class="flex items-center gap-1 text-on-surface-variant"> 900 886 <Icon aria-hidden="true" iconClass="i-ri-at-line" /> ··· 907 893 908 894 function FeedContent( 909 895 props: { 896 + activeFeedId: string; 910 897 activeFeedState: FeedState | undefined; 911 898 focusedIndex: number; 912 899 likePendingByUri: Record<string, boolean>; ··· 926 913 ) { 927 914 return ( 928 915 <Presence exitBeforeEnter> 929 - <Motion.div 930 - class="grid gap-3" 931 - initial={{ opacity: 0 }} 932 - animate={{ opacity: 1 }} 933 - exit={{ opacity: 0 }} 934 - transition={{ duration: 0.2 }}> 935 - <FeedStatus activeFeedState={props.activeFeedState} visibleItems={props.visibleItems} /> 936 - <For each={props.visibleItems}> 937 - {(item, index) => ( 938 - <PostCard 939 - focused={props.focusedIndex === index()} 940 - item={item} 941 - likePending={!!props.likePendingByUri[item.post.uri]} 942 - onFocus={() => props.onFocusIndex(index())} 943 - onLike={() => void props.onLike(item.post)} 944 - onOpenThread={() => void props.onOpenThread(item.post.uri)} 945 - onQuote={() => props.onQuote(item.post)} 946 - onReply={() => props.onReply(item.post, getReplyRootPost(item))} 947 - onRepost={() => void props.onRepost(item.post)} 948 - post={item.post} 949 - pulseLike={props.likePulseUri === item.post.uri} 950 - pulseRepost={props.repostPulseUri === item.post.uri} 951 - registerRef={(element) => props.postRefs.set(item.post.uri, element)} 952 - repostPending={!!props.repostPendingByUri[item.post.uri]} /> 953 - )} 954 - </For> 955 - <div ref={(element) => props.sentinelRef(element)} /> 956 - <LoadingMoreIndicator loading={!!props.activeFeedState?.loadingMore} /> 957 - </Motion.div> 916 + <For each={[props.activeFeedId]}> 917 + {() => ( 918 + <Motion.div 919 + class="grid gap-3" 920 + initial={{ opacity: 0 }} 921 + animate={{ opacity: 1 }} 922 + exit={{ opacity: 0 }} 923 + transition={{ duration: 0.2 }}> 924 + <FeedStatus activeFeedState={props.activeFeedState} visibleItems={props.visibleItems} /> 925 + <For each={props.visibleItems}> 926 + {(item, index) => ( 927 + <PostCard 928 + focused={props.focusedIndex === index()} 929 + item={item} 930 + likePending={!!props.likePendingByUri[item.post.uri]} 931 + onFocus={() => props.onFocusIndex(index())} 932 + onLike={() => void props.onLike(item.post)} 933 + onOpenThread={() => void props.onOpenThread(item.post.uri)} 934 + onQuote={() => props.onQuote(item.post)} 935 + onReply={() => props.onReply(item.post, getReplyRootPost(item))} 936 + onRepost={() => void props.onRepost(item.post)} 937 + post={item.post} 938 + pulseLike={props.likePulseUri === item.post.uri} 939 + pulseRepost={props.repostPulseUri === item.post.uri} 940 + registerRef={(element) => props.postRefs.set(item.post.uri, element)} 941 + repostPending={!!props.repostPendingByUri[item.post.uri]} /> 942 + )} 943 + </For> 944 + <div ref={(element) => props.sentinelRef(element)} /> 945 + <LoadingMoreIndicator loading={!!props.activeFeedState?.loadingMore} /> 946 + </Motion.div> 947 + )} 948 + </For> 958 949 </Presence> 959 950 ); 960 951 } 961 952 962 953 function FeedStatus(props: { activeFeedState: FeedState | undefined; visibleItems: FeedViewPost[] }) { 954 + const loading = () => !props.activeFeedState || props.activeFeedState.loading; 955 + 963 956 return ( 964 957 <> 965 - <Show when={props.activeFeedState?.loading}> 958 + <Show when={loading()}> 966 959 <FeedSkeleton /> 967 960 </Show> 968 961 <Show when={props.activeFeedState?.error}> ··· 972 965 </div> 973 966 )} 974 967 </Show> 975 - <Show when={!props.activeFeedState?.loading && !props.activeFeedState?.error && props.visibleItems.length === 0}> 968 + <Show when={!loading() && !props.activeFeedState?.error && props.visibleItems.length === 0}> 976 969 <EmptyFeedState /> 977 970 </Show> 978 971 </> ··· 982 975 function LoadingMoreIndicator(props: { loading: boolean }) { 983 976 return ( 984 977 <Show when={props.loading}> 985 - <div class="flex items-center justify-center py-4 text-[0.82rem] text-on-surface-variant"> 978 + <div class="flex items-center justify-center py-4 text-sm text-on-surface-variant"> 986 979 <Icon aria-hidden="true" class="animate-spin" iconClass="i-ri-loader-4-line" /> 987 980 <span class="ml-2">Loading more</span> 988 981 </div> ··· 1003 996 }, 1004 997 ) { 1005 998 return ( 1006 - <aside class="grid gap-4 xl:h-[calc(100vh-10rem)] xl:overflow-y-auto"> 999 + <aside class="grid min-h-0 gap-4 overflow-hidden xl:overflow-y-auto xl:overscroll-contain"> 1007 1000 <SavedFeedsCard drawerFeeds={props.drawerFeeds} generators={props.generators} onFeedSelect={props.onFeedSelect} /> 1008 1001 <DisplayFiltersCard activePref={props.activePref} onPrefChange={props.onPrefChange} /> 1009 1002 <ShortcutsCard /> ··· 1046 1039 onClick={() => props.onSelect(props.feed.id)}> 1047 1040 <FeedChipAvatar feed={props.feed} generator={props.generator} /> 1048 1041 <div class="min-w-0 flex-1"> 1049 - <p class="m-0 truncate text-[0.84rem] font-medium">{getFeedName(props.feed, props.generator?.displayName)}</p> 1050 - <p class="m-0 text-[0.72rem] uppercase tracking-[0.08em] text-on-surface-variant">{props.feed.type}</p> 1042 + <p class="m-0 truncate text-sm font-medium">{getFeedName(props.feed, props.generator?.displayName)}</p> 1043 + <p class="m-0 text-xs uppercase tracking-[0.08em] text-on-surface-variant">{props.feed.type}</p> 1051 1044 </div> 1052 1045 </button> 1053 1046 ); ··· 1131 1124 <Presence> 1132 1125 <Show when={props.open}> 1133 1126 <Motion.aside 1134 - class="fixed inset-y-0 right-0 z-30 w-full max-w-104 overflow-y-auto border-l border-white/5 bg-[rgba(12,12,12,0.95)] px-5 pb-6 pt-5 backdrop-blur-[22px] shadow-[-28px_0_50px_rgba(0,0,0,0.35)]" 1127 + class="fixed inset-y-0 right-0 z-30 w-full max-w-104 overflow-y-auto overscroll-contain border-l border-white/5 bg-[rgba(12,12,12,0.95)] px-5 pb-6 pt-5 backdrop-blur-[22px] shadow-[-28px_0_50px_rgba(0,0,0,0.35)]" 1135 1128 initial={{ opacity: 0, x: 20 }} 1136 1129 animate={{ opacity: 1, x: 0 }} 1137 1130 exit={{ opacity: 0, x: 24 }} ··· 1181 1174 <FeedChipAvatar feed={props.feed} generator={props.generator} /> 1182 1175 <div class="min-w-0 flex-1"> 1183 1176 <p class="m-0 truncate text-[0.88rem] font-semibold">{getFeedName(props.feed, props.generator?.displayName)}</p> 1184 - <p class="m-0 truncate text-[0.74rem] text-on-surface-variant">{props.feed.value}</p> 1177 + <p class="m-0 break-all text-[0.74rem] text-on-surface-variant">{props.feed.value}</p> 1185 1178 </div> 1186 1179 </button> 1187 1180 ); 1188 1181 } 1189 1182 1190 - function FeedChipAvatar(props: { feed: SavedFeedItem; generator?: FeedGeneratorView }) { 1191 - const icon = createMemo(() => { 1192 - switch (props.feed.type) { 1193 - case "list": { 1194 - return "i-ri-list-check-2"; 1195 - } 1196 - case "timeline": { 1197 - return "i-ri-home-5-line"; 1198 - } 1199 - default: { 1200 - return "i-ri-rss-line"; 1201 - } 1202 - } 1203 - }); 1204 - 1205 - return ( 1206 - <Show 1207 - when={props.generator?.avatar} 1208 - fallback={ 1209 - <div class="flex h-8 w-8 items-center justify-center rounded-full bg-white/6 text-primary"> 1210 - <Icon aria-hidden="true" iconClass={icon()} /> 1211 - </div> 1212 - }> 1213 - {(avatar) => <img class="h-8 w-8 rounded-full object-cover" src={avatar()} alt="" />} 1214 - </Show> 1215 - ); 1216 - } 1217 - 1218 1183 function SidebarCard(props: { children: JSX.Element; subtitle: string; title: string }) { 1219 1184 return ( 1220 1185 <section class="rounded-[1.6rem] bg-white/3 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 1221 - <p class="m-0 text-[0.95rem] font-semibold text-on-surface">{props.title}</p> 1222 - <p class="mt-1 text-[0.72rem] uppercase tracking-[0.12em] text-on-surface-variant">{props.subtitle}</p> 1186 + <p class="m-0 text-base font-semibold text-on-surface">{props.title}</p> 1187 + <p class="mt-1 text-xs uppercase tracking-[0.12em] text-on-surface-variant">{props.subtitle}</p> 1223 1188 <div class="mt-4">{props.children}</div> 1224 1189 </section> 1225 1190 ); ··· 1227 1192 1228 1193 function ToggleRow(props: { checked: boolean; label: string; onChange: (checked: boolean) => void }) { 1229 1194 return ( 1230 - <label class="flex items-center justify-between gap-3 rounded-2xl bg-white/4 px-3 py-3 text-[0.84rem] text-on-surface"> 1195 + <label class="flex items-center justify-between gap-3 rounded-2xl bg-white/4 px-3 py-3 text-sm text-on-surface"> 1231 1196 <span>{props.label}</span> 1232 1197 <input checked={props.checked} type="checkbox" onInput={(event) => props.onChange(event.currentTarget.checked)} /> 1233 1198 </label> ··· 1283 1248 </div> 1284 1249 ); 1285 1250 } 1286 - 1287 - function getFeedCommand(feed: SavedFeedItem) { 1288 - if (feed.type === "timeline") { 1289 - return { args: (cursor: string | null, limit: number) => ({ cursor, limit }), name: "get_timeline" }; 1290 - } 1291 - 1292 - if (feed.type === "list") { 1293 - return { 1294 - args: (cursor: string | null, limit: number) => ({ cursor, limit, uri: feed.value }), 1295 - name: "get_list_feed", 1296 - }; 1297 - } 1298 - 1299 - return { args: (cursor: string | null, limit: number) => ({ cursor, limit, uri: feed.value }), name: "get_feed" }; 1300 - } 1301 - 1302 - function applyFeedPreferences(items: FeedViewPost[], pref: UserPreferences["feedViewPrefs"][number]) { 1303 - return items.filter((item) => { 1304 - if (pref.hideReposts && isRepostReason(item)) { 1305 - return false; 1306 - } 1307 - 1308 - if (pref.hideReplies && isReplyItem(item)) { 1309 - return false; 1310 - } 1311 - 1312 - if (pref.hideQuotePosts && isQuoteEmbed(item.post.embed)) { 1313 - return false; 1314 - } 1315 - 1316 - if (pref.hideRepliesByLikeCount && isReplyItem(item) && (item.post.likeCount ?? 0) < pref.hideRepliesByLikeCount) { 1317 - return false; 1318 - } 1319 - 1320 - return true; 1321 - }); 1322 - } 1323 - 1324 - function getReplyRootPost(item: FeedViewPost) { 1325 - if (item.reply?.root.$type === "app.bsky.feed.defs#postView") { 1326 - return item.reply.root; 1327 - } 1328 - 1329 - return item.post; 1330 - } 1331 - 1332 - function shouldIgnoreKey(event: KeyboardEvent) { 1333 - const element = event.target; 1334 - if (!(element instanceof HTMLElement)) { 1335 - return false; 1336 - } 1337 - 1338 - return element.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(element.tagName); 1339 - } 1340 - 1341 - function escapeForRegex(value: string) { 1342 - return value.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); 1343 - }
+39
src/components/feeds/PostCard.test.tsx
··· 1 + import { fireEvent, render, screen } from "@solidjs/testing-library"; 2 + import { describe, expect, it, vi } from "vitest"; 3 + import { PostCard } from "./PostCard"; 4 + 5 + function createPost() { 6 + return { 7 + author: { did: "did:plc:alice", handle: "alice.test", displayName: "Alice" }, 8 + cid: "cid-post", 9 + indexedAt: "2026-03-28T12:00:00.000Z", 10 + likeCount: 4, 11 + record: { 12 + createdAt: "2026-03-28T12:00:00.000Z", 13 + text: "Visit https://example.com @bob.test #solid", 14 + }, 15 + replyCount: 2, 16 + repostCount: 1, 17 + uri: "at://did:plc:alice/app.bsky.feed.post/123", 18 + viewer: {}, 19 + } as const; 20 + } 21 + 22 + describe("PostCard", () => { 23 + it("linkifies urls and keeps mentions and hashtags visible", () => { 24 + render(() => <PostCard post={createPost()} />); 25 + 26 + expect(screen.getByRole("link", { name: "https://example.com" })).toHaveAttribute("href", "https://example.com"); 27 + expect(screen.getByText("@bob.test")).toBeInTheDocument(); 28 + expect(screen.getByText("#solid")).toBeInTheDocument(); 29 + }); 30 + 31 + it("opens the thread when Enter is pressed on the card", async () => { 32 + const onOpenThread = vi.fn(); 33 + render(() => <PostCard post={createPost()} onOpenThread={onOpenThread} />); 34 + 35 + await fireEvent.keyDown(screen.getByRole("article"), { key: "Enter" }); 36 + 37 + expect(onOpenThread).toHaveBeenCalledTimes(1); 38 + }); 39 + });
+36 -31
src/components/feeds/PostCard.tsx
··· 65 65 } 66 66 }}> 67 67 <Show when={reasonLabel()}> 68 - <div class="mb-3 flex items-center gap-2 text-[0.72rem] font-medium tracking-[0.04em] text-primary"> 68 + <div class="mb-3 flex items-center gap-2 text-xs font-medium tracking-[0.04em] text-primary"> 69 69 <Icon aria-hidden="true" iconClass="i-ri-repeat-2-line" /> 70 70 <span>{reasonLabel()}</span> 71 71 </div> ··· 76 76 77 77 <div class="min-w-0 flex-1"> 78 78 <header class="mb-2 flex flex-wrap items-center gap-x-2 gap-y-1"> 79 - <span class="text-[0.95rem] font-semibold tracking-[-0.01em] text-on-surface">{authorName()}</span> 80 - <span class="text-xs text-on-surface-variant">@{props.post.author.handle.replace(/^@/, "")}</span> 79 + <span class="wrap-break-word text-base font-semibold tracking-[-0.01em] text-on-surface"> 80 + {authorName()} 81 + </span> 82 + <span class="break-all text-xs text-on-surface-variant">@{props.post.author.handle.replace(/^@/, "")}</span> 81 83 <span class="text-xs text-on-surface-variant">{createdAt()}</span> 82 84 </header> 83 85 84 86 <Show when={getPostText(props.post)}> 85 87 {(text) => ( 86 - <p class="m-0 whitespace-pre-wrap text-[0.94rem] leading-[1.65] text-on-secondary-container"> 87 - {linkifyText(text())} 88 + <p class="m-0 whitespace-pre-wrap wrap-break-word text-base leading-[1.65] text-on-secondary-container"> 89 + <LinkifiedText text={text()} /> 88 90 </p> 89 91 )} 90 92 </Show> ··· 147 149 ) { 148 150 return ( 149 151 <button 152 + aria-label={props.label} 150 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" 151 154 classList={{ "text-primary": !!props.active }} 152 155 type="button" ··· 158 161 transition={{ duration: 0.28 }}> 159 162 <Icon aria-hidden="true" iconClass={props.active ? props.iconActive ?? props.icon : props.icon} /> 160 163 </Motion.span> 161 - <span>{props.busy ? "..." : props.label}</span> 164 + <span class="max-w-24 truncate">{props.busy ? "..." : props.label}</span> 162 165 </button> 163 166 ); 164 167 } ··· 225 228 {(thumb) => <img class="max-h-64 w-full rounded-2xl object-cover" src={thumb()} alt="" />} 226 229 </Show> 227 230 <div class="grid gap-1"> 228 - <p class="m-0 text-sm font-semibold text-on-surface">{props.title || "External link"}</p> 231 + <p class="m-0 wrap-break-word text-sm font-semibold text-on-surface">{props.title || "External link"}</p> 229 232 <Show when={props.description}> 230 - {(description) => <p class="m-0 text-[0.82rem] leading-[1.55] text-on-surface-variant">{description()}</p>} 233 + {(description) => ( 234 + <p class="m-0 wrap-break-word text-sm leading-[1.55] text-on-surface-variant">{description()}</p> 235 + )} 231 236 </Show> 232 237 <Show when={props.uri}> 233 238 {(uri) => ( 234 - <p class="m-0 text-[0.74rem] uppercase tracking-[0.08em] text-primary"> 239 + <p class="m-0 break-all text-[0.74rem] uppercase tracking-[0.08em] text-primary"> 235 240 {uri().replace(/^https?:\/\//, "")} 236 241 </p> 237 242 )} ··· 246 251 247 252 return ( 248 253 <div class="rounded-[1.25rem] bg-black/30 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]"> 249 - <p class="m-0 text-[0.72rem] uppercase tracking-[0.12em] text-on-surface-variant">{props.title}</p> 254 + <p class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant">{props.title}</p> 250 255 <Show when={props.author}> 251 256 {(author) => ( 252 - <p class="mt-2 text-[0.84rem] font-semibold text-on-surface"> 257 + <p class="mt-2 wrap-break-word text-sm font-semibold text-on-surface"> 253 258 {getDisplayName(author())} 254 - <span class="ml-1 text-xs font-normal text-on-surface-variant">@{author().handle.replace(/^@/, "")}</span> 259 + <span class="ml-1 break-all text-xs font-normal text-on-surface-variant"> 260 + @{author().handle.replace(/^@/, "")} 261 + </span> 255 262 </p> 256 263 )} 257 264 </Show> ··· 262 269 ); 263 270 } 264 271 265 - function linkifyText(text: string) { 266 - const parts = text.split(/(https?:\/\/\S+|@[a-z0-9._-]+(?:\.[a-z0-9._-]+)+|#[\p{L}\p{N}_-]+)/giu); 272 + function LinkifiedText(props: { text: string }) { 273 + const parts = () => props.text.split(/(https?:\/\/\S+|@[a-z0-9._-]+(?:\.[a-z0-9._-]+)+|#[\p{L}\p{N}_-]+)/giu); 267 274 268 275 return ( 269 - <> 270 - <For each={parts}> 271 - {(part) => { 272 - if (/^https?:\/\//i.test(part)) { 273 - return ( 274 - <a class="text-primary no-underline hover:underline" href={part} rel="noreferrer" target="_blank"> 275 - {part} 276 - </a> 277 - ); 278 - } 276 + <For each={parts()}> 277 + {(part) => { 278 + if (/^https?:\/\//i.test(part)) { 279 + return ( 280 + <a class="break-all text-primary no-underline hover:underline" href={part} rel="noreferrer" target="_blank"> 281 + {part} 282 + </a> 283 + ); 284 + } 279 285 280 - if (/^[@#]/.test(part)) { 281 - return <span class="text-primary">{part}</span>; 282 - } 286 + if (/^[@#]/.test(part)) { 287 + return <span class="break-all text-primary">{part}</span>; 288 + } 283 289 284 - return <span>{part}</span>; 285 - }} 286 - </For> 287 - </> 290 + return <span class="wrap-anywhere">{part}</span>; 291 + }} 292 + </For> 288 293 ); 289 294 }
+29 -27
src/components/feeds/ThreadPanel.tsx
··· 25 25 <Presence> 26 26 <Show when={props.activeUri}> 27 27 <Motion.aside 28 - class="fixed inset-y-0 right-0 z-40 w-full max-w-136 overflow-y-auto border-l border-white/5 bg-[rgba(12,12,12,0.92)] px-5 pb-6 pt-5 backdrop-blur-[22px] shadow-[-28px_0_50px_rgba(0,0,0,0.35)]" 28 + class="fixed inset-y-0 right-0 z-40 grid w-full max-w-136 grid-rows-[auto_minmax(0,1fr)] overflow-hidden bg-[rgba(12,12,12,0.92)] px-5 pb-6 pt-5 backdrop-blur-[22px] shadow-[-28px_0_50px_rgba(0,0,0,0.35)]" 29 29 initial={{ opacity: 0, x: 30 }} 30 30 animate={{ opacity: 1, x: 0 }} 31 31 exit={{ opacity: 0, x: 36 }} 32 32 transition={{ duration: 0.22 }}> 33 33 <ThreadPanelHeader onClose={props.onClose} /> 34 - <ThreadPanelLoading loading={props.loading} /> 34 + <div class="min-h-0 overflow-y-auto overscroll-contain pb-1"> 35 + <ThreadPanelLoading loading={props.loading} /> 35 36 36 - <Show when={!props.loading && props.error}> 37 - {(message) => ( 38 - <div class="rounded-3xl bg-[rgba(138,31,31,0.2)] p-4 text-sm text-error shadow-[inset_0_0_0_1px_rgba(255,128,128,0.2)]"> 39 - {message()} 40 - </div> 41 - )} 42 - </Show> 37 + <Show when={!props.loading && props.error}> 38 + {(message) => ( 39 + <div class="rounded-3xl bg-[rgba(138,31,31,0.2)] p-4 text-sm text-error shadow-[inset_0_0_0_1px_rgba(255,128,128,0.2)]"> 40 + {message()} 41 + </div> 42 + )} 43 + </Show> 43 44 44 - <Show when={!props.loading && props.thread && !props.error && rootPost()}> 45 - {(root) => ( 46 - <div class="grid gap-4"> 47 - <ThreadNodeView 48 - activeUri={props.activeUri} 49 - node={props.thread!} 50 - rootPost={root()} 51 - onLike={props.onLike} 52 - onOpenThread={props.onOpenThread} 53 - onQuote={props.onQuote} 54 - onReply={props.onReply} 55 - onRepost={props.onRepost} /> 56 - </div> 57 - )} 58 - </Show> 45 + <Show when={!props.loading && props.thread && !props.error && rootPost()}> 46 + {(root) => ( 47 + <div class="grid gap-4"> 48 + <ThreadNodeView 49 + activeUri={props.activeUri} 50 + node={props.thread!} 51 + rootPost={root()} 52 + onLike={props.onLike} 53 + onOpenThread={props.onOpenThread} 54 + onQuote={props.onQuote} 55 + onReply={props.onReply} 56 + onRepost={props.onRepost} /> 57 + </div> 58 + )} 59 + </Show> 60 + </div> 59 61 </Motion.aside> 60 62 </Show> 61 63 </Presence> ··· 66 68 return ( 67 69 <header class="sticky top-0 z-10 mb-4 flex items-center justify-between rounded-3xl bg-[rgba(14,14,14,0.9)] px-4 py-3 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 68 70 <div> 69 - <p class="m-0 text-[0.95rem] font-semibold text-on-surface">Thread</p> 71 + <p class="m-0 text-base font-semibold text-on-surface">Thread</p> 70 72 <p class="m-0 text-[0.74rem] uppercase tracking-[0.12em] text-on-surface-variant">Nested replies</p> 71 73 </div> 72 74 <button ··· 117 119 <div class="grid gap-4"> 118 120 <Show when={threadNode().parent}> 119 121 {(parent) => ( 120 - <div class="border-l border-white/6 pl-4"> 122 + <div class="rounded-[1.35rem] bg-white/[0.03] p-3"> 121 123 <ThreadNodeView 122 124 activeUri={props.activeUri} 123 125 node={parent()} ··· 141 143 onRepost={() => props.onRepost(threadNode().post)} /> 142 144 143 145 <Show when={threadNode().replies?.length}> 144 - <div class="grid gap-4 border-l border-white/6 pl-4"> 146 + <div class="grid gap-4 rounded-[1.35rem] bg-white/[0.03] p-3"> 145 147 <For each={threadNode().replies}> 146 148 {(reply) => ( 147 149 <ThreadNodeView
+2 -2
src/components/panels/Header.tsx
··· 4 4 return ( 5 5 <header class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_auto] xl:items-start"> 6 6 <div class="max-w-3xl"> 7 - <p class="overline-copy text-[0.72rem] text-primary">Welcome</p> 7 + <p class="overline-copy text-xs text-primary">Welcome</p> 8 8 <h1 class="m-0 max-w-[11ch] text-balance text-[clamp(2.3rem,5vw,4.2rem)] leading-[0.94] tracking-[-0.03em] max-[760px]:text-[clamp(1.95rem,10vw,3.2rem)]"> 9 9 Join the conversation. 10 10 </h1> 11 11 </div> 12 - <p class="overline-copy text-[0.72rem] tracking-[0.18em] text-on-surface-variant xl:pt-2">{props.metaLabel}</p> 12 + <p class="overline-copy text-xs tracking-[0.18em] text-on-surface-variant xl:pt-2">{props.metaLabel}</p> 13 13 </header> 14 14 ); 15 15 }
+21 -1
src/components/shared/Icon.tsx
··· 10 10 | "refresh" 11 11 | "search" 12 12 | "timeline" 13 - | "user"; 13 + | "user" 14 + | "menu" 15 + | "quill" 16 + | "at" 17 + | "hashtag" 18 + | "close"; 14 19 15 20 type IconProps = JSX.HTMLAttributes<HTMLSpanElement> & { 16 21 class?: string; ··· 28 33 <Match when={!!local.iconClass}> 29 34 <i class={local.iconClass} /> 30 35 </Match> 36 + <Match when={local.kind === "quill"}> 37 + <i class="i-ri-quill-3-line" /> 38 + </Match> 39 + <Match when={local.kind === "menu"}> 40 + <i class="i-ri-menu-line" /> 41 + </Match> 31 42 <Match when={local.kind === "loader"}> 32 43 <i class="i-ri-loader-4-line" /> 33 44 </Match> ··· 57 68 </Match> 58 69 <Match when={local.kind === "ext-link"}> 59 70 <i class="i-ri-external-link-line" /> 71 + </Match> 72 + <Match when={local.kind === "at"}> 73 + <i class="i-ri-at-line" /> 74 + </Match> 75 + <Match when={local.kind === "hashtag"}> 76 + <i class="i-ri-hashtag" /> 77 + </Match> 78 + <Match when={local.kind === "close"}> 79 + <i class="i-ri-close-line" /> 60 80 </Match> 61 81 </Switch> 62 82 </span>
+110
src/lib/feeds.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { 3 + applyFeedPreferences, 4 + buildThreadRoute, 5 + decodeThreadRouteUri, 6 + getFeedCommand, 7 + } from "./feeds"; 8 + import type { FeedViewPost, FeedViewPrefItem, SavedFeedItem } from "./types"; 9 + 10 + function createFeedItem(overrides: Partial<FeedViewPost> = {}): FeedViewPost { 11 + return { 12 + post: { 13 + author: { did: "did:plc:alice", handle: "alice.test" }, 14 + cid: "cid-1", 15 + indexedAt: "2026-03-28T12:00:00.000Z", 16 + likeCount: 10, 17 + record: { createdAt: "2026-03-28T12:00:00.000Z", text: "hello world" }, 18 + uri: "at://did:plc:alice/app.bsky.feed.post/1", 19 + }, 20 + ...overrides, 21 + }; 22 + } 23 + 24 + function createPref(overrides: Partial<FeedViewPrefItem> = {}): FeedViewPrefItem { 25 + return { 26 + feed: "following", 27 + hideQuotePosts: false, 28 + hideReplies: false, 29 + hideRepliesByLikeCount: null, 30 + hideRepliesByUnfollowed: false, 31 + hideReposts: false, 32 + ...overrides, 33 + }; 34 + } 35 + 36 + describe("feed helpers", () => { 37 + it("filters reposts, replies, quote posts, and low-like replies", () => { 38 + const base = createFeedItem(); 39 + const repost = createFeedItem({ 40 + post: { ...base.post, uri: "at://did:plc:alice/app.bsky.feed.post/2" }, 41 + reason: { 42 + $type: "app.bsky.feed.defs#reasonRepost", 43 + by: { did: "did:plc:bob", handle: "bob.test" }, 44 + indexedAt: "2026-03-28T12:10:00.000Z", 45 + }, 46 + }); 47 + const reply = createFeedItem({ 48 + post: { ...base.post, likeCount: 2, uri: "at://did:plc:alice/app.bsky.feed.post/3" }, 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 quote = createFeedItem({ 55 + post: { 56 + ...base.post, 57 + embed: { 58 + $type: "app.bsky.embed.record#view", 59 + record: { uri: "at://did:plc:bob/app.bsky.feed.post/9" }, 60 + }, 61 + uri: "at://did:plc:alice/app.bsky.feed.post/4", 62 + }, 63 + }); 64 + 65 + const filtered = applyFeedPreferences( 66 + [base, repost, reply, quote], 67 + createPref({ hideQuotePosts: true, hideReplies: true, hideReposts: true }), 68 + ); 69 + 70 + expect(filtered).toEqual([base]); 71 + expect(applyFeedPreferences([reply], createPref({ hideRepliesByLikeCount: 5 }))).toEqual([]); 72 + }); 73 + 74 + it("builds feed commands per saved feed type", () => { 75 + const timeline: SavedFeedItem = { id: "following", pinned: true, type: "timeline", value: "following" }; 76 + const feed: SavedFeedItem = { 77 + id: "custom", 78 + pinned: true, 79 + type: "feed", 80 + value: "at://did:plc:alice/app.bsky.feed.generator/custom", 81 + }; 82 + const list: SavedFeedItem = { 83 + id: "list", 84 + pinned: false, 85 + type: "list", 86 + value: "at://did:plc:alice/app.bsky.graph.list/list", 87 + }; 88 + 89 + expect(getFeedCommand(timeline)).toEqual({ 90 + args: expect.any(Function), 91 + name: "get_timeline", 92 + }); 93 + expect(getFeedCommand(feed).name).toBe("get_feed"); 94 + 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 + }); 100 + }); 101 + 102 + it("encodes and decodes thread routes", () => { 103 + const uri = "at://did:plc:alice/app.bsky.feed.post/abc123"; 104 + 105 + expect(buildThreadRoute(uri)).toBe("/timeline/thread/at%3A%2F%2Fdid%3Aplc%3Aalice%2Fapp.bsky.feed.post%2Fabc123"); 106 + expect(decodeThreadRouteUri("at%3A%2F%2Fdid%3Aplc%3Aalice%2Fapp.bsky.feed.post%2Fabc123")).toBe(uri); 107 + expect(decodeThreadRouteUri(uri)).toBe(uri); 108 + expect(decodeThreadRouteUri("https%3A%2F%2Fexample.com")).toBeNull(); 109 + }); 110 + });
+89 -9
src/lib/feeds.ts
··· 3 3 EmbedView, 4 4 FeedReplyNode, 5 5 FeedViewPost, 6 + FeedViewPrefItem, 7 + Maybe, 6 8 NotFoundPost, 7 9 PostRecord, 8 10 PostView, 9 11 ProfileViewBasic, 12 + SavedFeedItem, 10 13 StrongRefInput, 11 14 ThreadNode, 12 15 ThreadViewPost, 13 16 } from "./types"; 17 + 18 + export const TIMELINE_ROUTE = "/timeline"; 19 + 20 + export const THREAD_ROUTE_BASE = "/timeline/thread"; 14 21 15 22 export function asRecord(value: unknown): Record<string, unknown> | null { 16 23 if (!value || typeof value !== "object" || Array.isArray(value)) { ··· 67 74 return formatter.format(deltaSeconds, "second"); 68 75 } 69 76 70 - export function formatCount(value: number | null | undefined) { 77 + export function formatCount(value: Maybe<number>) { 71 78 if (!value) { 72 79 return "0"; 73 80 } ··· 96 103 return item.type === "list" ? "List" : "Custom feed"; 97 104 } 98 105 106 + export function getFeedCommand(feed: SavedFeedItem) { 107 + if (feed.type === "timeline") { 108 + return { args: (cursor: string | null, limit: number) => ({ cursor, limit }), name: "get_timeline" as const }; 109 + } 110 + 111 + if (feed.type === "list") { 112 + return { 113 + args: (cursor: string | null, limit: number) => ({ cursor, limit, uri: feed.value }), 114 + name: "get_list_feed" as const, 115 + }; 116 + } 117 + 118 + return { 119 + args: (cursor: string | null, limit: number) => ({ cursor, limit, uri: feed.value }), 120 + name: "get_feed" as const, 121 + }; 122 + } 123 + 99 124 export function isRepostReason(item: FeedViewPost) { 100 125 return item.reason?.$type === "app.bsky.feed.defs#reasonRepost"; 101 126 } 102 127 103 - export function isQuoteEmbed(embed: EmbedView | null | undefined) { 128 + export function isQuoteEmbed(embed: Maybe<EmbedView>) { 104 129 return embed?.$type === "app.bsky.embed.record#view" || embed?.$type === "app.bsky.embed.recordWithMedia#view"; 105 130 } 106 131 ··· 114 139 } 115 140 116 141 return toStrongRef(item.post); 142 + } 143 + 144 + export function getReplyRootPost(item: FeedViewPost) { 145 + if (item.reply?.root.$type === "app.bsky.feed.defs#postView") { 146 + return item.reply.root; 147 + } 148 + 149 + return item.post; 117 150 } 118 151 119 152 export function toStrongRef(post: PostView) { ··· 121 154 } 122 155 123 156 export function canUseStrongRef( 124 - post: FeedReplyNode | ThreadNode | null | undefined, 157 + post: Maybe<FeedReplyNode | ThreadNode>, 125 158 ): post is { $type: "app.bsky.feed.defs#postView" } & PostView { 126 159 return !!post && "$type" in post && post.$type === "app.bsky.feed.defs#postView"; 127 160 } 128 161 129 - export function isThreadViewPost(node: ThreadNode | null | undefined): node is ThreadViewPost { 162 + export function isThreadViewPost(node: Maybe<ThreadNode>): node is ThreadViewPost { 130 163 return !!node && node.$type === "app.bsky.feed.defs#threadViewPost"; 131 164 } 132 165 133 - export function isBlockedNode(node: ThreadNode | FeedReplyNode | null | undefined): node is BlockedPost { 166 + export function isBlockedNode(node: Maybe<ThreadNode | FeedReplyNode>): node is BlockedPost { 134 167 return !!node && node.$type === "app.bsky.feed.defs#blockedPost"; 135 168 } 136 169 137 - export function isNotFoundNode(node: ThreadNode | FeedReplyNode | null | undefined): node is NotFoundPost { 170 + export function isNotFoundNode(node: Maybe<ThreadNode | FeedReplyNode>): node is NotFoundPost { 138 171 return !!node && node.$type === "app.bsky.feed.defs#notFoundPost"; 139 172 } 140 173 ··· 164 197 return [...handles].toSorted((left, right) => left.localeCompare(right)); 165 198 } 166 199 167 - export function getQuotedRecord(embed: EmbedView | null | undefined) { 200 + export function applyFeedPreferences(items: FeedViewPost[], pref: FeedViewPrefItem) { 201 + return items.filter((item) => { 202 + if (pref.hideReposts && isRepostReason(item)) { 203 + return false; 204 + } 205 + 206 + if (pref.hideReplies && isReplyItem(item)) { 207 + return false; 208 + } 209 + 210 + if (pref.hideQuotePosts && isQuoteEmbed(item.post.embed)) { 211 + return false; 212 + } 213 + 214 + if (pref.hideRepliesByLikeCount && isReplyItem(item) && (item.post.likeCount ?? 0) < pref.hideRepliesByLikeCount) { 215 + return false; 216 + } 217 + 218 + return true; 219 + }); 220 + } 221 + 222 + export function getQuotedRecord(embed: Maybe<EmbedView>) { 168 223 if (!embed) { 169 224 return null; 170 225 } ··· 180 235 return null; 181 236 } 182 237 183 - export function getQuotedText(embed: EmbedView | null | undefined) { 238 + export function getQuotedText(embed: Maybe<EmbedView>) { 184 239 const record = getQuotedRecord(embed); 185 240 return asRecord(record?.value)?.text; 186 241 } 187 242 188 - export function getQuotedAuthor(embed: EmbedView | null | undefined) { 243 + export function getQuotedAuthor(embed: Maybe<EmbedView>) { 189 244 return getQuotedRecord(embed)?.author ?? null; 190 245 } 191 246 ··· 217 272 218 273 return node.post; 219 274 } 275 + 276 + export function encodeThreadRouteUri(uri: string) { 277 + return encodeURIComponent(uri); 278 + } 279 + 280 + export function decodeThreadRouteUri(value: Maybe<string>) { 281 + if (!value) { 282 + return null; 283 + } 284 + 285 + if (value.startsWith("at://")) { 286 + return value; 287 + } 288 + 289 + try { 290 + const decoded = decodeURIComponent(value); 291 + return decoded.startsWith("at://") ? decoded : null; 292 + } catch { 293 + return null; 294 + } 295 + } 296 + 297 + export function buildThreadRoute(uri: string) { 298 + return `${THREAD_ROUTE_BASE}/${encodeThreadRouteUri(uri)}`; 299 + }
+11 -7
src/lib/types.ts
··· 1 + export type Maybe<T> = T | null | undefined; 2 + 1 3 export type AccountSummary = { did: string; handle: string; pdsUrl: string; active: boolean }; 2 4 3 5 export type ActiveSession = { did: string; handle: string }; ··· 6 8 7 9 export type LoginSuggestion = { did: string; handle: string; displayName?: string | null; avatar?: string | null }; 8 10 9 - export type SavedFeedItem = { id: string; type: string; value: string; pinned: boolean }; 11 + export type SavedFeedKind = "timeline" | "feed" | "list"; 12 + 13 + export type SavedFeedItem = { id: string; type: SavedFeedKind; value: string; pinned: boolean }; 10 14 11 15 export type FeedViewPrefItem = { 12 16 feed: string; ··· 104 108 export type PostView = { 105 109 author: ProfileViewBasic; 106 110 cid: string; 107 - embed?: EmbedView | null; 111 + embed: Maybe<EmbedView>; 108 112 indexedAt: string; 109 - likeCount?: number | null; 110 - quoteCount?: number | null; 113 + likeCount: Maybe<number>; 114 + quoteCount: Maybe<number>; 111 115 record: PostRecord | Record<string, unknown>; 112 - replyCount?: number | null; 113 - repostCount?: number | null; 116 + replyCount: Maybe<number>; 117 + repostCount: Maybe<number>; 114 118 uri: string; 115 - viewer?: ViewerState | null; 119 + viewer: Maybe<ViewerState>; 116 120 }; 117 121 118 122 export type NotFoundPost = { $type: "app.bsky.feed.defs#notFoundPost"; notFound: boolean; uri: string };
+8
src/lib/utils/events.ts
··· 1 + export function shouldIgnoreKey(event: KeyboardEvent) { 2 + const element = event.target; 3 + if (!(element instanceof HTMLElement)) { 4 + return false; 5 + } 6 + 7 + return element.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(element.tagName); 8 + }
+3
src/lib/utils/text.ts
··· 1 + export function escapeForRegex(value: string) { 2 + return value.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); 3 + }
+54
src/router.test.tsx
··· 1 + import { render, screen } from "@solidjs/testing-library"; 2 + import type { Component, JSX } from "solid-js"; 3 + import { describe, expect, it, vi } from "vitest"; 4 + import { buildThreadRoute } from "./lib/feeds"; 5 + import type { ActiveSession } from "./lib/types"; 6 + import { AppRouter } from "./router"; 7 + 8 + const session: ActiveSession = { did: "did:plc:alice", handle: "alice.test" }; 9 + 10 + const Shell: Component<{ children: JSX.Element }> = (props) => <div>{props.children}</div>; 11 + 12 + function renderRouter(hash: string) { 13 + globalThis.location.hash = hash; 14 + const renderTimeline = vi.fn((currentSession: ActiveSession, context: { threadUri: string | null }) => ( 15 + <div data-testid="timeline-view"> 16 + <span>{currentSession.handle}</span> 17 + <span>{context.threadUri ?? "no-thread"}</span> 18 + </div> 19 + )); 20 + 21 + render(() => ( 22 + <AppRouter 23 + bootstrapping={false} 24 + hasSession 25 + renderAuth={() => <div>Auth</div>} 26 + renderShell={Shell} 27 + renderTimeline={renderTimeline} 28 + session={session} /> 29 + )); 30 + 31 + return { renderTimeline }; 32 + } 33 + 34 + describe("AppRouter", () => { 35 + it("renders the timeline route without a thread uri", async () => { 36 + const { renderTimeline } = renderRouter("#/timeline"); 37 + 38 + await screen.findByTestId("timeline-view"); 39 + 40 + expect(renderTimeline).toHaveBeenCalled(); 41 + expect(renderTimeline.mock.lastCall?.[1].threadUri).toBeNull(); 42 + expect(screen.getByText("no-thread")).toBeInTheDocument(); 43 + }); 44 + 45 + it("passes the decoded thread uri on the thread route", async () => { 46 + const threadUri = "at://did:plc:alice/app.bsky.feed.post/xyz"; 47 + const { renderTimeline } = renderRouter(`#${buildThreadRoute(threadUri)}`); 48 + 49 + await screen.findByTestId("timeline-view"); 50 + 51 + expect(renderTimeline.mock.lastCall?.[1].threadUri).toBe(threadUri); 52 + expect(screen.getByText(threadUri)).toBeInTheDocument(); 53 + }); 54 + });
+52 -8
src/router.tsx
··· 1 - import { HashRouter, Navigate, Route, type RouteSectionProps, useLocation } from "@solidjs/router"; 1 + import { HashRouter, Navigate, Route, type RouteSectionProps, useLocation, useNavigate, useParams } from "@solidjs/router"; 2 2 import { type Component, createEffect, type JSX, Show } from "solid-js"; 3 + import { buildThreadRoute, decodeThreadRouteUri, TIMELINE_ROUTE } from "./lib/feeds"; 3 4 import type { ActiveSession } from "./lib/types"; 4 5 5 6 type AppRouterProps = { ··· 8 9 onLocationChange?: () => void; 9 10 renderAuth: () => JSX.Element; 10 11 renderShell: Component<{ children: JSX.Element }>; 11 - renderTimeline: (session: ActiveSession) => JSX.Element; 12 + renderTimeline: ( 13 + session: ActiveSession, 14 + context: { onThreadRouteChange: (uri: string | null) => void; threadUri: string | null }, 15 + ) => JSX.Element; 12 16 session: ActiveSession | null; 13 17 }; 14 18 ··· 30 34 31 35 const IndexRoute = () => ( 32 36 <Show when={!props.bootstrapping} fallback={<RouteLoadingState />}> 33 - <Navigate href={props.hasSession ? "/timeline" : "/auth"} /> 37 + <Navigate href={props.hasSession ? TIMELINE_ROUTE : "/auth"} /> 34 38 </Show> 35 39 ); 36 40 37 41 const AuthRoute = () => ( 38 - <PublicOnlyRoute bootstrapping={props.bootstrapping} when={!props.hasSession} redirectHref="/timeline"> 42 + <PublicOnlyRoute bootstrapping={props.bootstrapping} when={!props.hasSession} redirectHref={TIMELINE_ROUTE}> 39 43 {props.renderAuth()} 40 44 </PublicOnlyRoute> 41 45 ); 42 46 43 47 const TimelineRoute = () => ( 44 - <ProtectedRouteView bootstrapping={props.bootstrapping} session={props.session}> 45 - {(session) => props.renderTimeline(session)} 46 - </ProtectedRouteView> 48 + <TimelineRouteView 49 + bootstrapping={props.bootstrapping} 50 + renderTimeline={props.renderTimeline} 51 + session={props.session} 52 + threadUri={null} /> 47 53 ); 48 54 55 + const ThreadRoute = () => { 56 + const params = useParams<{ threadUri: string }>(); 57 + const threadUri = () => decodeThreadRouteUri(params.threadUri); 58 + 59 + return ( 60 + <Show when={threadUri()} keyed fallback={<Navigate href={TIMELINE_ROUTE} />}> 61 + {(uri) => ( 62 + <TimelineRouteView 63 + bootstrapping={props.bootstrapping} 64 + renderTimeline={props.renderTimeline} 65 + session={props.session} 66 + threadUri={uri} /> 67 + )} 68 + </Show> 69 + ); 70 + }; 71 + 49 72 const SearchRoute = () => ( 50 73 <ProtectedRouteView bootstrapping={props.bootstrapping} session={props.session}> 51 74 {() => ( ··· 81 104 82 105 const NotFoundRoute = () => ( 83 106 <Show when={!props.bootstrapping} fallback={<RouteLoadingState />}> 84 - <Navigate href={props.hasSession ? "/timeline" : "/auth"} /> 107 + <Navigate href={props.hasSession ? TIMELINE_ROUTE : "/auth"} /> 85 108 </Show> 86 109 ); 87 110 ··· 90 113 <Route path="/" component={IndexRoute} /> 91 114 <Route path="/auth" component={AuthRoute} /> 92 115 <Route path="/timeline" component={TimelineRoute} /> 116 + <Route path="/timeline/thread/:threadUri" component={ThreadRoute} /> 93 117 <Route path="/search" component={SearchRoute} /> 94 118 <Route path="/notifications" component={NotificationsRoute} /> 95 119 <Route path="/explorer" component={ExplorerRoute} /> 96 120 <Route path="*404" component={NotFoundRoute} /> 97 121 </HashRouter> 122 + ); 123 + } 124 + 125 + function TimelineRouteView( 126 + props: { 127 + bootstrapping: boolean; 128 + renderTimeline: AppRouterProps["renderTimeline"]; 129 + session: ActiveSession | null; 130 + threadUri: string | null; 131 + }, 132 + ) { 133 + const navigate = useNavigate(); 134 + 135 + return ( 136 + <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 + })} 141 + </ProtectedRouteView> 98 142 ); 99 143 } 100 144
+5 -2
src/test/setup.ts
··· 1 - /* eslint-disable unicorn/consistent-function-scoping */ 2 1 import { cleanup } from "@solidjs/testing-library"; 3 2 import "@testing-library/jest-dom/vitest"; 3 + import { Dynamic } from "solid-js/web"; 4 4 import { afterEach, vi } from "vitest"; 5 5 6 6 vi.mock( 7 7 "solid-motionone", 8 8 () => ({ 9 - Motion: new Proxy({}, { get: () => (props: { children?: unknown }) => props.children as unknown }), 9 + Motion: new Proxy({}, { 10 + get: (_, property) => 11 + (props: { children?: unknown }) => Dynamic({ ...props, component: String(property) }), 12 + }), 10 13 Presence: (props: { children?: unknown }) => props.children as unknown, 11 14 }), 12 15 );