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: feed UI

+2959 -302
+122 -278
src/App.tsx
··· 1 1 import { invoke } from "@tauri-apps/api/core"; 2 2 import { listen } from "@tauri-apps/api/event"; 3 - import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, startTransition } from "solid-js"; 4 - import { Motion } from "solid-motionone"; 3 + import { createMemo, createSignal, onCleanup, onMount, Show, startTransition } from "solid-js"; 5 4 import "@fontsource-variable/google-sans"; 6 5 import "./App.css"; 6 + import { AccountLedger } from "./components/AccountLedger"; 7 7 import { AccountSwitcher } from "./components/AccountSwitcher"; 8 - import { AvatarBadge } from "./components/AvatarBadge"; 8 + import { FeedWorkspace } from "./components/feeds/FeedWorkspace"; 9 + import { LoginPanel } from "./components/LoginPanel"; 9 10 import { HeaderPanel } from "./components/panels/Header"; 10 11 import { RailButton } from "./components/RailButton"; 11 12 import { SessionSpotlight } from "./components/Session"; 12 13 import { ErrorToast } from "./components/shared/ErrorToast"; 13 - import { Icon } from "./components/shared/Icon"; 14 14 import { Wordmark } from "./components/Wordmark"; 15 15 import type { AccountSummary, ActiveSession, AppBootstrap } from "./lib/types"; 16 16 17 17 const ACCOUNT_SWITCH_EVENT = "auth:account-switched"; 18 18 19 - const panelTitleClass = "overline-copy text-[0.75rem] text-[color:var(--on-surface-variant)]"; 20 - 21 - const subtleTextClass = "m-0 text-[0.78rem] leading-[1.55] text-[color:var(--on-surface-variant)]"; 22 - 23 - const primaryButtonClass = 24 - "pill-action border-0 bg-[linear-gradient(135deg,var(--primary)_0%,var(--primary-dim)_100%)] text-[color:var(--on-primary-fixed)]"; 25 - 26 - const secondaryButtonClass = "pill-action border-0 bg-white/8 text-on-surface"; 27 - 28 - const ghostButtonClass = "pill-action border-0 bg-transparent text-[color:var(--on-surface-variant)]"; 29 - 30 - type LoginPanelProps = { 31 - value: string; 32 - pending: boolean; 33 - shakeCount: number; 34 - onInput: (value: string) => void; 35 - onSubmit: () => void; 36 - }; 37 - 38 - function LoginPanel(props: LoginPanelProps) { 39 - let input: HTMLInputElement | undefined; 40 - 41 - createEffect(() => { 42 - if (props.shakeCount > 0) { 43 - input?.focus(); 44 - input?.select(); 45 - } 46 - }); 47 - 48 - return ( 49 - <article class="panel-surface grid gap-6 p-6"> 50 - <div class="flex items-baseline justify-between gap-3"> 51 - <p class={panelTitleClass}>Add account</p> 52 - <p class={subtleTextClass}>Enter the account you want to use.</p> 53 - </div> 54 - 55 - <Motion.form 56 - class="grid gap-4" 57 - initial={{ opacity: 0, y: 18 }} 58 - animate={{ opacity: 1, y: 0, x: props.shakeCount > 0 ? [0, -16, 10, -8, 0] : 0 }} 59 - transition={{ duration: props.shakeCount > 0 ? 0.42 : 0.24, easing: [0.22, 1, 0.36, 1] }} 60 - onSubmit={(event) => { 61 - event.preventDefault(); 62 - props.onSubmit(); 63 - }}> 64 - <label class="grid gap-[0.7rem]"> 65 - <span class="overline-copy text-[0.76rem] tracking-[0.08em] text-on-surface-variant"> 66 - Handle, DID, or URL 67 - </span> 68 - <input 69 - ref={(element) => { 70 - input = element; 71 - }} 72 - class="min-h-[3.4rem] w-full rounded-full border-0 bg-white/4 px-[1.15rem] text-on-surface shadow-[inset_0_0_0_1px_rgba(125,175,255,0.16)] focus:outline focus:outline-primary/50 focus:shadow-[inset_0_0_0_1px_rgba(125,175,255,0.35),0_0_28px_rgba(125,175,255,0.12)]" 73 - type="text" 74 - autocomplete="username" 75 - spellcheck={false} 76 - value={props.value} 77 - placeholder="alice.bsky.social" 78 - onInput={(event) => props.onInput(event.currentTarget.value)} /> 79 - </label> 80 - <LoginSubmitButton pending={props.pending} /> 81 - </Motion.form> 82 - </article> 83 - ); 84 - } 85 - 86 - function LoginSubmitButton(props: { pending: boolean }) { 87 - return ( 88 - <button class={primaryButtonClass} type="submit" disabled={props.pending}> 89 - <Show 90 - when={props.pending} 91 - fallback={ 92 - <> 93 - <Icon kind="ext-link" name="ext-link" aria-hidden="true" class="mr-1" /> 94 - <span>Continue</span> 95 - </> 96 - }> 97 - <> 98 - <Icon kind="loader" name="loader" aria-hidden="true" class="mr-1" /> 99 - <span>Opening sign-in...</span> 100 - </> 101 - </Show> 102 - </button> 103 - ); 104 - } 105 - 106 - type AccountLedgerProps = { 107 - accounts: AccountSummary[]; 108 - activeDid: string | null; 109 - busyDid: string | null; 110 - logoutDid: string | null; 111 - onSwitch: (did: string) => void; 112 - onLogout: (did: string) => void; 113 - }; 114 - 115 - function AccountLedger(props: AccountLedgerProps) { 116 - return ( 117 - <article class="panel-surface grid gap-6 p-6"> 118 - <div class="flex items-baseline justify-between gap-3"> 119 - <p class={panelTitleClass}>Accounts</p> 120 - <p class={subtleTextClass}>{props.accounts.length} added</p> 121 - </div> 122 - 123 - <Show 124 - when={props.accounts.length > 0} 125 - fallback={ 126 - <p class="overline-copy text-[0.72rem] text-on-surface-variant">Accounts you add will show up here.</p> 127 - }> 128 - <div class="grid gap-3" role="list"> 129 - <For each={props.accounts}> 130 - {(account) => ( 131 - <AccountLedgerCard 132 - account={account} 133 - activeDid={props.activeDid} 134 - busyDid={props.busyDid} 135 - logoutDid={props.logoutDid} 136 - onSwitch={props.onSwitch} 137 - onLogout={props.onLogout} /> 138 - )} 139 - </For> 140 - </div> 141 - </Show> 142 - </article> 143 - ); 144 - } 145 - 146 - function LogoutButton( 147 - props: { isSwitching: boolean; isLoggingOut: boolean; did: string; onLogout: (did: string) => void }, 148 - ) { 149 - const isSwitching = () => props.isSwitching; 150 - const isLoggingOut = () => props.isLoggingOut; 151 - const did = () => props.did; 152 - return ( 153 - <button 154 - class={ghostButtonClass} 155 - type="button" 156 - disabled={isSwitching() || isLoggingOut()} 157 - onClick={() => props.onLogout(did())}> 158 - <Show 159 - when={isLoggingOut()} 160 - fallback={ 161 - <> 162 - <Icon kind="logout" name="logout" aria-hidden="true" /> 163 - <span>Logout</span> 164 - </> 165 - }> 166 - <> 167 - <Icon kind="loader" name="loader" aria-hidden="true" /> 168 - <span>Removing...</span> 169 - </> 170 - </Show> 171 - </button> 172 - ); 173 - } 174 - 175 - function AccountSwitchButton( 176 - props: { 177 - isActive: boolean; 178 - switching: boolean; 179 - loggingOut: boolean; 180 - account: AccountSummary; 181 - onSwitch: (did: string) => void; 182 - }, 183 - ) { 184 - const isActive = () => props.isActive; 185 - const switching = () => props.switching; 186 - const loggingOut = () => props.loggingOut; 187 - 188 - const content = createMemo(() => { 189 - const active = isActive(); 190 - const isSwitching = switching(); 191 - if (active) { 192 - return "Active"; 193 - } 194 - 195 - if (isSwitching) { 196 - return "Switching..."; 197 - } 198 - 199 - return "Switch"; 200 - }); 201 - return ( 202 - <button 203 - class={secondaryButtonClass} 204 - type="button" 205 - disabled={isActive() || switching() || loggingOut()} 206 - onClick={() => props.onSwitch(props.account.did)}> 207 - <Show when={switching()} fallback={<Icon kind="user" name="user" aria-hidden="true" class="mr-1" />}> 208 - <Icon kind="loader" name="loader" aria-hidden="true" class="mr-1" /> 209 - <span>{content()}</span> 210 - </Show> 211 - </button> 212 - ); 213 - } 214 - 215 - type AccountLedgerCardProps = { 216 - account: AccountSummary; 217 - activeDid: string | null; 218 - busyDid: string | null; 219 - logoutDid: string | null; 220 - onSwitch: (did: string) => void; 221 - onLogout: (did: string) => void; 222 - }; 223 - 224 - function AccountLedgerCard(props: AccountLedgerCardProps) { 225 - const isActive = () => props.activeDid === props.account.did; 226 - const switching = () => props.busyDid === props.account.did; 227 - const loggingOut = () => props.logoutDid === props.account.did; 228 - 229 - return ( 230 - <Motion.div 231 - class="grid items-center gap-4 rounded-2xl bg-white/2.5 p-4 max-[920px]:grid-cols-1 grid-cols-[minmax(0,1fr)_auto]" 232 - classList={{ "bg-[linear-gradient(135deg,rgba(125,175,255,0.12),rgba(0,115,222,0.08))]": isActive() }} 233 - role="listitem" 234 - initial={{ opacity: 0, y: 18 }} 235 - animate={{ opacity: 1, y: 0 }} 236 - transition={{ duration: 0.24 }}> 237 - <div class="flex min-w-0 items-start gap-4"> 238 - <AvatarBadge label={props.account.handle || props.account.did} tone={isActive() ? "primary" : "muted"} /> 239 - <div class="min-w-0"> 240 - <p class="m-0 wrap-break-word text-[0.92rem] font-semibold">{props.account.handle || props.account.did}</p> 241 - <p class="m-0 wrap-break-word text-[0.78rem] text-on-surface-variant">{props.account.did}</p> 242 - <p class="m-0 wrap-break-word text-[0.78rem] text-on-surface-variant"> 243 - {props.account.pdsUrl || "PDS unavailable"} 244 - </p> 245 - </div> 246 - </div> 247 - 248 - <div class="flex items-center gap-2 max-[920px]:flex-col max-[920px]:items-stretch"> 249 - <AccountSwitchButton 250 - isActive={isActive()} 251 - switching={switching()} 252 - loggingOut={loggingOut()} 253 - account={props.account} 254 - onSwitch={props.onSwitch} /> 255 - <LogoutButton 256 - isSwitching={switching()} 257 - isLoggingOut={loggingOut()} 258 - did={props.account.did} 259 - onLogout={props.onLogout} /> 260 - </div> 261 - </Motion.div> 262 - ); 263 - } 264 - 265 19 function App() { 266 20 const [bootstrapping, setBootstrapping] = createSignal(true); 267 21 const [activeSession, setActiveSession] = createSignal<ActiveSession | null>(null); ··· 277 31 278 32 const activeAccount = createMemo(() => accounts().find((account) => account.did === activeSession()?.did) ?? null); 279 33 const primaryAccount = createMemo(() => activeAccount() ?? accounts()[0] ?? null); 34 + const hasSession = createMemo(() => !!activeSession()); 280 35 const metaLabel = createMemo(() => { 281 36 if (bootstrapping()) { 282 37 return "signing you back in"; ··· 403 158 class="flex min-h-screen flex-col gap-8 bg-surface-container-lowest px-6 pb-6 pt-8 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" 404 159 aria-label="Primary navigation"> 405 160 <Wordmark /> 406 - <div class="grid gap-2 max-[1180px]:flex max-[1180px]:items-center"> 407 - <RailButton label="Accounts" icon="profile" active /> 408 - <RailButton label="Search" icon="search" /> 409 - </div> 161 + <RailNavigation hasSession={hasSession()} /> 410 162 <AccountSwitcher 411 163 activeSession={activeSession()} 412 164 accounts={accounts()} ··· 421 173 <section 422 174 class="m-5 grid gap-8 rounded-4xl bg-[linear-gradient(160deg,rgba(14,14,14,0.92),rgba(25,25,25,0.98))] p-8 shadow-[0_24px_40px_rgba(125,175,255,0.05)] max-[1360px]:p-7 max-[1180px]:m-0 max-[1180px]:min-h-[calc(100vh-5.5rem)] max-[1180px]:rounded-none max-[1180px]:p-6 max-[760px]:gap-6 max-[760px]:p-5" 423 175 aria-busy={bootstrapping()}> 424 - <HeaderPanel metaLabel={metaLabel()} /> 425 - 426 - <div class="grid gap-6 grid-cols-[minmax(0,1.25fr)_minmax(20rem,0.9fr)] max-[1320px]:grid-cols-1"> 427 - <SessionSpotlight 428 - activeSession={activeSession()} 429 - activeAccount={activeAccount()} 430 - bootstrapping={bootstrapping()} 431 - reauthNeeded={reauthNeeded()} 432 - onReauth={() => void reauthorizePrimaryAccount()} /> 433 - <LoginPanel 434 - value={loginValue()} 435 - pending={loggingIn()} 436 - shakeCount={shakeCount()} 437 - onInput={setLoginValue} 438 - onSubmit={() => void submitLogin()} /> 439 - </div> 440 - 441 - <AccountLedger 442 - accounts={accounts()} 443 - activeDid={activeSession()?.did ?? null} 444 - busyDid={switchingDid()} 445 - logoutDid={logoutDid()} 446 - onSwitch={(did) => void switchAccount(did)} 447 - onLogout={(did) => void logout(did)} /> 176 + <Show 177 + when={activeSession()} 178 + keyed 179 + fallback={ 180 + <AuthWorkspace 181 + accounts={accounts()} 182 + activeAccount={activeAccount()} 183 + activeDid={activeSession()?.did ?? null} 184 + bootstrapping={bootstrapping()} 185 + loggingIn={loggingIn()} 186 + loginValue={loginValue()} 187 + logoutDid={logoutDid()} 188 + metaLabel={metaLabel()} 189 + reauthNeeded={reauthNeeded()} 190 + shakeCount={shakeCount()} 191 + switchingDid={switchingDid()} 192 + onInput={setLoginValue} 193 + onLogout={(did) => void logout(did)} 194 + onReauth={() => void reauthorizePrimaryAccount()} 195 + onSubmit={() => void submitLogin()} 196 + onSwitch={(did) => void switchAccount(did)} /> 197 + }> 198 + {(session) => <FeedWorkspace activeSession={session} onError={setErrorMessage} />} 199 + </Show> 448 200 </section> 449 201 </main> 450 202 451 203 <ErrorToast message={errorMessage} onDismiss={() => setErrorMessage(null)} /> 452 204 </> 205 + ); 206 + } 207 + 208 + function RailNavigation(props: { hasSession: boolean }) { 209 + return ( 210 + <div class="grid gap-2 max-[1180px]:flex max-[1180px]:items-center"> 211 + <Show when={props.hasSession} fallback={<RailButton label="Accounts" icon="profile" active />}> 212 + <> 213 + <RailButton label="Timeline" icon="timeline" active /> 214 + <RailButton label="Search" icon="search" /> 215 + <RailButton label="Notifications" icon="notifications" /> 216 + <RailButton label="Explorer" icon="explorer" /> 217 + </> 218 + </Show> 219 + </div> 220 + ); 221 + } 222 + 223 + function AuthWorkspace( 224 + props: { 225 + accounts: AccountSummary[]; 226 + activeAccount: AccountSummary | null; 227 + activeDid: string | null; 228 + bootstrapping: boolean; 229 + loggingIn: boolean; 230 + loginValue: string; 231 + logoutDid: string | null; 232 + metaLabel: string; 233 + reauthNeeded: boolean; 234 + shakeCount: number; 235 + switchingDid: string | null; 236 + onInput: (value: string) => void; 237 + onLogout: (did: string) => void; 238 + onReauth: () => void; 239 + onSubmit: () => void; 240 + onSwitch: (did: string) => void; 241 + }, 242 + ) { 243 + return ( 244 + <> 245 + <HeaderPanel metaLabel={props.metaLabel} /> 246 + <AuthHero 247 + activeAccount={props.activeAccount} 248 + bootstrapping={props.bootstrapping} 249 + loggingIn={props.loggingIn} 250 + loginValue={props.loginValue} 251 + reauthNeeded={props.reauthNeeded} 252 + shakeCount={props.shakeCount} 253 + onInput={props.onInput} 254 + onReauth={props.onReauth} 255 + onSubmit={props.onSubmit} /> 256 + <AccountLedger 257 + accounts={props.accounts} 258 + activeDid={props.activeDid} 259 + busyDid={props.switchingDid} 260 + logoutDid={props.logoutDid} 261 + onSwitch={props.onSwitch} 262 + onLogout={props.onLogout} /> 263 + </> 264 + ); 265 + } 266 + 267 + function AuthHero( 268 + props: { 269 + activeAccount: AccountSummary | null; 270 + bootstrapping: boolean; 271 + loggingIn: boolean; 272 + loginValue: string; 273 + reauthNeeded: boolean; 274 + shakeCount: number; 275 + onInput: (value: string) => void; 276 + onReauth: () => void; 277 + onSubmit: () => void; 278 + }, 279 + ) { 280 + return ( 281 + <div class="grid gap-6 grid-cols-[minmax(0,1.25fr)_minmax(20rem,0.9fr)] max-[1320px]:grid-cols-1"> 282 + <SessionSpotlight 283 + activeSession={props.activeAccount 284 + ? { did: props.activeAccount.did, handle: props.activeAccount.handle } 285 + : null} 286 + activeAccount={props.activeAccount} 287 + bootstrapping={props.bootstrapping} 288 + reauthNeeded={props.reauthNeeded} 289 + onReauth={props.onReauth} /> 290 + <LoginPanel 291 + value={props.loginValue} 292 + pending={props.loggingIn} 293 + shakeCount={props.shakeCount} 294 + onInput={props.onInput} 295 + onSubmit={props.onSubmit} /> 296 + </div> 453 297 ); 454 298 } 455 299
+72
src/components/AccountButtons.tsx
··· 1 + import type { AccountSummary } from "$/lib/types"; 2 + import { createMemo, Show } from "solid-js"; 3 + import { Icon } from "./shared/Icon"; 4 + 5 + export function AccountSwitchButton( 6 + props: { 7 + isActive: boolean; 8 + switching: boolean; 9 + loggingOut: boolean; 10 + account: AccountSummary; 11 + onSwitch: (did: string) => void; 12 + }, 13 + ) { 14 + const isActive = () => props.isActive; 15 + const switching = () => props.switching; 16 + const loggingOut = () => props.loggingOut; 17 + 18 + const content = createMemo(() => { 19 + const active = isActive(); 20 + const isSwitching = switching(); 21 + if (active) { 22 + return "Active"; 23 + } 24 + 25 + if (isSwitching) { 26 + return "Switching..."; 27 + } 28 + 29 + return "Switch"; 30 + }); 31 + return ( 32 + <button 33 + class="pill-action border-0 bg-white/8 text-on-surface" 34 + type="button" 35 + disabled={isActive() || switching() || loggingOut()} 36 + onClick={() => props.onSwitch(props.account.did)}> 37 + <Show when={switching()} fallback={<Icon kind="user" name="user" aria-hidden="true" class="mr-1" />}> 38 + <Icon kind="loader" name="loader" aria-hidden="true" class="mr-1" /> 39 + <span>{content()}</span> 40 + </Show> 41 + </button> 42 + ); 43 + } 44 + 45 + export function LogoutButton( 46 + props: { isSwitching: boolean; isLoggingOut: boolean; did: string; onLogout: (did: string) => void }, 47 + ) { 48 + const isSwitching = () => props.isSwitching; 49 + const isLoggingOut = () => props.isLoggingOut; 50 + const did = () => props.did; 51 + return ( 52 + <button 53 + class="pill-action border-0 bg-transparent text-on-surface-variant" 54 + type="button" 55 + disabled={isSwitching() || isLoggingOut()} 56 + onClick={() => props.onLogout(did())}> 57 + <Show 58 + when={isLoggingOut()} 59 + fallback={ 60 + <> 61 + <Icon kind="logout" name="logout" aria-hidden="true" /> 62 + <span>Logout</span> 63 + </> 64 + }> 65 + <> 66 + <Icon kind="loader" name="loader" aria-hidden="true" /> 67 + <span>Removing...</span> 68 + </> 69 + </Show> 70 + </button> 71 + ); 72 + }
+93
src/components/AccountLedger.tsx
··· 1 + import { AccountSummary } from "$/lib/types"; 2 + import { For, Show } from "solid-js"; 3 + import { Motion } from "solid-motionone"; 4 + import { AccountSwitchButton, LogoutButton } from "./AccountButtons"; 5 + import { AvatarBadge } from "./AvatarBadge"; 6 + 7 + type AccountLedgerProps = { 8 + accounts: AccountSummary[]; 9 + activeDid: string | null; 10 + busyDid: string | null; 11 + logoutDid: string | null; 12 + onSwitch: (did: string) => void; 13 + onLogout: (did: string) => void; 14 + }; 15 + 16 + export function AccountLedger(props: AccountLedgerProps) { 17 + return ( 18 + <article class="panel-surface grid gap-6 p-6"> 19 + <div class="flex items-baseline justify-between gap-3"> 20 + <p class="overline-copy text-[0.75rem] text-on-surface-variant">Accounts</p> 21 + <p class="m-0 text-xs leading-[1.55] text-on-surface-variant">{props.accounts.length} added</p> 22 + </div> 23 + 24 + <Show 25 + when={props.accounts.length > 0} 26 + fallback={ 27 + <p class="overline-copy text-[0.72rem] text-on-surface-variant">Accounts you add will show up here.</p> 28 + }> 29 + <div class="grid gap-3" role="list"> 30 + <For each={props.accounts}> 31 + {(account) => ( 32 + <AccountLedgerCard 33 + account={account} 34 + activeDid={props.activeDid} 35 + busyDid={props.busyDid} 36 + logoutDid={props.logoutDid} 37 + onSwitch={props.onSwitch} 38 + onLogout={props.onLogout} /> 39 + )} 40 + </For> 41 + </div> 42 + </Show> 43 + </article> 44 + ); 45 + } 46 + 47 + type AccountLedgerCardProps = { 48 + account: AccountSummary; 49 + activeDid: string | null; 50 + busyDid: string | null; 51 + logoutDid: string | null; 52 + onSwitch: (did: string) => void; 53 + onLogout: (did: string) => void; 54 + }; 55 + 56 + function AccountLedgerCard(props: AccountLedgerCardProps) { 57 + const isActive = () => props.activeDid === props.account.did; 58 + const switching = () => props.busyDid === props.account.did; 59 + const loggingOut = () => props.logoutDid === props.account.did; 60 + 61 + return ( 62 + <Motion.div 63 + class="grid items-center gap-4 rounded-2xl bg-white/2.5 p-4 max-[920px]:grid-cols-1 grid-cols-[minmax(0,1fr)_auto]" 64 + classList={{ "bg-[linear-gradient(135deg,rgba(125,175,255,0.12),rgba(0,115,222,0.08))]": isActive() }} 65 + role="listitem" 66 + initial={{ opacity: 0, y: 18 }} 67 + animate={{ opacity: 1, y: 0 }} 68 + transition={{ duration: 0.24 }}> 69 + <div class="flex min-w-0 items-start gap-4"> 70 + <AvatarBadge label={props.account.handle || props.account.did} tone={isActive() ? "primary" : "muted"} /> 71 + <div class="min-w-0"> 72 + <p class="m-0 wrap-break-word text-[0.92rem] font-semibold">{props.account.handle || props.account.did}</p> 73 + <p class="m-0 wrap-break-word text-xs text-on-surface-variant">{props.account.did}</p> 74 + <p class="m-0 wrap-break-word text-xs text-on-surface-variant">{props.account.pdsUrl || "PDS unavailable"}</p> 75 + </div> 76 + </div> 77 + 78 + <div class="flex items-center gap-2 max-[920px]:flex-col max-[920px]:items-stretch"> 79 + <AccountSwitchButton 80 + isActive={isActive()} 81 + switching={switching()} 82 + loggingOut={loggingOut()} 83 + account={props.account} 84 + onSwitch={props.onSwitch} /> 85 + <LogoutButton 86 + isSwitching={switching()} 87 + isLoggingOut={loggingOut()} 88 + did={props.account.did} 89 + onLogout={props.onLogout} /> 90 + </div> 91 + </Motion.div> 92 + ); 93 + }
+1 -1
src/components/AccountSwitcherIdentity.tsx
··· 12 12 <AvatarBadge label={props.label} tone={props.tone} /> 13 13 <div class="grid"> 14 14 <span class="text-[0.92rem] font-semibold">{props.name}</span> 15 - <span class="text-[0.78rem] text-on-surface-variant">{props.meta}</span> 15 + <span class="text-xs text-on-surface-variant">{props.meta}</span> 16 16 </div> 17 17 </Motion.div> 18 18 );
+1 -1
src/components/AccountSwitcherRow.tsx
··· 26 26 tone={props.account.active ? "primary" : "muted"} /> 27 27 <span class="grid"> 28 28 <span class="text-[0.92rem] font-semibold">{props.account.handle || props.account.did}</span> 29 - <span class="text-[0.78rem] text-on-surface-variant">{props.account.pdsUrl || "PDS unavailable"}</span> 29 + <span class="text-xs text-on-surface-variant">{props.account.pdsUrl || "PDS unavailable"}</span> 30 30 </span> 31 31 </button> 32 32 <button
+82
src/components/LoginPanel.tsx
··· 1 + import { createEffect, Show } from "solid-js"; 2 + import { Motion } from "solid-motionone"; 3 + import { Icon } from "./shared/Icon"; 4 + 5 + function LoginSubmitButton(props: { pending: boolean }) { 6 + return ( 7 + <button 8 + class="pill-action border-0 bg-[linear-gradient(135deg,var(--primary)_0%,var(--primary-dim)_100%)] text-on-primary-fixed" 9 + type="submit" 10 + disabled={props.pending}> 11 + <Show 12 + when={props.pending} 13 + fallback={ 14 + <> 15 + <Icon kind="ext-link" name="ext-link" aria-hidden="true" class="mr-1" /> 16 + <span>Continue</span> 17 + </> 18 + }> 19 + <> 20 + <Icon kind="loader" name="loader" aria-hidden="true" class="mr-1" /> 21 + <span>Opening sign-in...</span> 22 + </> 23 + </Show> 24 + </button> 25 + ); 26 + } 27 + 28 + type LoginPanelProps = { 29 + value: string; 30 + pending: boolean; 31 + shakeCount: number; 32 + onInput: (value: string) => void; 33 + onSubmit: () => void; 34 + }; 35 + 36 + export function LoginPanel(props: LoginPanelProps) { 37 + let input: HTMLInputElement | undefined; 38 + 39 + createEffect(() => { 40 + if (props.shakeCount > 0) { 41 + input?.focus(); 42 + input?.select(); 43 + } 44 + }); 45 + 46 + return ( 47 + <article class="panel-surface grid gap-6 p-6"> 48 + <div class="flex items-baseline justify-between gap-3"> 49 + <p class="overline-copy text-[0.75rem] text-on-surface-variant">Add account</p> 50 + <p class="m-0 text-xs leading-[1.55] text-on-surface-variant">Enter the account you want to use.</p> 51 + </div> 52 + 53 + <Motion.form 54 + class="grid gap-4" 55 + initial={{ opacity: 0, y: 18 }} 56 + animate={{ opacity: 1, y: 0, x: props.shakeCount > 0 ? [0, -16, 10, -8, 0] : 0 }} 57 + transition={{ duration: props.shakeCount > 0 ? 0.42 : 0.24, easing: [0.22, 1, 0.36, 1] }} 58 + onSubmit={(event) => { 59 + event.preventDefault(); 60 + props.onSubmit(); 61 + }}> 62 + <label class="grid gap-[0.7rem]"> 63 + <span class="overline-copy text-[0.76rem] tracking-[0.08em] text-on-surface-variant"> 64 + Handle, DID, or URL 65 + </span> 66 + <input 67 + ref={(element) => { 68 + input = element; 69 + }} 70 + class="min-h-[3.4rem] w-full rounded-full border-0 bg-white/4 px-[1.15rem] text-on-surface shadow-[inset_0_0_0_1px_rgba(125,175,255,0.16)] focus:outline focus:outline-primary/50 focus:shadow-[inset_0_0_0_1px_rgba(125,175,255,0.35),0_0_28px_rgba(125,175,255,0.12)]" 71 + type="text" 72 + autocomplete="username" 73 + spellcheck={false} 74 + value={props.value} 75 + placeholder="alice.bsky.social" 76 + onInput={(event) => props.onInput(event.currentTarget.value)} /> 77 + </label> 78 + <LoginSubmitButton pending={props.pending} /> 79 + </Motion.form> 80 + </article> 81 + ); 82 + }
+1 -3
src/components/ReauthBanner.tsx
··· 12 12 transition={{ duration: 1.8, repeat: Number.POSITIVE_INFINITY, easing: "ease-in-out" }}> 13 13 <div class="grid gap-[0.2rem]"> 14 14 <p class="m-0 text-[0.95rem] font-semibold">Sign in again to reconnect this account.</p> 15 - <p class="m-0 text-[0.78rem] text-on-surface-variant"> 16 - We couldn&apos;t restore the last session automatically. 17 - </p> 15 + <p class="m-0 text-xs text-on-surface-variant">We couldn&apos;t restore the last session automatically.</p> 18 16 </div> 19 17 <button class="pill-action border-0 bg-white/8 text-on-surface" type="button" onClick={() => props.onReauth()}> 20 18 <Icon kind="refresh" name="refresh" aria-hidden="true" class="mr-1" />
+4 -4
src/components/Session.tsx
··· 1 + import { AccountSummary, ActiveSession } from "$/lib/types"; 1 2 import { createMemo, Show } from "solid-js"; 2 3 import { Presence } from "solid-motionone"; 3 - import { AccountSummary, ActiveSession } from "../lib/types"; 4 4 import { AvatarBadge } from "./AvatarBadge"; 5 5 import { ProfileSkeleton } from "./ProfileSkeleton"; 6 6 import { ReauthBanner } from "./ReauthBanner"; ··· 9 9 return ( 10 10 <div class="grid"> 11 11 <h2 class="m-0 text-[clamp(1.4rem,2vw,1.85rem)] leading-[1.08] tracking-[-0.03em]">Sign in to get started.</h2> 12 - <p class="m-0 text-[0.78rem] leading-[1.55] text-on-surface-variant"> 12 + <p class="m-0 text-xs leading-[1.55] text-on-surface-variant"> 13 13 Add a Bluesky account now. You can switch or add more later. 14 14 </p> 15 15 </div> ··· 22 22 <AvatarBadge label={props.session.handle} tone="primary" /> 23 23 <div class="grid"> 24 24 <h2 class="m-0 text-[clamp(1.3rem,2vw,1.7rem)] tracking-[-0.02em]">{props.session.handle}</h2> 25 - <p class="m-0 text-[0.78rem] text-on-surface-variant">{props.session.did}</p> 25 + <p class="m-0 text-xs text-on-surface-variant">{props.session.did}</p> 26 26 </div> 27 27 <Show when={props.activeAccount}> 28 - {(account) => <p class="m-0 text-[0.78rem] text-on-surface-variant">{account().pdsUrl || "PDS unavailable"}</p>} 28 + {(account) => <p class="m-0 text-xs text-on-surface-variant">{account().pdsUrl || "PDS unavailable"}</p>} 29 29 </Show> 30 30 </div> 31 31 );
+331
src/components/feeds/FeedComposer.tsx
··· 1 + import { Icon } from "$/components/shared/Icon"; 2 + import { getDisplayName, getPostText } from "$/lib/feeds"; 3 + import type { PostView } from "$/lib/types"; 4 + import { createMemo, For, Show } from "solid-js"; 5 + import { Motion, Presence } from "solid-motionone"; 6 + 7 + type ComposerSuggestion = { label: string; type: "handle" | "hashtag" }; 8 + 9 + type FeedComposerProps = { 10 + activeHandle: string | null; 11 + open: boolean; 12 + pending: boolean; 13 + quoteTarget: PostView | null; 14 + replyTarget: PostView | null; 15 + suggestions: ComposerSuggestion[]; 16 + text: string; 17 + onApplySuggestion: (value: string) => void; 18 + onClearQuote: () => void; 19 + onClearReply: () => void; 20 + onClose: () => void; 21 + onSubmit: () => void; 22 + onTextChange: (value: string) => void; 23 + }; 24 + 25 + export function FeedComposer(props: FeedComposerProps) { 26 + const count = createMemo(() => [...props.text].length); 27 + const progress = createMemo(() => Math.min(100, (count() / 300) * 100)); 28 + 29 + return ( 30 + <Presence> 31 + <Show when={props.open}> 32 + <div class="fixed inset-0 z-50"> 33 + <Motion.button 34 + class="absolute inset-0 h-full w-full border-0 bg-black/80 backdrop-blur-[20px]" 35 + initial={{ opacity: 0 }} 36 + animate={{ opacity: 1 }} 37 + exit={{ opacity: 0 }} 38 + transition={{ duration: 0.18 }} 39 + type="button" 40 + onClick={() => props.onClose()} /> 41 + 42 + <ComposerPanel count={count()} progress={progress()} {...props} /> 43 + </div> 44 + </Show> 45 + </Presence> 46 + ); 47 + } 48 + 49 + function ComposerPanel(props: FeedComposerProps & { count: number; progress: number }) { 50 + return ( 51 + <div class="relative z-10 flex min-h-screen items-end justify-center p-4 pt-16"> 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)]" 54 + initial={{ opacity: 0, y: 36 }} 55 + animate={{ opacity: 1, y: 0 }} 56 + exit={{ opacity: 0, y: 30 }} 57 + transition={{ duration: 0.24, easing: [0.22, 1, 0.36, 1] }}> 58 + <ComposerHeader 59 + activeHandle={props.activeHandle} 60 + pending={props.pending} 61 + quoteTarget={props.quoteTarget} 62 + text={props.text} 63 + onClose={props.onClose} 64 + onSubmit={props.onSubmit} /> 65 + <ComposerBody 66 + activeHandle={props.activeHandle} 67 + quoteTarget={props.quoteTarget} 68 + replyTarget={props.replyTarget} 69 + suggestions={props.suggestions} 70 + text={props.text} 71 + onApplySuggestion={props.onApplySuggestion} 72 + onClearQuote={props.onClearQuote} 73 + onClearReply={props.onClearReply} 74 + onTextChange={props.onTextChange} /> 75 + <ComposerFooter count={props.count} progress={props.progress} /> 76 + </Motion.section> 77 + </div> 78 + ); 79 + } 80 + 81 + function ComposerHeader( 82 + props: { 83 + activeHandle: string | null; 84 + pending: boolean; 85 + quoteTarget: PostView | null; 86 + text: string; 87 + onClose: () => void; 88 + onSubmit: () => void; 89 + }, 90 + ) { 91 + return ( 92 + <header class="flex items-center justify-between border-b border-white/5 px-6 py-4"> 93 + <div class="flex items-center gap-3"> 94 + <button 95 + class="inline-flex h-10 w-10 items-center justify-center rounded-xl border-0 bg-transparent text-on-surface-variant transition duration-150 ease-out hover:bg-white/5 hover:text-on-surface" 96 + type="button" 97 + onClick={() => props.onClose()}> 98 + <Icon aria-hidden="true" iconClass="i-ri-close-line" /> 99 + </button> 100 + <ComposerTitle activeHandle={props.activeHandle} /> 101 + </div> 102 + <ComposerSubmitButton 103 + disabled={props.pending || (!props.text.trim() && !props.quoteTarget)} 104 + pending={props.pending} 105 + onSubmit={props.onSubmit} /> 106 + </header> 107 + ); 108 + } 109 + 110 + function ComposerTitle(props: { activeHandle: string | null }) { 111 + return ( 112 + <div> 113 + <p class="m-0 text-[0.95rem] font-semibold text-on-surface">New Post</p> 114 + <Show when={props.activeHandle}> 115 + {(handle) => <p class="m-0 text-[0.76rem] text-on-surface-variant">@{handle().replace(/^@/, "")}</p>} 116 + </Show> 117 + </div> 118 + ); 119 + } 120 + 121 + function ComposerSubmitButton(props: { disabled: boolean; pending: boolean; onSubmit: () => void }) { 122 + return ( 123 + <button 124 + class="inline-flex min-h-11 items-center justify-center gap-2 rounded-full border-0 bg-[linear-gradient(135deg,var(--primary)_0%,var(--primary-dim)_100%)] px-5 text-sm font-semibold text-on-primary-fixed transition duration-150 ease-out hover:-translate-y-px disabled:cursor-not-allowed disabled:opacity-60" 125 + type="button" 126 + disabled={props.disabled} 127 + onClick={() => props.onSubmit()}> 128 + <Icon aria-hidden="true" iconClass={props.pending ? "i-ri-loader-4-line" : "i-ri-send-plane-2-line"} /> 129 + <span>{props.pending ? "Posting..." : "Post"}</span> 130 + </button> 131 + ); 132 + } 133 + 134 + function ComposerBody( 135 + props: { 136 + activeHandle: string | null; 137 + quoteTarget: PostView | null; 138 + replyTarget: PostView | null; 139 + suggestions: ComposerSuggestion[]; 140 + text: string; 141 + onApplySuggestion: (value: string) => void; 142 + onClearQuote: () => void; 143 + onClearReply: () => void; 144 + onTextChange: (value: string) => void; 145 + }, 146 + ) { 147 + return ( 148 + <div class="p-6"> 149 + <div class="flex gap-4"> 150 + <ComposerAvatar activeHandle={props.activeHandle} /> 151 + <div class="min-w-0 flex-1"> 152 + <ComposerContexts 153 + quoteTarget={props.quoteTarget} 154 + replyTarget={props.replyTarget} 155 + onClearQuote={props.onClearQuote} 156 + onClearReply={props.onClearReply} /> 157 + <ComposerTextarea text={props.text} onTextChange={props.onTextChange} /> 158 + <QuotePreview post={props.quoteTarget} /> 159 + <SuggestionPanel suggestions={props.suggestions} onApplySuggestion={props.onApplySuggestion} /> 160 + </div> 161 + </div> 162 + </div> 163 + ); 164 + } 165 + 166 + function ComposerAvatar(props: { activeHandle: string | null }) { 167 + return ( 168 + <div class="mt-1 flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-[linear-gradient(135deg,rgba(125,175,255,0.95),rgba(0,115,222,0.75))] text-sm font-semibold text-on-primary-fixed"> 169 + {(props.activeHandle ?? "L").slice(0, 1).toUpperCase()} 170 + </div> 171 + ); 172 + } 173 + 174 + function ComposerContexts( 175 + props: { 176 + quoteTarget: PostView | null; 177 + replyTarget: PostView | null; 178 + onClearQuote: () => void; 179 + onClearReply: () => void; 180 + }, 181 + ) { 182 + return ( 183 + <> 184 + <Show when={props.replyTarget}> 185 + {(post) => ( 186 + <ContextChip 187 + icon="i-ri-reply-line" 188 + label={`Replying to ${getDisplayName(post().author)}`} 189 + onClear={props.onClearReply} /> 190 + )} 191 + </Show> 192 + <Show when={props.quoteTarget}> 193 + {(post) => ( 194 + <ContextChip 195 + icon="i-ri-chat-quote-line" 196 + label={`Quoting ${getDisplayName(post().author)}`} 197 + onClear={props.onClearQuote} /> 198 + )} 199 + </Show> 200 + </> 201 + ); 202 + } 203 + 204 + function ComposerTextarea(props: { text: string; onTextChange: (value: string) => void }) { 205 + return ( 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" 208 + placeholder="What's happening?" 209 + value={props.text} 210 + onInput={(event) => props.onTextChange(event.currentTarget.value)} /> 211 + ); 212 + } 213 + 214 + function QuotePreview(props: { post: PostView | null }) { 215 + return ( 216 + <Show when={props.post}> 217 + {(post) => ( 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"> 221 + {getDisplayName(post().author)} 222 + <span class="ml-1 text-xs font-normal text-on-surface-variant"> 223 + @{post().author.handle.replace(/^@/, "")} 224 + </span> 225 + </p> 226 + <p class="mt-2 line-clamp-4 text-sm leading-[1.55] text-on-secondary-container"> 227 + {getPostText(post()) || "Quoted post"} 228 + </p> 229 + </div> 230 + )} 231 + </Show> 232 + ); 233 + } 234 + 235 + function SuggestionPanel(props: { suggestions: ComposerSuggestion[]; onApplySuggestion: (value: string) => void }) { 236 + return ( 237 + <Show when={props.suggestions.length > 0}> 238 + <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> 244 + </div> 245 + </div> 246 + </Show> 247 + ); 248 + } 249 + 250 + function SuggestionChip(props: { suggestion: ComposerSuggestion; onApplySuggestion: (value: string) => void }) { 251 + return ( 252 + <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" 254 + type="button" 255 + 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> 258 + </button> 259 + ); 260 + } 261 + 262 + function ComposerFooter(props: { count: number; progress: number }) { 263 + return ( 264 + <footer class="flex items-center justify-between border-t border-white/5 px-6 py-4"> 265 + <ComposerToolbar /> 266 + <ComposerCounter count={props.count} progress={props.progress} /> 267 + </footer> 268 + ); 269 + } 270 + 271 + function ComposerToolbar() { 272 + return ( 273 + <div class="flex items-center gap-2 text-on-surface-variant"> 274 + <ToolbarButton icon="i-ri-at-line" label="Mentions" /> 275 + <ToolbarButton icon="i-ri-hashtag" label="Hashtags" /> 276 + <ToolbarButton icon="i-ri-chat-quote-line" label="Quote" /> 277 + </div> 278 + ); 279 + } 280 + 281 + function ComposerCounter(props: { count: number; progress: number }) { 282 + return ( 283 + <div class="flex items-center gap-3"> 284 + <div class="relative h-9 w-9"> 285 + <svg class="h-full w-full -rotate-90" viewBox="0 0 36 36"> 286 + <path 287 + d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" 288 + fill="none" 289 + stroke="rgba(255,255,255,0.12)" 290 + stroke-width="3" /> 291 + <path 292 + d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" 293 + fill="none" 294 + stroke="var(--primary)" 295 + stroke-dasharray={`${props.progress}, 100`} 296 + stroke-width="3" /> 297 + </svg> 298 + <span class="absolute inset-0 flex items-center justify-center text-[0.68rem] font-semibold text-on-surface"> 299 + {props.count} 300 + </span> 301 + </div> 302 + <span class="text-xs text-on-surface-variant">/ 300</span> 303 + </div> 304 + ); 305 + } 306 + 307 + function ContextChip(props: { icon: string; label: string; onClear: () => void }) { 308 + return ( 309 + <div class="mb-3 inline-flex max-w-full items-center gap-2 rounded-full bg-white/6 px-3 py-2 text-[0.8rem] text-on-surface"> 310 + <Icon aria-hidden="true" iconClass={props.icon} /> 311 + <span class="truncate">{props.label}</span> 312 + <button 313 + 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 + type="button" 315 + onClick={() => props.onClear()}> 316 + <Icon aria-hidden="true" iconClass="i-ri-close-line" /> 317 + </button> 318 + </div> 319 + ); 320 + } 321 + 322 + function ToolbarButton(props: { icon: string; label: string }) { 323 + return ( 324 + <button 325 + class="inline-flex h-11 w-11 items-center justify-center rounded-xl border-0 bg-transparent transition duration-150 ease-out hover:bg-white/5 hover:text-primary" 326 + type="button" 327 + title={props.label}> 328 + <Icon aria-hidden="true" iconClass={props.icon} /> 329 + </button> 330 + ); 331 + }
+1343
src/components/feeds/FeedWorkspace.tsx
··· 1 + import { Icon } from "$/components/shared/Icon"; 2 + import { 3 + extractHandles, 4 + extractHashtags, 5 + getFeedName, 6 + isQuoteEmbed, 7 + isReplyItem, 8 + isRepostReason, 9 + patchFeedItems, 10 + patchThreadNode, 11 + toStrongRef, 12 + } from "$/lib/feeds"; 13 + import type { 14 + ActiveSession, 15 + CreateRecordResult, 16 + EmbedInput, 17 + FeedGeneratorView, 18 + FeedResponse, 19 + FeedViewPost, 20 + FeedViewPrefItem, 21 + PostView, 22 + ReplyRefInput, 23 + SavedFeedItem, 24 + ThreadNode, 25 + ThreadResponse, 26 + UserPreferences, 27 + } from "$/lib/types"; 28 + import { invoke } from "@tauri-apps/api/core"; 29 + import { createEffect, createMemo, For, type JSX, onCleanup, onMount, Show } from "solid-js"; 30 + import { createStore, reconcile } from "solid-js/store"; 31 + import { Motion, Presence } from "solid-motionone"; 32 + import { FeedComposer } from "./FeedComposer"; 33 + import { PostCard } from "./PostCard"; 34 + import { ThreadPanel } from "./ThreadPanel"; 35 + 36 + type FeedWorkspaceProps = { activeSession: ActiveSession; onError: (message: string) => void }; 37 + 38 + type FeedState = { 39 + cursor: string | null; 40 + error: string | null; 41 + items: FeedViewPost[]; 42 + loading: boolean; 43 + loadingMore: boolean; 44 + scrollTop: number; 45 + }; 46 + 47 + type FeedWorkspaceState = { 48 + activeFeedId: string | null; 49 + composer: { 50 + open: boolean; 51 + pending: boolean; 52 + quoteTarget: PostView | null; 53 + replyRoot: PostView | null; 54 + replyTarget: PostView | null; 55 + text: string; 56 + }; 57 + feedStates: Record<string, FeedState>; 58 + focusedIndex: number; 59 + generators: Record<string, FeedGeneratorView>; 60 + likePendingByUri: Record<string, boolean>; 61 + likePulseUri: string | null; 62 + localPrefs: Record<string, FeedViewPrefItem>; 63 + preferences: UserPreferences | null; 64 + repostPendingByUri: Record<string, boolean>; 65 + repostPulseUri: string | null; 66 + showFeedsDrawer: boolean; 67 + thread: { data: ThreadNode | null; error: string | null; loading: boolean; uri: string | null }; 68 + }; 69 + 70 + const DEFAULT_LIMIT = 30; 71 + 72 + const DEFAULT_TIMELINE: SavedFeedItem = { id: "following", type: "timeline", value: "following", pinned: true }; 73 + 74 + function createDefaultFeedState(): FeedState { 75 + return { cursor: null, error: null, items: [], loading: false, loadingMore: false, scrollTop: 0 }; 76 + } 77 + 78 + function createDefaultFeedPref(feed: SavedFeedItem): FeedViewPrefItem { 79 + return { 80 + feed: feed.value, 81 + hideQuotePosts: false, 82 + hideReplies: false, 83 + hideRepliesByLikeCount: null, 84 + hideRepliesByUnfollowed: false, 85 + hideReposts: false, 86 + }; 87 + } 88 + 89 + function createInitialWorkspaceState(): FeedWorkspaceState { 90 + return { 91 + activeFeedId: null, 92 + composer: { open: false, pending: false, quoteTarget: null, replyRoot: null, replyTarget: null, text: "" }, 93 + feedStates: {}, 94 + focusedIndex: 0, 95 + generators: {}, 96 + likePendingByUri: {}, 97 + likePulseUri: null, 98 + localPrefs: {}, 99 + preferences: null, 100 + repostPendingByUri: {}, 101 + repostPulseUri: null, 102 + showFeedsDrawer: false, 103 + thread: { data: null, error: null, loading: false, uri: null }, 104 + }; 105 + } 106 + 107 + export function FeedWorkspace(props: FeedWorkspaceProps) { 108 + const [workspace, setWorkspace] = createStore<FeedWorkspaceState>(createInitialWorkspaceState()); 109 + 110 + let scroller: HTMLDivElement | undefined; 111 + let sentinel: HTMLDivElement | undefined; 112 + const postRefs = new Map<string, HTMLElement>(); 113 + 114 + const savedFeeds = createMemo(() => { 115 + const stored = workspace.preferences?.savedFeeds ?? []; 116 + return stored.length > 0 ? stored : [DEFAULT_TIMELINE]; 117 + }); 118 + const pinnedFeeds = createMemo(() => { 119 + const pinned = savedFeeds().filter((feed) => feed.pinned); 120 + return pinned.length > 0 ? pinned : [DEFAULT_TIMELINE]; 121 + }); 122 + const drawerFeeds = createMemo(() => savedFeeds().filter((feed) => !feed.pinned)); 123 + const activeFeed = createMemo(() => { 124 + const feedId = workspace.activeFeedId; 125 + return savedFeeds().find((feed) => feed.id === feedId) ?? pinnedFeeds()[0] ?? DEFAULT_TIMELINE; 126 + }); 127 + const activePref = createMemo(() => { 128 + const feed = activeFeed(); 129 + return workspace.localPrefs[feed.value] ?? createDefaultFeedPref(feed); 130 + }); 131 + const activeFeedState = createMemo(() => workspace.feedStates[activeFeed().id]); 132 + const visibleItems = createMemo(() => applyFeedPreferences(activeFeedState()?.items ?? [], activePref())); 133 + const composerToken = createMemo(() => { 134 + const match = /(^|\s)([@#][^\s@#]*)$/u.exec(workspace.composer.text); 135 + return match?.[2] ?? null; 136 + }); 137 + const composerSuggestions = createMemo(() => { 138 + const token = composerToken(); 139 + if (!token) { 140 + return []; 141 + } 142 + 143 + const posts = visibleItems().map((item) => item.post); 144 + if (token.startsWith("@")) { 145 + return extractHandles(posts, props.activeSession.handle).filter((handle) => 146 + handle.toLowerCase().startsWith(token.toLowerCase()) 147 + ).map((label) => ({ label, type: "handle" as const })); 148 + } 149 + 150 + return extractHashtags(posts).filter((tag) => tag.toLowerCase().startsWith(token.toLowerCase())).map((label) => ({ 151 + label, 152 + type: "hashtag" as const, 153 + })); 154 + }); 155 + 156 + createEffect(() => { 157 + void bootstrapFeeds(); 158 + }); 159 + 160 + createEffect(() => { 161 + const feed = activeFeed(); 162 + if (!feed) { 163 + return; 164 + } 165 + 166 + if (workspace.activeFeedId !== feed.id) { 167 + setWorkspace("activeFeedId", feed.id); 168 + } 169 + 170 + void ensureFeedLoaded(feed); 171 + const nextScrollTop = workspace.feedStates[feed.id]?.scrollTop ?? 0; 172 + queueMicrotask(() => { 173 + if (scroller) { 174 + scroller.scrollTop = nextScrollTop; 175 + } 176 + }); 177 + }); 178 + 179 + createEffect(() => { 180 + const items = visibleItems(); 181 + if (items.length === 0) { 182 + setWorkspace("focusedIndex", 0); 183 + return; 184 + } 185 + 186 + setWorkspace("focusedIndex", (current) => Math.min(current, items.length - 1)); 187 + }); 188 + 189 + createEffect(() => { 190 + const item = visibleItems()[workspace.focusedIndex]; 191 + if (!item) { 192 + return; 193 + } 194 + 195 + queueMicrotask(() => { 196 + const element = postRefs.get(item.post.uri); 197 + element?.focus(); 198 + element?.scrollIntoView({ block: "nearest" }); 199 + }); 200 + }); 201 + 202 + createEffect(() => { 203 + const root = scroller; 204 + const currentSentinel = sentinel; 205 + const feed = activeFeed(); 206 + if (!root || !currentSentinel || !feed) { 207 + return; 208 + } 209 + 210 + const observer = new IntersectionObserver((entries) => { 211 + const entry = entries[0]; 212 + if (!entry?.isIntersecting) { 213 + return; 214 + } 215 + 216 + const state = workspace.feedStates[feed.id]; 217 + if (state?.cursor && !state.loading && !state.loadingMore) { 218 + void loadFeed(feed, true); 219 + } 220 + }, { root, threshold: 0.15 }); 221 + 222 + observer.observe(currentSentinel); 223 + onCleanup(() => observer.disconnect()); 224 + }); 225 + 226 + function handleGlobalKeydown(event: KeyboardEvent) { 227 + if (shouldIgnoreKey(event)) { 228 + return; 229 + } 230 + 231 + const tabs = pinnedFeeds(); 232 + if (/^[1-9]$/.test(event.key)) { 233 + const index = Number(event.key) - 1; 234 + const target = tabs[index]; 235 + if (target) { 236 + event.preventDefault(); 237 + switchFeed(target.id); 238 + } 239 + return; 240 + } 241 + 242 + if (event.key === "n") { 243 + event.preventDefault(); 244 + openComposer(); 245 + return; 246 + } 247 + 248 + const items = visibleItems(); 249 + if (items.length === 0) { 250 + return; 251 + } 252 + 253 + if (event.key === "j" || event.key === "k") { 254 + event.preventDefault(); 255 + setWorkspace("focusedIndex", (current) => { 256 + if (event.key === "j") { 257 + return Math.min(current + 1, items.length - 1); 258 + } 259 + 260 + return Math.max(current - 1, 0); 261 + }); 262 + return; 263 + } 264 + 265 + const item = items[workspace.focusedIndex]; 266 + if (!item) { 267 + return; 268 + } 269 + 270 + switch (event.key) { 271 + case "l": { 272 + event.preventDefault(); 273 + void toggleLike(item.post); 274 + break; 275 + } 276 + case "r": { 277 + event.preventDefault(); 278 + openReplyComposer(item.post, getReplyRootPost(item)); 279 + break; 280 + } 281 + case "t": { 282 + event.preventDefault(); 283 + void toggleRepost(item.post); 284 + break; 285 + } 286 + case "o": 287 + case "Enter": { 288 + event.preventDefault(); 289 + void openThread(item.post.uri); 290 + break; 291 + } 292 + default: { 293 + break; 294 + } 295 + } 296 + } 297 + 298 + onMount(() => { 299 + globalThis.addEventListener("keydown", handleGlobalKeydown); 300 + onCleanup(() => globalThis.removeEventListener("keydown", handleGlobalKeydown)); 301 + }); 302 + 303 + async function bootstrapFeeds() { 304 + const currentDid = props.activeSession.did; 305 + setWorkspace(reconcile(createInitialWorkspaceState())); 306 + 307 + try { 308 + const nextPreferences = await invoke<UserPreferences>("get_preferences"); 309 + if (currentDid !== props.activeSession.did) { 310 + return; 311 + } 312 + 313 + setWorkspace("preferences", nextPreferences); 314 + setWorkspace( 315 + "localPrefs", 316 + reconcile(Object.fromEntries(nextPreferences.feedViewPrefs.map((pref) => [pref.feed, pref]))), 317 + ); 318 + 319 + const uris = nextPreferences.savedFeeds.filter((feed) => feed.type === "feed").map((feed) => feed.value); 320 + if (uris.length > 0) { 321 + const hydrated = await invoke<{ feeds: FeedGeneratorView[] }>("get_feed_generators", { uris }); 322 + setWorkspace( 323 + "generators", 324 + reconcile(Object.fromEntries(hydrated.feeds.map((generator) => [generator.uri, generator]))), 325 + ); 326 + } 327 + 328 + const nextActive = nextPreferences.savedFeeds.find((feed) => feed.pinned) ?? nextPreferences.savedFeeds[0] 329 + ?? DEFAULT_TIMELINE; 330 + setWorkspace("activeFeedId", nextActive.id); 331 + } catch (error) { 332 + props.onError(`Failed to load feeds: ${String(error)}`); 333 + } 334 + } 335 + 336 + async function ensureFeedLoaded(feed: SavedFeedItem) { 337 + const state = workspace.feedStates[feed.id]; 338 + if (state?.loading || state?.loadingMore || state?.items.length) { 339 + return; 340 + } 341 + 342 + await loadFeed(feed, false); 343 + } 344 + 345 + async function loadFeed(feed: SavedFeedItem, append: boolean) { 346 + const state = workspace.feedStates[feed.id] ?? createDefaultFeedState(); 347 + 348 + if (append) { 349 + setWorkspace("feedStates", feed.id, { ...state, error: null, loadingMore: true }); 350 + } else { 351 + setWorkspace("feedStates", feed.id, { ...state, error: null, loading: true }); 352 + } 353 + 354 + try { 355 + const command = getFeedCommand(feed); 356 + const payload = await invoke<FeedResponse>(command.name, command.args(state.cursor, DEFAULT_LIMIT)); 357 + const items = append ? [...state.items, ...payload.feed] : payload.feed; 358 + setWorkspace("feedStates", feed.id, { 359 + cursor: payload.cursor ?? null, 360 + error: null, 361 + items, 362 + loading: false, 363 + loadingMore: false, 364 + scrollTop: append ? state.scrollTop : 0, 365 + }); 366 + } catch (error) { 367 + setWorkspace("feedStates", feed.id, { ...state, error: String(error), loading: false, loadingMore: false }); 368 + props.onError( 369 + `Failed to load ${getFeedName(feed, workspace.generators[feed.value]?.displayName)}: ${String(error)}`, 370 + ); 371 + } 372 + } 373 + 374 + function switchFeed(feedId: string) { 375 + const current = activeFeed(); 376 + if (current && scroller) { 377 + setWorkspace("feedStates", current.id, { 378 + ...(workspace.feedStates[current.id] ?? createDefaultFeedState()), 379 + scrollTop: scroller.scrollTop, 380 + }); 381 + } 382 + 383 + setWorkspace("activeFeedId", feedId); 384 + setWorkspace("focusedIndex", 0); 385 + setWorkspace("showFeedsDrawer", false); 386 + } 387 + 388 + async function openThread(uri: string) { 389 + setWorkspace("thread", "uri", uri); 390 + setWorkspace("thread", "loading", true); 391 + setWorkspace("thread", "error", null); 392 + 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 + } 402 + } 403 + 404 + function openComposer() { 405 + setWorkspace("composer", "open", true); 406 + } 407 + 408 + function resetComposer() { 409 + setWorkspace( 410 + "composer", 411 + (current) => ({ ...current, open: false, quoteTarget: null, replyRoot: null, replyTarget: null, text: "" }), 412 + ); 413 + } 414 + 415 + function openReplyComposer(post: PostView, root: PostView) { 416 + setWorkspace( 417 + "composer", 418 + (current) => ({ ...current, open: true, quoteTarget: null, replyRoot: root, replyTarget: post }), 419 + ); 420 + } 421 + 422 + function openQuoteComposer(post: PostView) { 423 + setWorkspace( 424 + "composer", 425 + (current) => ({ ...current, open: true, quoteTarget: post, replyRoot: null, replyTarget: null }), 426 + ); 427 + } 428 + 429 + function applySuggestion(value: string) { 430 + const token = composerToken(); 431 + if (!token) { 432 + return; 433 + } 434 + 435 + setWorkspace( 436 + "composer", 437 + "text", 438 + (current) => current.replace(new RegExp(`${escapeForRegex(token)}$`, "u"), `${value} `), 439 + ); 440 + } 441 + 442 + async function submitPost() { 443 + const text = workspace.composer.text; 444 + const reply = workspace.composer.replyTarget; 445 + const root = workspace.composer.replyRoot; 446 + const quote = workspace.composer.quoteTarget; 447 + 448 + const replyTo: ReplyRefInput | null = reply && root 449 + ? { parent: toStrongRef(reply), root: toStrongRef(root) } 450 + : null; 451 + const embed: EmbedInput | null = quote ? { type: "record", record: toStrongRef(quote) } : null; 452 + 453 + setWorkspace("composer", "pending", true); 454 + try { 455 + await invoke<CreateRecordResult>("create_post", { embed, replyTo, text }); 456 + resetComposer(); 457 + setWorkspace("thread", "uri", null); 458 + setWorkspace("thread", "data", null); 459 + await loadFeed(activeFeed(), false); 460 + if (scroller) { 461 + scroller.scrollTop = 0; 462 + } 463 + } catch (error) { 464 + props.onError(`Failed to create post: ${String(error)}`); 465 + } finally { 466 + setWorkspace("composer", "pending", false); 467 + } 468 + } 469 + 470 + async function toggleLike(post: PostView) { 471 + setWorkspace("likePendingByUri", post.uri, true); 472 + try { 473 + if (post.viewer?.like) { 474 + await invoke("unlike_post", { likeUri: post.viewer.like }); 475 + patchPost( 476 + post.uri, 477 + (current) => ({ 478 + ...current, 479 + likeCount: Math.max(0, (current.likeCount ?? 0) - 1), 480 + viewer: { ...current.viewer, like: null }, 481 + }), 482 + ); 483 + } else { 484 + const result = await invoke<CreateRecordResult>("like_post", { cid: post.cid, uri: post.uri }); 485 + patchPost( 486 + post.uri, 487 + (current) => ({ 488 + ...current, 489 + likeCount: (current.likeCount ?? 0) + 1, 490 + viewer: { ...current.viewer, like: result.uri }, 491 + }), 492 + ); 493 + triggerLikePulse(post.uri); 494 + } 495 + } catch (error) { 496 + props.onError(`Failed to update like: ${String(error)}`); 497 + } finally { 498 + setWorkspace("likePendingByUri", post.uri, false); 499 + } 500 + } 501 + 502 + async function toggleRepost(post: PostView) { 503 + setWorkspace("repostPendingByUri", post.uri, true); 504 + try { 505 + if (post.viewer?.repost) { 506 + await invoke("unrepost", { repostUri: post.viewer.repost }); 507 + patchPost( 508 + post.uri, 509 + (current) => ({ 510 + ...current, 511 + repostCount: Math.max(0, (current.repostCount ?? 0) - 1), 512 + viewer: { ...current.viewer, repost: null }, 513 + }), 514 + ); 515 + } else { 516 + const result = await invoke<CreateRecordResult>("repost", { cid: post.cid, uri: post.uri }); 517 + patchPost( 518 + post.uri, 519 + (current) => ({ 520 + ...current, 521 + repostCount: (current.repostCount ?? 0) + 1, 522 + viewer: { ...current.viewer, repost: result.uri }, 523 + }), 524 + ); 525 + triggerRepostPulse(post.uri); 526 + } 527 + } catch (error) { 528 + props.onError(`Failed to update repost: ${String(error)}`); 529 + } finally { 530 + setWorkspace("repostPendingByUri", post.uri, false); 531 + } 532 + } 533 + 534 + function patchPost(uri: string, updater: (post: PostView) => PostView) { 535 + for (const [feedId, state] of Object.entries(workspace.feedStates)) { 536 + if (!state) { 537 + continue; 538 + } 539 + 540 + setWorkspace("feedStates", feedId, "items", patchFeedItems(state.items, uri, updater)); 541 + } 542 + 543 + const currentThread = workspace.thread.data; 544 + if (currentThread) { 545 + setWorkspace("thread", "data", patchThreadNode(currentThread, uri, updater)); 546 + } 547 + } 548 + 549 + function triggerLikePulse(uri: string) { 550 + setWorkspace("likePulseUri", uri); 551 + globalThis.setTimeout(() => setWorkspace("likePulseUri", (current) => (current === uri ? null : current)), 320); 552 + } 553 + 554 + function triggerRepostPulse(uri: string) { 555 + setWorkspace("repostPulseUri", uri); 556 + globalThis.setTimeout(() => setWorkspace("repostPulseUri", (current) => (current === uri ? null : current)), 320); 557 + } 558 + 559 + return ( 560 + <> 561 + <div class="grid h-full gap-6 xl:grid-cols-[minmax(0,1fr)_20rem]"> 562 + <FeedPane 563 + activeFeed={activeFeed()} 564 + activeFeedState={activeFeedState()} 565 + activeHandle={props.activeSession.handle} 566 + focusedIndex={workspace.focusedIndex} 567 + generators={workspace.generators} 568 + likePendingByUri={workspace.likePendingByUri} 569 + likePulseUri={workspace.likePulseUri} 570 + onCompose={openComposer} 571 + onFeedSelect={switchFeed} 572 + onFocusIndex={(index) => setWorkspace("focusedIndex", index)} 573 + onLike={toggleLike} 574 + onOpenThread={openThread} 575 + onQuote={openQuoteComposer} 576 + onReply={openReplyComposer} 577 + onRepost={toggleRepost} 578 + onToggleDrawer={() => setWorkspace("showFeedsDrawer", (value) => !value)} 579 + pinnedFeeds={pinnedFeeds().slice(0, 9)} 580 + postRefs={postRefs} 581 + repostPendingByUri={workspace.repostPendingByUri} 582 + repostPulseUri={workspace.repostPulseUri} 583 + scrollerRef={(element) => { 584 + scroller = element; 585 + }} 586 + sentinelRef={(element) => { 587 + sentinel = element; 588 + }} 589 + setScrollTop={(top) => 590 + setWorkspace("feedStates", activeFeed().id, { 591 + ...(workspace.feedStates[activeFeed().id] ?? createDefaultFeedState()), 592 + scrollTop: top, 593 + })} 594 + visibleItems={visibleItems()} /> 595 + 596 + <WorkspaceSidebar 597 + activePref={activePref()} 598 + drawerFeeds={drawerFeeds()} 599 + generators={workspace.generators} 600 + onFeedSelect={switchFeed} 601 + onPrefChange={setFeedPref} /> 602 + </div> 603 + 604 + <SavedFeedsDrawer 605 + feeds={drawerFeeds()} 606 + generators={workspace.generators} 607 + open={workspace.showFeedsDrawer} 608 + onClose={() => setWorkspace("showFeedsDrawer", false)} 609 + onSelectFeed={switchFeed} /> 610 + 611 + <ThreadPanel 612 + activeUri={workspace.thread.uri} 613 + error={workspace.thread.error} 614 + loading={workspace.thread.loading} 615 + onClose={() => setWorkspace("thread", "uri", null)} 616 + onLike={(post) => void toggleLike(post)} 617 + onOpenThread={(uri) => void openThread(uri)} 618 + onQuote={(post) => openQuoteComposer(post)} 619 + onReply={(post, root) => openReplyComposer(post, root)} 620 + onRepost={(post) => void toggleRepost(post)} 621 + thread={workspace.thread.data} /> 622 + 623 + <FeedComposer 624 + activeHandle={props.activeSession.handle} 625 + open={workspace.composer.open} 626 + pending={workspace.composer.pending} 627 + quoteTarget={workspace.composer.quoteTarget} 628 + replyTarget={workspace.composer.replyTarget} 629 + suggestions={composerSuggestions()} 630 + text={workspace.composer.text} 631 + onApplySuggestion={applySuggestion} 632 + onClearQuote={() => setWorkspace("composer", "quoteTarget", null)} 633 + onClearReply={() => { 634 + setWorkspace("composer", "replyTarget", null); 635 + setWorkspace("composer", "replyRoot", null); 636 + }} 637 + onClose={() => resetComposer()} 638 + onSubmit={() => void submitPost()} 639 + onTextChange={(text) => setWorkspace("composer", "text", text)} /> 640 + </> 641 + ); 642 + 643 + function setFeedPref<K extends keyof FeedViewPrefItem>(key: K, value: FeedViewPrefItem[K]) { 644 + const feed = activeFeed(); 645 + setWorkspace("localPrefs", feed.value, { ...activePref(), [key]: value }); 646 + } 647 + } 648 + 649 + function FeedPane( 650 + props: { 651 + activeFeed: SavedFeedItem; 652 + activeFeedState: FeedState | undefined; 653 + activeHandle: string; 654 + focusedIndex: number; 655 + generators: Record<string, FeedGeneratorView>; 656 + likePendingByUri: Record<string, boolean>; 657 + likePulseUri: string | null; 658 + onCompose: () => void; 659 + onFeedSelect: (feedId: string) => void; 660 + onFocusIndex: (index: number) => void; 661 + onLike: (post: PostView) => Promise<void>; 662 + onOpenThread: (uri: string) => Promise<void>; 663 + onQuote: (post: PostView) => void; 664 + onReply: (post: PostView, root: PostView) => void; 665 + onRepost: (post: PostView) => Promise<void>; 666 + onToggleDrawer: () => void; 667 + pinnedFeeds: SavedFeedItem[]; 668 + postRefs: Map<string, HTMLElement>; 669 + repostPendingByUri: Record<string, boolean>; 670 + repostPulseUri: string | null; 671 + scrollerRef: (element: HTMLDivElement) => void; 672 + sentinelRef: (element: HTMLDivElement) => void; 673 + setScrollTop: (top: number) => void; 674 + visibleItems: FeedViewPost[]; 675 + }, 676 + ) { 677 + 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)]"> 679 + <FeedPaneHeader 680 + activeFeed={props.activeFeed} 681 + generators={props.generators} 682 + onCompose={props.onCompose} 683 + onFeedSelect={props.onFeedSelect} 684 + onToggleDrawer={props.onToggleDrawer} 685 + pinnedFeeds={props.pinnedFeeds} /> 686 + <FeedScroller 687 + activeFeedState={props.activeFeedState} 688 + activeHandle={props.activeHandle} 689 + focusedIndex={props.focusedIndex} 690 + generators={props.generators} 691 + likePendingByUri={props.likePendingByUri} 692 + likePulseUri={props.likePulseUri} 693 + onCompose={props.onCompose} 694 + onFocusIndex={props.onFocusIndex} 695 + onLike={props.onLike} 696 + onOpenThread={props.onOpenThread} 697 + onQuote={props.onQuote} 698 + onReply={props.onReply} 699 + onRepost={props.onRepost} 700 + postRefs={props.postRefs} 701 + repostPendingByUri={props.repostPendingByUri} 702 + repostPulseUri={props.repostPulseUri} 703 + scrollerRef={props.scrollerRef} 704 + sentinelRef={props.sentinelRef} 705 + setScrollTop={props.setScrollTop} 706 + visibleItems={props.visibleItems} /> 707 + </section> 708 + ); 709 + } 710 + 711 + function FeedPaneHeader( 712 + props: { 713 + activeFeed: SavedFeedItem; 714 + generators: Record<string, FeedGeneratorView>; 715 + onCompose: () => void; 716 + onFeedSelect: (feedId: string) => void; 717 + onToggleDrawer: () => void; 718 + pinnedFeeds: SavedFeedItem[]; 719 + }, 720 + ) { 721 + return ( 722 + <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)]"> 723 + <FeedPaneTitle 724 + activeFeed={props.activeFeed} 725 + generators={props.generators} 726 + onCompose={props.onCompose} 727 + onToggleDrawer={props.onToggleDrawer} /> 728 + <FeedTabBar 729 + activeFeedId={props.activeFeed.id} 730 + generators={props.generators} 731 + onFeedSelect={props.onFeedSelect} 732 + onToggleDrawer={props.onToggleDrawer} 733 + pinnedFeeds={props.pinnedFeeds} /> 734 + </header> 735 + ); 736 + } 737 + 738 + function FeedPaneTitle( 739 + props: { 740 + activeFeed: SavedFeedItem; 741 + generators: Record<string, FeedGeneratorView>; 742 + onCompose: () => void; 743 + onToggleDrawer: () => void; 744 + }, 745 + ) { 746 + 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"> 751 + {getFeedName(props.activeFeed, props.generators[props.activeFeed.value]?.displayName)} 752 + </p> 753 + </div> 754 + <FeedHeaderActions onCompose={props.onCompose} onToggleDrawer={props.onToggleDrawer} /> 755 + </div> 756 + ); 757 + } 758 + 759 + function FeedHeaderActions(props: { onCompose: () => void; onToggleDrawer: () => void }) { 760 + return ( 761 + <div class="flex items-center gap-2"> 762 + <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" 764 + type="button" 765 + onClick={() => props.onCompose()}> 766 + <Icon aria-hidden="true" iconClass="i-ri-quill-pen-line" /> 767 + <span>New post</span> 768 + </button> 769 + <button 770 + 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 + type="button" 772 + 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> 808 + </button> 809 + </div> 810 + ); 811 + } 812 + 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 + function FeedScroller( 838 + props: { 839 + activeFeedState: FeedState | undefined; 840 + activeHandle: string; 841 + focusedIndex: number; 842 + generators: Record<string, FeedGeneratorView>; 843 + likePendingByUri: Record<string, boolean>; 844 + likePulseUri: string | null; 845 + onCompose: () => void; 846 + onFocusIndex: (index: number) => void; 847 + onLike: (post: PostView) => Promise<void>; 848 + onOpenThread: (uri: string) => Promise<void>; 849 + onQuote: (post: PostView) => void; 850 + onReply: (post: PostView, root: PostView) => void; 851 + onRepost: (post: PostView) => Promise<void>; 852 + postRefs: Map<string, HTMLElement>; 853 + repostPendingByUri: Record<string, boolean>; 854 + repostPulseUri: string | null; 855 + scrollerRef: (element: HTMLDivElement) => void; 856 + sentinelRef: (element: HTMLDivElement) => void; 857 + setScrollTop: (top: number) => void; 858 + visibleItems: FeedViewPost[]; 859 + }, 860 + ) { 861 + return ( 862 + <div 863 + 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)]" 865 + onScroll={(event) => props.setScrollTop(event.currentTarget.scrollTop)}> 866 + <ComposerLauncher activeHandle={props.activeHandle} onCompose={props.onCompose} /> 867 + <FeedContent 868 + activeFeedState={props.activeFeedState} 869 + focusedIndex={props.focusedIndex} 870 + likePendingByUri={props.likePendingByUri} 871 + likePulseUri={props.likePulseUri} 872 + onFocusIndex={props.onFocusIndex} 873 + onLike={props.onLike} 874 + onOpenThread={props.onOpenThread} 875 + onQuote={props.onQuote} 876 + onReply={props.onReply} 877 + onRepost={props.onRepost} 878 + postRefs={props.postRefs} 879 + repostPendingByUri={props.repostPendingByUri} 880 + repostPulseUri={props.repostPulseUri} 881 + sentinelRef={props.sentinelRef} 882 + visibleItems={props.visibleItems} /> 883 + </div> 884 + ); 885 + } 886 + 887 + function ComposerLauncher(props: { activeHandle: string; onCompose: () => void }) { 888 + return ( 889 + <button 890 + 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" 891 + type="button" 892 + onClick={() => props.onCompose()}> 893 + <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"> 894 + {props.activeHandle.slice(0, 1).toUpperCase()} 895 + </div> 896 + <div class="min-w-0 flex-1"> 897 + <p class="m-0 text-[0.9rem] text-on-surface-variant">What's happening?</p> 898 + </div> 899 + <div class="flex items-center gap-1 text-on-surface-variant"> 900 + <Icon aria-hidden="true" iconClass="i-ri-at-line" /> 901 + <Icon aria-hidden="true" iconClass="i-ri-hashtag" /> 902 + <Icon aria-hidden="true" iconClass="i-ri-chat-quote-line" /> 903 + </div> 904 + </button> 905 + ); 906 + } 907 + 908 + function FeedContent( 909 + props: { 910 + activeFeedState: FeedState | undefined; 911 + focusedIndex: number; 912 + likePendingByUri: Record<string, boolean>; 913 + likePulseUri: string | null; 914 + onFocusIndex: (index: number) => void; 915 + onLike: (post: PostView) => Promise<void>; 916 + onOpenThread: (uri: string) => Promise<void>; 917 + onQuote: (post: PostView) => void; 918 + onReply: (post: PostView, root: PostView) => void; 919 + onRepost: (post: PostView) => Promise<void>; 920 + postRefs: Map<string, HTMLElement>; 921 + repostPendingByUri: Record<string, boolean>; 922 + repostPulseUri: string | null; 923 + sentinelRef: (element: HTMLDivElement) => void; 924 + visibleItems: FeedViewPost[]; 925 + }, 926 + ) { 927 + return ( 928 + <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> 958 + </Presence> 959 + ); 960 + } 961 + 962 + function FeedStatus(props: { activeFeedState: FeedState | undefined; visibleItems: FeedViewPost[] }) { 963 + return ( 964 + <> 965 + <Show when={props.activeFeedState?.loading}> 966 + <FeedSkeleton /> 967 + </Show> 968 + <Show when={props.activeFeedState?.error}> 969 + {(message) => ( 970 + <div class="rounded-[1.4rem] 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)]"> 971 + {message()} 972 + </div> 973 + )} 974 + </Show> 975 + <Show when={!props.activeFeedState?.loading && !props.activeFeedState?.error && props.visibleItems.length === 0}> 976 + <EmptyFeedState /> 977 + </Show> 978 + </> 979 + ); 980 + } 981 + 982 + function LoadingMoreIndicator(props: { loading: boolean }) { 983 + return ( 984 + <Show when={props.loading}> 985 + <div class="flex items-center justify-center py-4 text-[0.82rem] text-on-surface-variant"> 986 + <Icon aria-hidden="true" class="animate-spin" iconClass="i-ri-loader-4-line" /> 987 + <span class="ml-2">Loading more</span> 988 + </div> 989 + </Show> 990 + ); 991 + } 992 + 993 + function WorkspaceSidebar( 994 + props: { 995 + activePref: UserPreferences["feedViewPrefs"][number]; 996 + drawerFeeds: SavedFeedItem[]; 997 + generators: Record<string, FeedGeneratorView>; 998 + onFeedSelect: (feedId: string) => void; 999 + onPrefChange: <K extends keyof UserPreferences["feedViewPrefs"][number]>( 1000 + key: K, 1001 + value: UserPreferences["feedViewPrefs"][number][K], 1002 + ) => void; 1003 + }, 1004 + ) { 1005 + return ( 1006 + <aside class="grid gap-4 xl:h-[calc(100vh-10rem)] xl:overflow-y-auto"> 1007 + <SavedFeedsCard drawerFeeds={props.drawerFeeds} generators={props.generators} onFeedSelect={props.onFeedSelect} /> 1008 + <DisplayFiltersCard activePref={props.activePref} onPrefChange={props.onPrefChange} /> 1009 + <ShortcutsCard /> 1010 + </aside> 1011 + ); 1012 + } 1013 + 1014 + function SavedFeedsCard( 1015 + props: { 1016 + drawerFeeds: SavedFeedItem[]; 1017 + generators: Record<string, FeedGeneratorView>; 1018 + onFeedSelect: (feedId: string) => void; 1019 + }, 1020 + ) { 1021 + return ( 1022 + <SidebarCard title="Saved Feeds" subtitle="Drawer access"> 1023 + <div class="grid gap-2"> 1024 + <For each={props.drawerFeeds.slice(0, 4)}> 1025 + {(feed) => ( 1026 + <SidebarFeedButton feed={feed} generator={props.generators[feed.value]} onSelect={props.onFeedSelect} /> 1027 + )} 1028 + </For> 1029 + <Show when={props.drawerFeeds.length === 0}> 1030 + <p class="m-0 text-[0.8rem] leading-[1.6] text-on-surface-variant"> 1031 + All saved feeds are already pinned as tabs. 1032 + </p> 1033 + </Show> 1034 + </div> 1035 + </SidebarCard> 1036 + ); 1037 + } 1038 + 1039 + function SidebarFeedButton( 1040 + props: { feed: SavedFeedItem; generator?: FeedGeneratorView; onSelect: (feedId: string) => void }, 1041 + ) { 1042 + return ( 1043 + <button 1044 + class="flex w-full items-center gap-3 rounded-[1.1rem] border-0 bg-white/4 px-3 py-3 text-left text-on-surface transition duration-150 ease-out hover:-translate-y-px hover:bg-white/[0.07]" 1045 + type="button" 1046 + onClick={() => props.onSelect(props.feed.id)}> 1047 + <FeedChipAvatar feed={props.feed} generator={props.generator} /> 1048 + <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> 1051 + </div> 1052 + </button> 1053 + ); 1054 + } 1055 + 1056 + function DisplayFiltersCard( 1057 + props: { 1058 + activePref: UserPreferences["feedViewPrefs"][number]; 1059 + onPrefChange: <K extends keyof UserPreferences["feedViewPrefs"][number]>( 1060 + key: K, 1061 + value: UserPreferences["feedViewPrefs"][number][K], 1062 + ) => void; 1063 + }, 1064 + ) { 1065 + return ( 1066 + <SidebarCard title="Display Filters" subtitle="Per-feed"> 1067 + <div class="grid gap-3"> 1068 + <ToggleRow 1069 + checked={props.activePref.hideReposts} 1070 + label="Hide reposts" 1071 + onChange={(checked) => props.onPrefChange("hideReposts", checked)} /> 1072 + <ToggleRow 1073 + checked={props.activePref.hideReplies} 1074 + label="Hide replies" 1075 + onChange={(checked) => props.onPrefChange("hideReplies", checked)} /> 1076 + <ToggleRow 1077 + checked={props.activePref.hideQuotePosts} 1078 + label="Hide quotes" 1079 + onChange={(checked) => props.onPrefChange("hideQuotePosts", checked)} /> 1080 + <ReplyLikeThreshold 1081 + value={props.activePref.hideRepliesByLikeCount} 1082 + onChange={(value) => props.onPrefChange("hideRepliesByLikeCount", value)} /> 1083 + </div> 1084 + </SidebarCard> 1085 + ); 1086 + } 1087 + 1088 + function ReplyLikeThreshold(props: { value: number | null; onChange: (value: number | null) => void }) { 1089 + return ( 1090 + <label class="grid gap-2 text-[0.8rem] text-on-surface-variant"> 1091 + <span>Minimum likes for replies</span> 1092 + <input 1093 + class="rounded-full border-0 bg-white/6 px-4 py-2 text-on-surface shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)] focus:outline focus:outline-primary/50" 1094 + min="0" 1095 + type="number" 1096 + value={props.value ?? ""} 1097 + onInput={(event) => { 1098 + const value = event.currentTarget.value.trim(); 1099 + props.onChange(value ? Number(value) : null); 1100 + }} /> 1101 + </label> 1102 + ); 1103 + } 1104 + 1105 + function ShortcutsCard() { 1106 + return ( 1107 + <SidebarCard title="Shortcuts" subtitle="Feed controls"> 1108 + <div class="grid gap-2 text-[0.8rem] text-on-surface-variant"> 1109 + <ShortcutLine keys="1-9" label="Switch pinned feeds" /> 1110 + <ShortcutLine keys="j / k" label="Move focus" /> 1111 + <ShortcutLine keys="l" label="Like focused post" /> 1112 + <ShortcutLine keys="r" label="Reply to focused post" /> 1113 + <ShortcutLine keys="t" label="Repost focused post" /> 1114 + <ShortcutLine keys="o" label="Open thread" /> 1115 + <ShortcutLine keys="n" label="Open composer" /> 1116 + </div> 1117 + </SidebarCard> 1118 + ); 1119 + } 1120 + 1121 + function SavedFeedsDrawer( 1122 + props: { 1123 + feeds: SavedFeedItem[]; 1124 + generators: Record<string, FeedGeneratorView>; 1125 + open: boolean; 1126 + onClose: () => void; 1127 + onSelectFeed: (feedId: string) => void; 1128 + }, 1129 + ) { 1130 + return ( 1131 + <Presence> 1132 + <Show when={props.open}> 1133 + <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)]" 1135 + initial={{ opacity: 0, x: 20 }} 1136 + animate={{ opacity: 1, x: 0 }} 1137 + exit={{ opacity: 0, x: 24 }} 1138 + transition={{ duration: 0.2 }}> 1139 + <DrawerHeader onClose={props.onClose} /> 1140 + <div class="mt-5 grid gap-3"> 1141 + <For each={props.feeds}> 1142 + {(feed) => ( 1143 + <DrawerFeedButton 1144 + feed={feed} 1145 + generator={props.generators[feed.value]} 1146 + onSelectFeed={props.onSelectFeed} /> 1147 + )} 1148 + </For> 1149 + </div> 1150 + </Motion.aside> 1151 + </Show> 1152 + </Presence> 1153 + ); 1154 + } 1155 + 1156 + function DrawerHeader(props: { onClose: () => void }) { 1157 + return ( 1158 + <div class="flex items-center justify-between"> 1159 + <div> 1160 + <p class="m-0 text-[1rem] font-semibold text-on-surface">Saved Feeds</p> 1161 + <p class="m-0 text-[0.74rem] uppercase tracking-[0.12em] text-on-surface-variant">Unpinned drawer</p> 1162 + </div> 1163 + <button 1164 + class="inline-flex h-10 w-10 items-center justify-center rounded-xl border-0 bg-transparent text-on-surface-variant transition duration-150 ease-out hover:bg-white/5 hover:text-on-surface" 1165 + type="button" 1166 + onClick={() => props.onClose()}> 1167 + <Icon aria-hidden="true" iconClass="i-ri-close-line" /> 1168 + </button> 1169 + </div> 1170 + ); 1171 + } 1172 + 1173 + function DrawerFeedButton( 1174 + props: { feed: SavedFeedItem; generator?: FeedGeneratorView; onSelectFeed: (feedId: string) => void }, 1175 + ) { 1176 + return ( 1177 + <button 1178 + class="flex w-full items-center gap-3 rounded-[1.25rem] border-0 bg-white/4 px-4 py-4 text-left text-on-surface transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8" 1179 + type="button" 1180 + onClick={() => props.onSelectFeed(props.feed.id)}> 1181 + <FeedChipAvatar feed={props.feed} generator={props.generator} /> 1182 + <div class="min-w-0 flex-1"> 1183 + <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> 1185 + </div> 1186 + </button> 1187 + ); 1188 + } 1189 + 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 + function SidebarCard(props: { children: JSX.Element; subtitle: string; title: string }) { 1219 + return ( 1220 + <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> 1223 + <div class="mt-4">{props.children}</div> 1224 + </section> 1225 + ); 1226 + } 1227 + 1228 + function ToggleRow(props: { checked: boolean; label: string; onChange: (checked: boolean) => void }) { 1229 + 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"> 1231 + <span>{props.label}</span> 1232 + <input checked={props.checked} type="checkbox" onInput={(event) => props.onChange(event.currentTarget.checked)} /> 1233 + </label> 1234 + ); 1235 + } 1236 + 1237 + function ShortcutLine(props: { keys: string; label: string }) { 1238 + return ( 1239 + <div class="flex items-center justify-between gap-3 rounded-2xl bg-white/4 px-3 py-2.5"> 1240 + <span>{props.label}</span> 1241 + <span class="rounded-full bg-black/30 px-2 py-1 text-[0.68rem] uppercase tracking-[0.08em] text-primary"> 1242 + {props.keys} 1243 + </span> 1244 + </div> 1245 + ); 1246 + } 1247 + 1248 + function FeedSkeleton() { 1249 + return ( 1250 + <div class="grid gap-3"> 1251 + <SkeletonCard /> 1252 + <SkeletonCard /> 1253 + <SkeletonCard /> 1254 + </div> 1255 + ); 1256 + } 1257 + 1258 + function SkeletonCard() { 1259 + return ( 1260 + <div class="rounded-3xl bg-white/3 p-5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)]"> 1261 + <div class="flex gap-3"> 1262 + <div class="skeleton-block h-11 w-11 rounded-full" /> 1263 + <div class="min-w-0 flex-1"> 1264 + <div class="skeleton-block h-4 w-48 rounded-full" /> 1265 + <div class="mt-3 grid gap-2"> 1266 + <div class="skeleton-block h-3.5 w-full rounded-full" /> 1267 + <div class="skeleton-block h-3.5 w-[88%] rounded-full" /> 1268 + <div class="skeleton-block h-3.5 w-[70%] rounded-full" /> 1269 + </div> 1270 + </div> 1271 + </div> 1272 + </div> 1273 + ); 1274 + } 1275 + 1276 + function EmptyFeedState() { 1277 + return ( 1278 + <div class="rounded-[1.6rem] bg-white/3 p-8 text-center shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 1279 + <p class="m-0 text-[1rem] font-semibold text-on-surface">Nothing to show yet</p> 1280 + <p class="mt-2 text-sm leading-[1.6] text-on-surface-variant"> 1281 + This feed is empty with the current filters. Try another tab or loosen the display settings. 1282 + </p> 1283 + </div> 1284 + ); 1285 + } 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 + }
+289
src/components/feeds/PostCard.tsx
··· 1 + import { Icon } from "$/components/shared/Icon"; 2 + import { 3 + formatCount, 4 + formatRelativeTime, 5 + getAvatarLabel, 6 + getDisplayName, 7 + getPostCreatedAt, 8 + getPostText, 9 + getQuotedAuthor, 10 + getQuotedText, 11 + } from "$/lib/feeds"; 12 + import type { FeedViewPost, ImagesEmbedView, PostView } from "$/lib/types"; 13 + import { createMemo, For, Match, Show, Switch } from "solid-js"; 14 + import { Motion } from "solid-motionone"; 15 + 16 + type PostCardProps = { 17 + focused?: boolean; 18 + item?: FeedViewPost; 19 + likePending?: boolean; 20 + onFocus?: () => void; 21 + onLike?: () => void; 22 + onOpenThread?: () => void; 23 + onQuote?: () => void; 24 + onReply?: () => void; 25 + onRepost?: () => void; 26 + post: PostView; 27 + pulseLike?: boolean; 28 + pulseRepost?: boolean; 29 + registerRef?: (element: HTMLElement) => void; 30 + repostPending?: boolean; 31 + }; 32 + 33 + export function PostCard(props: PostCardProps) { 34 + const authorName = createMemo(() => getDisplayName(props.post.author)); 35 + const createdAt = createMemo(() => formatRelativeTime(getPostCreatedAt(props.post))); 36 + const isLiked = createMemo(() => !!props.post.viewer?.like); 37 + const isReposted = createMemo(() => !!props.post.viewer?.repost); 38 + const reasonLabel = createMemo(() => { 39 + const reason = props.item?.reason; 40 + if (!reason || reason.$type !== "app.bsky.feed.defs#reasonRepost") { 41 + return null; 42 + } 43 + 44 + return `${getDisplayName(reason.by)} reposted`; 45 + }); 46 + 47 + return ( 48 + <Motion.article 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" 51 + classList={{ 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 + !!props.focused, 54 + }} 55 + role="article" 56 + tabIndex={0} 57 + initial={{ opacity: 0, y: 18 }} 58 + animate={{ opacity: 1, y: 0 }} 59 + transition={{ duration: 0.22 }} 60 + onClick={() => props.onFocus?.()} 61 + onFocus={() => props.onFocus?.()} 62 + onKeyDown={(event) => { 63 + if (event.key === "Enter") { 64 + props.onOpenThread?.(); 65 + } 66 + }}> 67 + <Show when={reasonLabel()}> 68 + <div class="mb-3 flex items-center gap-2 text-[0.72rem] font-medium tracking-[0.04em] text-primary"> 69 + <Icon aria-hidden="true" iconClass="i-ri-repeat-2-line" /> 70 + <span>{reasonLabel()}</span> 71 + </div> 72 + </Show> 73 + 74 + <div class="flex gap-3"> 75 + <AuthorAvatar avatar={props.post.author.avatar} label={getAvatarLabel(props.post.author)} /> 76 + 77 + <div class="min-w-0 flex-1"> 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> 81 + <span class="text-xs text-on-surface-variant">{createdAt()}</span> 82 + </header> 83 + 84 + <Show when={getPostText(props.post)}> 85 + {(text) => ( 86 + <p class="m-0 whitespace-pre-wrap text-[0.94rem] leading-[1.65] text-on-secondary-container"> 87 + {linkifyText(text())} 88 + </p> 89 + )} 90 + </Show> 91 + 92 + <PostEmbeds post={props.post} /> 93 + 94 + <footer class="mt-4 flex flex-wrap items-center gap-2"> 95 + <ActionButton 96 + active={isLiked()} 97 + busy={!!props.likePending} 98 + icon="i-ri-heart-3-line" 99 + iconActive="i-ri-heart-3-fill" 100 + label={formatCount(props.post.likeCount)} 101 + pulse={!!props.pulseLike} 102 + onClick={props.onLike} /> 103 + <ActionButton icon="i-ri-chat-1-line" label={formatCount(props.post.replyCount)} onClick={props.onReply} /> 104 + <ActionButton 105 + active={isReposted()} 106 + busy={!!props.repostPending} 107 + icon="i-ri-repeat-2-line" 108 + iconActive="i-ri-repeat-2-fill" 109 + label={formatCount(props.post.repostCount)} 110 + pulse={!!props.pulseRepost} 111 + onClick={props.onRepost} /> 112 + <ActionButton icon="i-ri-chat-quote-line" label="Quote" onClick={props.onQuote} /> 113 + <ActionButton icon="i-ri-node-tree" label="Thread" onClick={props.onOpenThread} /> 114 + </footer> 115 + </div> 116 + </div> 117 + </Motion.article> 118 + ); 119 + } 120 + 121 + function AuthorAvatar(props: { avatar?: string | null; label: string }) { 122 + return ( 123 + <div class="relative mt-0.5 h-11 w-11 shrink-0 overflow-hidden rounded-full bg-[linear-gradient(135deg,rgba(125,175,255,0.9),rgba(0,115,222,0.72))] shadow-[0_0_0_2px_rgba(14,14,14,1),0_0_0_3px_rgba(125,175,255,0.28)]"> 124 + <Show 125 + when={props.avatar} 126 + fallback={ 127 + <div class="flex h-full w-full items-center justify-center text-sm font-semibold text-on-primary-fixed"> 128 + {props.label} 129 + </div> 130 + }> 131 + {(avatar) => <img class="h-full w-full object-cover" src={avatar()} alt="" />} 132 + </Show> 133 + </div> 134 + ); 135 + } 136 + 137 + function ActionButton( 138 + props: { 139 + active?: boolean; 140 + busy?: boolean; 141 + icon: string; 142 + iconActive?: string; 143 + label: string; 144 + onClick?: () => void; 145 + pulse?: boolean; 146 + }, 147 + ) { 148 + return ( 149 + <button 150 + 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 + classList={{ "text-primary": !!props.active }} 152 + type="button" 153 + disabled={props.busy} 154 + onClick={() => props.onClick?.()}> 155 + <Motion.span 156 + class="flex items-center" 157 + animate={{ scale: props.pulse ? [1, 1.3, 1] : 1 }} 158 + transition={{ duration: 0.28 }}> 159 + <Icon aria-hidden="true" iconClass={props.active ? props.iconActive ?? props.icon : props.icon} /> 160 + </Motion.span> 161 + <span>{props.busy ? "..." : props.label}</span> 162 + </button> 163 + ); 164 + } 165 + 166 + function PostEmbeds(props: { post: PostView }) { 167 + const embed = createMemo(() => props.post.embed); 168 + 169 + return ( 170 + <Show when={embed()}> 171 + {(current) => ( 172 + <div class="mt-4"> 173 + <Switch> 174 + <Match when={current().$type === "app.bsky.embed.images#view"}> 175 + <ImageEmbed embed={current() as ImagesEmbedView} /> 176 + </Match> 177 + <Match when={current().$type === "app.bsky.embed.external#view"}> 178 + <ExternalEmbed 179 + description={(current() as { external: { description?: string } }).external.description} 180 + thumb={(current() as { external: { thumb?: string } }).external.thumb} 181 + title={(current() as { external: { title?: string } }).external.title} 182 + uri={(current() as { external: { uri?: string } }).external.uri} /> 183 + </Match> 184 + <Match when={current().$type === "app.bsky.embed.video#view"}> 185 + <ExternalEmbed 186 + description={(current() as { alt?: string }).alt} 187 + thumb={(current() as { thumbnail?: string }).thumbnail} 188 + title="Video attachment" 189 + uri={(current() as { playlist?: string }).playlist} /> 190 + </Match> 191 + <Match 192 + when={current().$type === "app.bsky.embed.record#view" 193 + || current().$type === "app.bsky.embed.recordWithMedia#view"}> 194 + <QuoteEmbed author={getQuotedAuthor(current())} text={getQuotedText(current())} title="Quoted post" /> 195 + </Match> 196 + </Switch> 197 + </div> 198 + )} 199 + </Show> 200 + ); 201 + } 202 + 203 + function ImageEmbed(props: { embed: ImagesEmbedView }) { 204 + return ( 205 + <div class="grid gap-2" classList={{ "grid-cols-2": props.embed.images.length > 1 }}> 206 + <For each={props.embed.images.slice(0, 4)}> 207 + {(image) => ( 208 + <div class="overflow-hidden rounded-[1.2rem] bg-black/30 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]"> 209 + <img class="max-h-88 w-full object-cover" src={image.fullsize ?? image.thumb} alt={image.alt ?? ""} /> 210 + </div> 211 + )} 212 + </For> 213 + </div> 214 + ); 215 + } 216 + 217 + function ExternalEmbed(props: { description?: string; thumb?: string; title?: string; uri?: string }) { 218 + return ( 219 + <a 220 + 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" 221 + href={props.uri} 222 + rel="noreferrer" 223 + target="_blank"> 224 + <Show when={props.thumb}> 225 + {(thumb) => <img class="max-h-64 w-full rounded-2xl object-cover" src={thumb()} alt="" />} 226 + </Show> 227 + <div class="grid gap-1"> 228 + <p class="m-0 text-sm font-semibold text-on-surface">{props.title || "External link"}</p> 229 + <Show when={props.description}> 230 + {(description) => <p class="m-0 text-[0.82rem] leading-[1.55] text-on-surface-variant">{description()}</p>} 231 + </Show> 232 + <Show when={props.uri}> 233 + {(uri) => ( 234 + <p class="m-0 text-[0.74rem] uppercase tracking-[0.08em] text-primary"> 235 + {uri().replace(/^https?:\/\//, "")} 236 + </p> 237 + )} 238 + </Show> 239 + </div> 240 + </a> 241 + ); 242 + } 243 + 244 + function QuoteEmbed(props: { author: PostView["author"] | null; text?: unknown; title: string }) { 245 + const preview = createMemo(() => (typeof props.text === "string" ? props.text : "")); 246 + 247 + return ( 248 + <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> 250 + <Show when={props.author}> 251 + {(author) => ( 252 + <p class="mt-2 text-[0.84rem] font-semibold text-on-surface"> 253 + {getDisplayName(author())} 254 + <span class="ml-1 text-xs font-normal text-on-surface-variant">@{author().handle.replace(/^@/, "")}</span> 255 + </p> 256 + )} 257 + </Show> 258 + <Show when={preview()}> 259 + {(text) => <p class="mt-2 line-clamp-4 text-sm leading-[1.55] text-on-secondary-container">{text()}</p>} 260 + </Show> 261 + </div> 262 + ); 263 + } 264 + 265 + function linkifyText(text: string) { 266 + const parts = text.split(/(https?:\/\/\S+|@[a-z0-9._-]+(?:\.[a-z0-9._-]+)+|#[\p{L}\p{N}_-]+)/giu); 267 + 268 + 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 + } 279 + 280 + if (/^[@#]/.test(part)) { 281 + return <span class="text-primary">{part}</span>; 282 + } 283 + 284 + return <span>{part}</span>; 285 + }} 286 + </For> 287 + </> 288 + ); 289 + }
+192
src/components/feeds/ThreadPanel.tsx
··· 1 + import { Icon } from "$/components/shared/Icon"; 2 + import { findRootPost, isBlockedNode, isNotFoundNode, isThreadViewPost } from "$/lib/feeds"; 3 + import type { PostView, ThreadNode } from "$/lib/types"; 4 + import { createMemo, For, Match, Show, Switch } from "solid-js"; 5 + import { Motion, Presence } from "solid-motionone"; 6 + import { PostCard } from "./PostCard"; 7 + 8 + type ThreadPanelProps = { 9 + activeUri: string | null; 10 + error: string | null; 11 + loading: boolean; 12 + onClose: () => void; 13 + onLike: (post: PostView) => void; 14 + onQuote: (post: PostView) => void; 15 + onReply: (post: PostView, root: PostView) => void; 16 + onRepost: (post: PostView) => void; 17 + onOpenThread: (uri: string) => void; 18 + thread: ThreadNode | null; 19 + }; 20 + 21 + export function ThreadPanel(props: ThreadPanelProps) { 22 + const rootPost = createMemo(() => findRootPost(props.thread)); 23 + 24 + return ( 25 + <Presence> 26 + <Show when={props.activeUri}> 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)]" 29 + initial={{ opacity: 0, x: 30 }} 30 + animate={{ opacity: 1, x: 0 }} 31 + exit={{ opacity: 0, x: 36 }} 32 + transition={{ duration: 0.22 }}> 33 + <ThreadPanelHeader onClose={props.onClose} /> 34 + <ThreadPanelLoading loading={props.loading} /> 35 + 36 + <Show when={!props.loading && props.error}> 37 + {(message) => ( 38 + <div class="rounded-[1.4rem] 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> 43 + 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> 59 + </Motion.aside> 60 + </Show> 61 + </Presence> 62 + ); 63 + } 64 + 65 + function ThreadPanelHeader(props: { onClose: () => void }) { 66 + return ( 67 + <header class="sticky top-0 z-10 mb-4 flex items-center justify-between rounded-[1.4rem] bg-[rgba(14,14,14,0.9)] px-4 py-3 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 68 + <div> 69 + <p class="m-0 text-[0.95rem] font-semibold text-on-surface">Thread</p> 70 + <p class="m-0 text-[0.74rem] uppercase tracking-[0.12em] text-on-surface-variant">Nested replies</p> 71 + </div> 72 + <button 73 + class="inline-flex h-10 w-10 items-center justify-center rounded-xl border-0 bg-transparent text-on-surface-variant transition duration-150 ease-out hover:bg-white/5 hover:text-on-surface" 74 + type="button" 75 + onClick={() => props.onClose()}> 76 + <Icon aria-hidden="true" iconClass="i-ri-close-line" /> 77 + </button> 78 + </header> 79 + ); 80 + } 81 + 82 + function ThreadPanelLoading(props: { loading: boolean }) { 83 + return ( 84 + <Show when={props.loading}> 85 + <div class="grid gap-3"> 86 + <SkeletonThreadCard /> 87 + <SkeletonThreadCard /> 88 + </div> 89 + </Show> 90 + ); 91 + } 92 + 93 + function ThreadNodeView( 94 + props: { 95 + activeUri: string | null; 96 + node: ThreadNode; 97 + onLike: (post: PostView) => void; 98 + onOpenThread: (uri: string) => void; 99 + onQuote: (post: PostView) => void; 100 + onReply: (post: PostView, root: PostView) => void; 101 + onRepost: (post: PostView) => void; 102 + rootPost: PostView; 103 + }, 104 + ) { 105 + const node = createMemo(() => (isThreadViewPost(props.node) ? props.node : null)); 106 + 107 + return ( 108 + <Switch> 109 + <Match when={isBlockedNode(props.node)}> 110 + <StateCard label="Blocked post" meta={isBlockedNode(props.node) ? props.node.uri : ""} /> 111 + </Match> 112 + <Match when={isNotFoundNode(props.node)}> 113 + <StateCard label="Post not found" meta={isNotFoundNode(props.node) ? props.node.uri : ""} /> 114 + </Match> 115 + <Match when={node()}> 116 + {(threadNode) => ( 117 + <div class="grid gap-4"> 118 + <Show when={threadNode().parent}> 119 + {(parent) => ( 120 + <div class="border-l border-white/6 pl-4"> 121 + <ThreadNodeView 122 + activeUri={props.activeUri} 123 + node={parent()} 124 + rootPost={props.rootPost} 125 + onLike={props.onLike} 126 + onOpenThread={props.onOpenThread} 127 + onQuote={props.onQuote} 128 + onReply={props.onReply} 129 + onRepost={props.onRepost} /> 130 + </div> 131 + )} 132 + </Show> 133 + 134 + <PostCard 135 + focused={threadNode().post.uri === props.activeUri} 136 + post={threadNode().post} 137 + onLike={() => props.onLike(threadNode().post)} 138 + onOpenThread={() => props.onOpenThread(threadNode().post.uri)} 139 + onQuote={() => props.onQuote(threadNode().post)} 140 + onReply={() => props.onReply(threadNode().post, props.rootPost)} 141 + onRepost={() => props.onRepost(threadNode().post)} /> 142 + 143 + <Show when={threadNode().replies?.length}> 144 + <div class="grid gap-4 border-l border-white/6 pl-4"> 145 + <For each={threadNode().replies}> 146 + {(reply) => ( 147 + <ThreadNodeView 148 + activeUri={props.activeUri} 149 + node={reply} 150 + rootPost={props.rootPost} 151 + onLike={props.onLike} 152 + onOpenThread={props.onOpenThread} 153 + onQuote={props.onQuote} 154 + onReply={props.onReply} 155 + onRepost={props.onRepost} /> 156 + )} 157 + </For> 158 + </div> 159 + </Show> 160 + </div> 161 + )} 162 + </Match> 163 + </Switch> 164 + ); 165 + } 166 + 167 + function StateCard(props: { label: string; meta: string }) { 168 + return ( 169 + <div class="rounded-[1.3rem] bg-white/3 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 170 + <p class="m-0 text-sm font-semibold text-on-surface">{props.label}</p> 171 + <p class="mt-1 text-[0.74rem] text-on-surface-variant">{props.meta}</p> 172 + </div> 173 + ); 174 + } 175 + 176 + function SkeletonThreadCard() { 177 + return ( 178 + <div class="rounded-[1.4rem] bg-white/3 p-5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 179 + <div class="flex gap-3"> 180 + <div class="skeleton-block h-11 w-11 rounded-full" /> 181 + <div class="min-w-0 flex-1"> 182 + <div class="skeleton-block h-4 w-40 rounded-full" /> 183 + <div class="mt-3 grid gap-2"> 184 + <div class="skeleton-block h-3.5 w-full rounded-full" /> 185 + <div class="skeleton-block h-3.5 w-[82%] rounded-full" /> 186 + <div class="skeleton-block h-3.5 w-[68%] rounded-full" /> 187 + </div> 188 + </div> 189 + </div> 190 + </div> 191 + ); 192 + }
+1 -1
src/components/panels/Header.tsx
··· 6 6 <div class="max-w-3xl"> 7 7 <p class="overline-copy text-[0.72rem] text-primary">Authentication</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 - All your accounts, right where you left them. 9 + Join the conversation. 10 10 </h1> 11 11 </div> 12 12 <p class="overline-copy text-[0.72rem] tracking-[0.18em] text-on-surface-variant xl:pt-2">{props.metaLabel}</p>
+42 -11
src/components/shared/Icon.tsx
··· 1 - import { Match, Switch } from "solid-js"; 1 + import { type JSX, Match, splitProps, Switch } from "solid-js"; 2 + 3 + export type IconKind = 4 + | "explorer" 5 + | "ext-link" 6 + | "loader" 7 + | "logout" 8 + | "notifications" 9 + | "profile" 10 + | "refresh" 11 + | "search" 12 + | "timeline" 13 + | "user"; 14 + 15 + type IconProps = JSX.HTMLAttributes<HTMLSpanElement> & { 16 + class?: string; 17 + iconClass?: string; 18 + kind?: IconKind; 19 + name?: string; 20 + }; 2 21 3 - export type IconKind = "loader" | "user" | "logout" | "profile" | "search" | "refresh" | "ext-link"; 22 + export function Icon(props: IconProps) { 23 + const [local, rest] = splitProps(props, ["class", "iconClass", "kind", "name"]); 4 24 5 - export function Icon(props: { name: string; class?: string; kind: IconKind }) { 6 25 return ( 7 - <span class="flex items-center justify-center" classList={{ [props.class ?? ""]: !!props.class }}> 26 + <span {...rest} class="flex items-center justify-center" classList={{ [local.class ?? ""]: !!local.class }}> 8 27 <Switch> 9 - <Match when={props.kind === "loader"}> 28 + <Match when={!!local.iconClass}> 29 + <i class={local.iconClass} /> 30 + </Match> 31 + <Match when={local.kind === "loader"}> 10 32 <i class="i-ri-loader-4-line" /> 11 33 </Match> 12 - <Match when={props.kind === "user"}> 34 + <Match when={local.kind === "user"}> 13 35 <i class="i-ri-user-shared-line" /> 14 36 </Match> 15 - <Match when={props.kind === "logout"}> 37 + <Match when={local.kind === "logout"}> 16 38 <i class="i-ri-logout-box-line" /> 17 39 </Match> 18 - <Match when={props.kind === "profile"}> 40 + <Match when={local.kind === "profile"}> 19 41 <i class="i-ri-user-3-line" /> 20 42 </Match> 21 - <Match when={props.kind === "search"}> 43 + <Match when={local.kind === "search"}> 22 44 <i class="i-ri-search-line" /> 23 45 </Match> 24 - <Match when={props.kind === "refresh"}> 46 + <Match when={local.kind === "timeline"}> 47 + <i class="i-ri-home-5-line" /> 48 + </Match> 49 + <Match when={local.kind === "notifications"}> 50 + <i class="i-ri-notification-3-line" /> 51 + </Match> 52 + <Match when={local.kind === "explorer"}> 53 + <i class="i-ri-compass-discover-line" /> 54 + </Match> 55 + <Match when={local.kind === "refresh"}> 25 56 <i class="i-ri-refresh-line" /> 26 57 </Match> 27 - <Match when={props.kind === "ext-link"}> 58 + <Match when={local.kind === "ext-link"}> 28 59 <i class="i-ri-external-link-line" /> 29 60 </Match> 30 61 </Switch>
+219
src/lib/feeds.ts
··· 1 + import type { 2 + BlockedPost, 3 + EmbedView, 4 + FeedReplyNode, 5 + FeedViewPost, 6 + NotFoundPost, 7 + PostRecord, 8 + PostView, 9 + ProfileViewBasic, 10 + StrongRefInput, 11 + ThreadNode, 12 + ThreadViewPost, 13 + } from "./types"; 14 + 15 + export function asRecord(value: unknown): Record<string, unknown> | null { 16 + if (!value || typeof value !== "object" || Array.isArray(value)) { 17 + return null; 18 + } 19 + 20 + return value as Record<string, unknown>; 21 + } 22 + 23 + export function asPostRecord(value: unknown): PostRecord { 24 + return (asRecord(value) ?? {}) as PostRecord; 25 + } 26 + 27 + export function getPostText(post: PostView) { 28 + const text = post.record.text; 29 + return typeof text === "string" ? text.trim() : ""; 30 + } 31 + 32 + export function getPostCreatedAt(post: PostView) { 33 + const createdAt = post.record.createdAt; 34 + return typeof createdAt === "string" ? createdAt : post.indexedAt; 35 + } 36 + 37 + export function getDisplayName(author: ProfileViewBasic) { 38 + return author.displayName?.trim() || author.handle; 39 + } 40 + 41 + export function getAvatarLabel(author: ProfileViewBasic) { 42 + return getDisplayName(author).slice(0, 1).toUpperCase() || "?"; 43 + } 44 + 45 + export function formatRelativeTime(value: string) { 46 + const timestamp = new Date(value).getTime(); 47 + if (Number.isNaN(timestamp)) { 48 + return ""; 49 + } 50 + 51 + const deltaSeconds = Math.round((timestamp - Date.now()) / 1000); 52 + const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }); 53 + const ranges = [ 54 + ["year", 60 * 60 * 24 * 365], 55 + ["month", 60 * 60 * 24 * 30], 56 + ["day", 60 * 60 * 24], 57 + ["hour", 60 * 60], 58 + ["minute", 60], 59 + ] as const; 60 + 61 + for (const [unit, seconds] of ranges) { 62 + if (Math.abs(deltaSeconds) >= seconds) { 63 + return formatter.format(Math.round(deltaSeconds / seconds), unit); 64 + } 65 + } 66 + 67 + return formatter.format(deltaSeconds, "second"); 68 + } 69 + 70 + export function formatCount(value: number | null | undefined) { 71 + if (!value) { 72 + return "0"; 73 + } 74 + 75 + if (value >= 1000) { 76 + return `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}K`; 77 + } 78 + 79 + return value.toString(); 80 + } 81 + 82 + export function getFeedName(item: { type: string; value: string }, hydratedName?: string | null) { 83 + if (item.type === "timeline") { 84 + return item.value === "following" ? "Following" : "Timeline"; 85 + } 86 + 87 + if (hydratedName) { 88 + return hydratedName; 89 + } 90 + 91 + const segment = item.value.split("/").at(-1)?.trim(); 92 + if (segment) { 93 + return segment.replaceAll("-", " "); 94 + } 95 + 96 + return item.type === "list" ? "List" : "Custom feed"; 97 + } 98 + 99 + export function isRepostReason(item: FeedViewPost) { 100 + return item.reason?.$type === "app.bsky.feed.defs#reasonRepost"; 101 + } 102 + 103 + export function isQuoteEmbed(embed: EmbedView | null | undefined) { 104 + return embed?.$type === "app.bsky.embed.record#view" || embed?.$type === "app.bsky.embed.recordWithMedia#view"; 105 + } 106 + 107 + export function isReplyItem(item: FeedViewPost) { 108 + return !!item.reply; 109 + } 110 + 111 + export function getRootRef(item: FeedViewPost) { 112 + if (item.reply?.root.$type === "app.bsky.feed.defs#postView") { 113 + return toStrongRef(item.reply.root); 114 + } 115 + 116 + return toStrongRef(item.post); 117 + } 118 + 119 + export function toStrongRef(post: PostView) { 120 + return { cid: post.cid, uri: post.uri } satisfies StrongRefInput; 121 + } 122 + 123 + export function canUseStrongRef( 124 + post: FeedReplyNode | ThreadNode | null | undefined, 125 + ): post is { $type: "app.bsky.feed.defs#postView" } & PostView { 126 + return !!post && "$type" in post && post.$type === "app.bsky.feed.defs#postView"; 127 + } 128 + 129 + export function isThreadViewPost(node: ThreadNode | null | undefined): node is ThreadViewPost { 130 + return !!node && node.$type === "app.bsky.feed.defs#threadViewPost"; 131 + } 132 + 133 + export function isBlockedNode(node: ThreadNode | FeedReplyNode | null | undefined): node is BlockedPost { 134 + return !!node && node.$type === "app.bsky.feed.defs#blockedPost"; 135 + } 136 + 137 + export function isNotFoundNode(node: ThreadNode | FeedReplyNode | null | undefined): node is NotFoundPost { 138 + return !!node && node.$type === "app.bsky.feed.defs#notFoundPost"; 139 + } 140 + 141 + export function extractHashtags(posts: PostView[]) { 142 + const tags = new Set<string>(); 143 + for (const post of posts) { 144 + for (const match of getPostText(post).matchAll(/#[\p{L}\p{N}_-]+/gu)) { 145 + tags.add(match[0]); 146 + } 147 + } 148 + 149 + return [...tags].toSorted((left, right) => left.localeCompare(right)); 150 + } 151 + 152 + export function extractHandles(posts: PostView[], activeHandle: string | null) { 153 + const handles = new Set<string>(); 154 + for (const post of posts) { 155 + if (post.author.handle) { 156 + handles.add(`@${post.author.handle.replace(/^@/, "")}`); 157 + } 158 + } 159 + 160 + if (activeHandle) { 161 + handles.add(`@${activeHandle.replace(/^@/, "")}`); 162 + } 163 + 164 + return [...handles].toSorted((left, right) => left.localeCompare(right)); 165 + } 166 + 167 + export function getQuotedRecord(embed: EmbedView | null | undefined) { 168 + if (!embed) { 169 + return null; 170 + } 171 + 172 + if (embed.$type === "app.bsky.embed.record#view") { 173 + return embed.record; 174 + } 175 + 176 + if (embed.$type === "app.bsky.embed.recordWithMedia#view") { 177 + return embed.record?.record ?? null; 178 + } 179 + 180 + return null; 181 + } 182 + 183 + export function getQuotedText(embed: EmbedView | null | undefined) { 184 + const record = getQuotedRecord(embed); 185 + return asRecord(record?.value)?.text; 186 + } 187 + 188 + export function getQuotedAuthor(embed: EmbedView | null | undefined) { 189 + return getQuotedRecord(embed)?.author ?? null; 190 + } 191 + 192 + export function patchFeedItems(items: FeedViewPost[], uri: string, updater: (post: PostView) => PostView) { 193 + return items.map((item) => (item.post.uri === uri ? { ...item, post: updater(item.post) } : item)); 194 + } 195 + 196 + export function patchThreadNode(node: ThreadNode, uri: string, updater: (post: PostView) => PostView): ThreadNode { 197 + if (node.$type !== "app.bsky.feed.defs#threadViewPost") { 198 + return node; 199 + } 200 + 201 + return { 202 + ...node, 203 + parent: node.parent ? patchThreadNode(node.parent, uri, updater) : node.parent, 204 + post: node.post.uri === uri ? updater(node.post) : node.post, 205 + replies: node.replies?.map((reply) => patchThreadNode(reply, uri, updater)) ?? node.replies, 206 + }; 207 + } 208 + 209 + export function findRootPost(node: ThreadNode | null): PostView | null { 210 + if (!node || !isThreadViewPost(node)) { 211 + return null; 212 + } 213 + 214 + if (node.parent && isThreadViewPost(node.parent)) { 215 + return findRootPost(node.parent) ?? node.post; 216 + } 217 + 218 + return node.post; 219 + }
+161
src/lib/types.ts
··· 3 3 export type ActiveSession = { did: string; handle: string }; 4 4 5 5 export type AppBootstrap = { activeSession: ActiveSession | null; accountList: AccountSummary[] }; 6 + 7 + export type SavedFeedItem = { id: string; type: string; value: string; pinned: boolean }; 8 + 9 + export type FeedViewPrefItem = { 10 + feed: string; 11 + hideReplies: boolean; 12 + hideRepliesByUnfollowed: boolean; 13 + hideRepliesByLikeCount: number | null; 14 + hideReposts: boolean; 15 + hideQuotePosts: boolean; 16 + }; 17 + 18 + export type UserPreferences = { savedFeeds: SavedFeedItem[]; feedViewPrefs: FeedViewPrefItem[] }; 19 + 20 + export type AuthorViewerState = { following?: string | null }; 21 + 22 + export type ProfileViewBasic = { 23 + did: string; 24 + handle: string; 25 + displayName?: string | null; 26 + avatar?: string | null; 27 + viewer?: AuthorViewerState | null; 28 + }; 29 + 30 + export type FeedGeneratorView = { 31 + uri: string; 32 + did: string; 33 + displayName: string; 34 + avatar?: string | null; 35 + description?: string | null; 36 + creator?: ProfileViewBasic | null; 37 + }; 38 + 39 + export type FeedGeneratorsResponse = { feeds: FeedGeneratorView[] }; 40 + 41 + export type ViewerState = { 42 + bookmarked?: boolean | null; 43 + embeddingDisabled?: boolean | null; 44 + like?: string | null; 45 + pinned?: boolean | null; 46 + replyDisabled?: boolean | null; 47 + repost?: string | null; 48 + threadMuted?: boolean | null; 49 + }; 50 + 51 + export type PostRecord = { 52 + $type?: string; 53 + createdAt?: string; 54 + embed?: Record<string, unknown> | null; 55 + facets?: unknown[] | null; 56 + text?: string; 57 + [key: string]: unknown; 58 + }; 59 + 60 + export type ImagesEmbedView = { 61 + $type: "app.bsky.embed.images#view"; 62 + images: Array<{ alt?: string; aspectRatio?: { height: number; width: number }; fullsize?: string; thumb?: string }>; 63 + }; 64 + 65 + export type ExternalEmbedView = { 66 + $type: "app.bsky.embed.external#view"; 67 + external: { description?: string; thumb?: string; title?: string; uri?: string }; 68 + }; 69 + 70 + export type EmbeddedQuoteRecord = { 71 + $type?: string; 72 + author?: ProfileViewBasic; 73 + cid?: string; 74 + embeds?: EmbedView[]; 75 + uri?: string; 76 + value?: Record<string, unknown>; 77 + }; 78 + 79 + export type RecordEmbedView = { $type: "app.bsky.embed.record#view"; record: EmbeddedQuoteRecord }; 80 + 81 + export type RecordWithMediaEmbedView = { 82 + $type: "app.bsky.embed.recordWithMedia#view"; 83 + media?: EmbedView; 84 + record?: RecordEmbedView; 85 + }; 86 + 87 + export type VideoEmbedView = { 88 + $type: "app.bsky.embed.video#view"; 89 + alt?: string; 90 + aspectRatio?: { height: number; width: number }; 91 + playlist?: string; 92 + thumbnail?: string; 93 + }; 94 + 95 + export type EmbedView = 96 + | ExternalEmbedView 97 + | ImagesEmbedView 98 + | RecordEmbedView 99 + | RecordWithMediaEmbedView 100 + | VideoEmbedView; 101 + 102 + export type PostView = { 103 + author: ProfileViewBasic; 104 + cid: string; 105 + embed?: EmbedView | null; 106 + indexedAt: string; 107 + likeCount?: number | null; 108 + quoteCount?: number | null; 109 + record: PostRecord | Record<string, unknown>; 110 + replyCount?: number | null; 111 + repostCount?: number | null; 112 + uri: string; 113 + viewer?: ViewerState | null; 114 + }; 115 + 116 + export type NotFoundPost = { $type: "app.bsky.feed.defs#notFoundPost"; notFound: boolean; uri: string }; 117 + 118 + export type BlockedPost = { 119 + $type: "app.bsky.feed.defs#blockedPost"; 120 + blocked: boolean; 121 + uri: string; 122 + author?: ProfileViewBasic; 123 + }; 124 + 125 + export type FeedReplyNode = ({ $type: "app.bsky.feed.defs#postView" } & PostView) | NotFoundPost | BlockedPost; 126 + 127 + export type FeedReplyRef = { grandparentAuthor?: ProfileViewBasic | null; parent: FeedReplyNode; root: FeedReplyNode }; 128 + 129 + export type FeedReasonRepost = { 130 + $type: "app.bsky.feed.defs#reasonRepost"; 131 + by: ProfileViewBasic; 132 + cid?: string | null; 133 + indexedAt: string; 134 + uri?: string | null; 135 + }; 136 + 137 + export type FeedReasonPin = { $type: "app.bsky.feed.defs#reasonPin" }; 138 + 139 + export type FeedViewPost = { 140 + feedContext?: string | null; 141 + post: PostView; 142 + reason?: FeedReasonPin | FeedReasonRepost | null; 143 + reply?: FeedReplyRef | null; 144 + reqId?: string | null; 145 + }; 146 + 147 + export type FeedResponse = { cursor?: string | null; feed: FeedViewPost[] }; 148 + 149 + export type ThreadViewPost = { 150 + $type: "app.bsky.feed.defs#threadViewPost"; 151 + parent?: ThreadNode | null; 152 + post: PostView; 153 + replies?: ThreadNode[] | null; 154 + }; 155 + 156 + export type ThreadNode = ThreadViewPost | NotFoundPost | BlockedPost; 157 + 158 + export type ThreadResponse = { thread: ThreadNode }; 159 + 160 + export type StrongRefInput = { cid: string; uri: string }; 161 + 162 + export type ReplyRefInput = { parent: StrongRefInput; root: StrongRefInput }; 163 + 164 + export type EmbedInput = { type: "record"; record: StrongRefInput }; 165 + 166 + export type CreateRecordResult = { cid: string; uri: string };
+5 -3
tsconfig.json
··· 1 1 { 2 2 "compilerOptions": { 3 - "target": "ES2020", 3 + "target": "ES2023", 4 4 "useDefineForClassFields": true, 5 5 "module": "ESNext", 6 - "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 + "lib": ["ES2023", "DOM", "DOM.Iterable"], 7 7 "skipLibCheck": true, 8 8 "moduleResolution": "bundler", 9 9 "allowImportingTsExtensions": true, ··· 16 16 "noUnusedLocals": true, 17 17 "noUnusedParameters": true, 18 18 "noFallthroughCasesInSwitch": true, 19 - "types": ["vite/client", "@testing-library/jest-dom"] 19 + "types": ["vite/client", "@testing-library/jest-dom"], 20 + "baseUrl": ".", 21 + "paths": { "$/*": ["src/*"] } 20 22 }, 21 23 "include": ["src"], 22 24 "references": [{ "path": "./tsconfig.node.json" }]