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.

refactor: split up feed workspace

+434 -407
+3 -2
package.json
··· 12 12 "check": "tsc --noEmit", 13 13 "typecheck": "tsc --noEmit", 14 14 "lint": "eslint . --ext .ts,.tsx --fix", 15 - "format": "dprint fmt ." 15 + "format": "dprint fmt .", 16 + "test": "vitest" 16 17 }, 17 18 "license": "MIT", 18 19 "dependencies": { 19 20 "@fontsource-variable/google-sans": "^5.2.1", 20 - "@solidjs/router": "^0.15.4", 21 + "@solidjs/router": "^0.16.1", 21 22 "@tauri-apps/api": "^2", 22 23 "@tauri-apps/plugin-deep-link": "~2.4.7", 23 24 "@tauri-apps/plugin-log": "~2",
+8 -8
pnpm-lock.yaml
··· 12 12 specifier: ^5.2.1 13 13 version: 5.2.1 14 14 '@solidjs/router': 15 - specifier: ^0.15.4 16 - version: 0.15.4(solid-js@1.9.12) 15 + specifier: ^0.16.1 16 + version: 0.16.1(solid-js@1.9.12) 17 17 '@tauri-apps/api': 18 18 specifier: ^2 19 19 version: 2.10.1 ··· 50 50 version: 1.2.10 51 51 '@solidjs/testing-library': 52 52 specifier: ^0.8.10 53 - version: 0.8.10(@solidjs/router@0.15.4(solid-js@1.9.12))(solid-js@1.9.12) 53 + version: 0.8.10(@solidjs/router@0.16.1(solid-js@1.9.12))(solid-js@1.9.12) 54 54 '@tailwindcss/forms': 55 55 specifier: ^0.5.11 56 56 version: 0.5.11(tailwindcss@4.2.2) ··· 732 732 peerDependencies: 733 733 solid-js: ^1.6.12 734 734 735 - '@solidjs/router@0.15.4': 736 - resolution: {integrity: sha512-WOpgg9a9T638cR+5FGbFi/IV4l2FpmBs1GpIMSPa0Ce9vyJN7Wts+X2PqMf9IYn0zUj2MlSJtm1gp7/HI/n5TQ==} 735 + '@solidjs/router@0.16.1': 736 + resolution: {integrity: sha512-IhyjedgC6LRpw/8CPGGI89FrV+r0xTHzOl2c4CRyzYQ1bLepJxbVI1LLKvsavMWY5TRBRacV7hAeOhuTXkjiqg==} 737 737 peerDependencies: 738 738 solid-js: ^1.8.6 739 739 ··· 2959 2959 dependencies: 2960 2960 solid-js: 1.9.12 2961 2961 2962 - '@solidjs/router@0.15.4(solid-js@1.9.12)': 2962 + '@solidjs/router@0.16.1(solid-js@1.9.12)': 2963 2963 dependencies: 2964 2964 solid-js: 1.9.12 2965 2965 2966 - '@solidjs/testing-library@0.8.10(@solidjs/router@0.15.4(solid-js@1.9.12))(solid-js@1.9.12)': 2966 + '@solidjs/testing-library@0.8.10(@solidjs/router@0.16.1(solid-js@1.9.12))(solid-js@1.9.12)': 2967 2967 dependencies: 2968 2968 '@testing-library/dom': 10.4.1 2969 2969 solid-js: 1.9.12 2970 2970 optionalDependencies: 2971 - '@solidjs/router': 0.15.4(solid-js@1.9.12) 2971 + '@solidjs/router': 0.16.1(solid-js@1.9.12) 2972 2972 2973 2973 '@standard-schema/spec@1.1.0': {} 2974 2974
+1 -1
src/components/AppRail.tsx
··· 1 - import { AccountSummary, ActiveSession } from "$/lib/types"; 1 + import type { AccountSummary, ActiveSession } from "$/lib/types"; 2 2 import { Show } from "solid-js"; 3 3 import { AccountSwitcher } from "./account/AccountSwitcher"; 4 4 import { RailButton } from "./RailButton";
+1 -1
src/components/Session.tsx
··· 1 - import { AccountSummary, ActiveSession } from "$/lib/types"; 1 + import type { AccountSummary, ActiveSession } from "$/lib/types"; 2 2 import { createMemo, Show } from "solid-js"; 3 3 import { Presence } from "solid-motionone"; 4 4 import { AvatarBadge } from "./AvatarBadge";
+2 -2
src/components/account/AccountLedger.tsx
··· 1 - import { AccountSummary } from "$/lib/types"; 1 + import { AvatarBadge } from "$/components/AvatarBadge"; 2 + import type { AccountSummary } from "$/lib/types"; 2 3 import { For, Show } from "solid-js"; 3 4 import { Motion } from "solid-motionone"; 4 - import { AvatarBadge } from "../AvatarBadge"; 5 5 import { AccountSwitchButton, LogoutButton } from "./AccountButtons"; 6 6 7 7 type AccountLedgerProps = {
+1 -1
src/components/account/AccountSwitcher.tsx
··· 1 + import type { AccountSummary, ActiveSession } from "$/lib/types"; 1 2 import { onCleanup, onMount, Show } from "solid-js"; 2 3 import { Motion, Presence } from "solid-motionone"; 3 - import { AccountSummary, ActiveSession } from "../../lib/types"; 4 4 import { ArrowIcon } from "../shared/Icon"; 5 5 import { SwitcherIdentity } from "./AccountSwitcherIdentity"; 6 6 import { AccountSwitcherMenuList } from "./AccountSwitcherMenuList";
+1 -1
src/components/account/AccountSwitcherMenuList.tsx
··· 1 + import type { AccountSummary } from "$/lib/types"; 1 2 import { For, Show } from "solid-js"; 2 - import { AccountSummary } from "../../lib/types"; 3 3 import { AccountSwitcherRow } from "./AccountSwitcherRow"; 4 4 5 5 export function AccountSwitcherMenuList(
+3 -3
src/components/account/AccountSwitcherRow.tsx
··· 1 - import { AccountSummary } from "../../lib/types"; 2 - import { AvatarBadge } from "../AvatarBadge"; 3 - import { Icon } from "../shared/Icon"; 1 + import { AvatarBadge } from "$/components/AvatarBadge"; 2 + import { Icon } from "$/components/shared/Icon"; 3 + import type { AccountSummary } from "$/lib/types"; 4 4 5 5 type AccountSwitcherRowProps = { 6 6 account: AccountSummary;
+1 -1
src/components/feeds/FeedChipAvatar.tsx
··· 1 1 import { Icon } from "$/components/shared/Icon"; 2 - import { FeedGeneratorView, SavedFeedItem } from "$/lib/types"; 2 + import type { FeedGeneratorView, SavedFeedItem } from "$/lib/types"; 3 3 import { createMemo, Show } from "solid-js"; 4 4 5 5 export function FeedChipAvatar(props: { feed: SavedFeedItem; generator?: FeedGeneratorView }) {
+21
src/components/feeds/FeedComposer.tsx
··· 6 6 7 7 type ComposerSuggestion = { label: string; type: "handle" | "hashtag" }; 8 8 9 + export function ComposerLauncher(props: { activeHandle: string; onCompose: () => void }) { 10 + return ( 11 + <button 12 + class="mb-4 flex w-full items-center gap-3 rounded-3xl border-0 bg-white/3 px-4 py-4 text-left text-on-surface-variant shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)] transition duration-150 ease-out hover:bg-white/5" 13 + type="button" 14 + onClick={() => props.onCompose()}> 15 + <div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[linear-gradient(135deg,rgba(125,175,255,0.9),rgba(0,115,222,0.72))] text-sm font-semibold text-on-primary-fixed"> 16 + {props.activeHandle.slice(0, 1).toUpperCase()} 17 + </div> 18 + <div class="min-w-0 flex-1"> 19 + <p class="m-0 wrap-break-word text-[0.9rem] text-on-surface-variant">What's happening?</p> 20 + </div> 21 + <div class="flex items-center gap-1 text-on-surface-variant"> 22 + <Icon aria-hidden="true" kind="at" /> 23 + <Icon aria-hidden="true" kind="hashtag" /> 24 + <Icon aria-hidden="true" kind="quote" /> 25 + </div> 26 + </button> 27 + ); 28 + } 29 + 9 30 type FeedComposerProps = { 10 31 activeHandle: string | null; 11 32 open: boolean;
+88
src/components/feeds/FeedContent.tsx
··· 1 + import { getReplyRootPost } from "$/lib/feeds"; 2 + import type { FeedViewPost, PostView } from "$/lib/types"; 3 + import { For, Show } from "solid-js"; 4 + import { Motion, Presence } from "solid-motionone"; 5 + import { EmptyFeedState, FeedSkeleton, LoadingMoreIndicator } from "./FeedEmpty"; 6 + import { PostCard } from "./PostCard"; 7 + import type { FeedState } from "./types"; 8 + 9 + function FeedStatus(props: { activeFeedState: FeedState | undefined; visibleItems: FeedViewPost[] }) { 10 + const loading = () => !props.activeFeedState || props.activeFeedState.loading; 11 + 12 + return ( 13 + <> 14 + <Show when={loading()}> 15 + <FeedSkeleton /> 16 + </Show> 17 + <Show when={props.activeFeedState?.error}> 18 + {(message) => ( 19 + <div class="rounded-3xl bg-[rgba(138,31,31,0.2)] p-4 text-sm text-error shadow-[inset_0_0_0_1px_rgba(255,128,128,0.2)]"> 20 + {message()} 21 + </div> 22 + )} 23 + </Show> 24 + <Show when={!loading() && !props.activeFeedState?.error && props.visibleItems.length === 0}> 25 + <EmptyFeedState /> 26 + </Show> 27 + </> 28 + ); 29 + } 30 + 31 + export function FeedContent( 32 + props: { 33 + activeFeedId: string; 34 + activeFeedState: FeedState | undefined; 35 + focusedIndex: number; 36 + likePendingByUri: Record<string, boolean>; 37 + likePulseUri: string | null; 38 + onFocusIndex: (index: number) => void; 39 + onLike: (post: PostView) => Promise<void>; 40 + onOpenThread: (uri: string) => Promise<void>; 41 + onQuote: (post: PostView) => void; 42 + onReply: (post: PostView, root: PostView) => void; 43 + onRepost: (post: PostView) => Promise<void>; 44 + postRefs: Map<string, HTMLElement>; 45 + repostPendingByUri: Record<string, boolean>; 46 + repostPulseUri: string | null; 47 + sentinelRef: (element: HTMLDivElement) => void; 48 + visibleItems: FeedViewPost[]; 49 + }, 50 + ) { 51 + return ( 52 + <Presence exitBeforeEnter> 53 + <For each={[props.activeFeedId]}> 54 + {() => ( 55 + <Motion.div 56 + class="grid gap-3" 57 + initial={{ opacity: 0 }} 58 + animate={{ opacity: 1 }} 59 + exit={{ opacity: 0 }} 60 + transition={{ duration: 0.2 }}> 61 + <FeedStatus activeFeedState={props.activeFeedState} visibleItems={props.visibleItems} /> 62 + <For each={props.visibleItems}> 63 + {(item, index) => ( 64 + <PostCard 65 + focused={props.focusedIndex === index()} 66 + item={item} 67 + likePending={!!props.likePendingByUri[item.post.uri]} 68 + onFocus={() => props.onFocusIndex(index())} 69 + onLike={() => void props.onLike(item.post)} 70 + onOpenThread={() => void props.onOpenThread(item.post.uri)} 71 + onQuote={() => props.onQuote(item.post)} 72 + onReply={() => props.onReply(item.post, getReplyRootPost(item))} 73 + onRepost={() => void props.onRepost(item.post)} 74 + post={item.post} 75 + pulseLike={props.likePulseUri === item.post.uri} 76 + pulseRepost={props.repostPulseUri === item.post.uri} 77 + registerRef={(element) => props.postRefs.set(item.post.uri, element)} 78 + repostPending={!!props.repostPendingByUri[item.post.uri]} /> 79 + )} 80 + </For> 81 + <div ref={(element) => props.sentinelRef(element)} /> 82 + <LoadingMoreIndicator loading={!!props.activeFeedState?.loadingMore} /> 83 + </Motion.div> 84 + )} 85 + </For> 86 + </Presence> 87 + ); 88 + }
+52
src/components/feeds/FeedEmpty.tsx
··· 1 + import { Show } from "solid-js"; 2 + import { Icon } from "../shared/Icon"; 3 + 4 + export function LoadingMoreIndicator(props: { loading: boolean }) { 5 + return ( 6 + <Show when={props.loading}> 7 + <div class="flex items-center justify-center py-4 text-sm text-on-surface-variant"> 8 + <Icon aria-hidden="true" class="animate-spin" iconClass="i-ri-loader-4-line" /> 9 + <span class="ml-2">Loading more</span> 10 + </div> 11 + </Show> 12 + ); 13 + } 14 + 15 + export function EmptyFeedState() { 16 + return ( 17 + <div class="rounded-[1.6rem] bg-white/3 p-8 text-center shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 18 + <p class="m-0 text-[1rem] font-semibold text-on-surface">Nothing to show yet</p> 19 + <p class="mt-2 text-sm leading-[1.6] text-on-surface-variant"> 20 + This feed is empty with the current filters. Try another tab or loosen the display settings. 21 + </p> 22 + </div> 23 + ); 24 + } 25 + 26 + function SkeletonCard() { 27 + return ( 28 + <div class="rounded-3xl bg-white/3 p-5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)]"> 29 + <div class="flex gap-3"> 30 + <div class="skeleton-block h-11 w-11 rounded-full" /> 31 + <div class="min-w-0 flex-1"> 32 + <div class="skeleton-block h-4 w-48 rounded-full" /> 33 + <div class="mt-3 grid gap-2"> 34 + <div class="skeleton-block h-3.5 w-full rounded-full" /> 35 + <div class="skeleton-block h-3.5 w-[88%] rounded-full" /> 36 + <div class="skeleton-block h-3.5 w-[70%] rounded-full" /> 37 + </div> 38 + </div> 39 + </div> 40 + </div> 41 + ); 42 + } 43 + 44 + export function FeedSkeleton() { 45 + return ( 46 + <div class="grid gap-3"> 47 + <SkeletonCard /> 48 + <SkeletonCard /> 49 + <SkeletonCard /> 50 + </div> 51 + ); 52 + }
+191
src/components/feeds/FeedPane.tsx
··· 1 + import { Icon } from "$/components/shared/Icon"; 2 + import { getFeedName } from "$/lib/feeds"; 3 + import type { FeedGeneratorView, FeedViewPost, PostView, SavedFeedItem } from "$/lib/types"; 4 + import { ComposerLauncher } from "./FeedComposer"; 5 + import { FeedContent } from "./FeedContent"; 6 + import { FeedTabBar } from "./FeedTabs"; 7 + import type { FeedState } from "./types"; 8 + 9 + function FeedHeaderActions(props: { onCompose: () => void; onToggleDrawer: () => void }) { 10 + return ( 11 + <div class="flex shrink-0 items-center gap-2 max-[640px]:justify-between"> 12 + <button 13 + class="inline-flex h-11 items-center gap-2 rounded-full border-0 bg-white/5 px-4 text-sm text-on-surface transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8" 14 + type="button" 15 + onClick={() => props.onCompose()}> 16 + <Icon aria-hidden="true" kind="quill" /> 17 + <span>New post</span> 18 + </button> 19 + <button 20 + 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" 21 + type="button" 22 + onClick={() => props.onToggleDrawer()}> 23 + <Icon aria-hidden="true" kind="menu" /> 24 + </button> 25 + </div> 26 + ); 27 + } 28 + 29 + function FeedScroller( 30 + props: { 31 + activeFeedId: string; 32 + activeFeedState: FeedState | undefined; 33 + activeHandle: string; 34 + focusedIndex: number; 35 + generators: Record<string, FeedGeneratorView>; 36 + likePendingByUri: Record<string, boolean>; 37 + likePulseUri: string | null; 38 + onCompose: () => void; 39 + onFocusIndex: (index: number) => void; 40 + onLike: (post: PostView) => Promise<void>; 41 + onOpenThread: (uri: string) => Promise<void>; 42 + onQuote: (post: PostView) => void; 43 + onReply: (post: PostView, root: PostView) => void; 44 + onRepost: (post: PostView) => Promise<void>; 45 + postRefs: Map<string, HTMLElement>; 46 + repostPendingByUri: Record<string, boolean>; 47 + repostPulseUri: string | null; 48 + scrollerRef: (element: HTMLDivElement) => void; 49 + sentinelRef: (element: HTMLDivElement) => void; 50 + setScrollTop: (top: number) => void; 51 + visibleItems: FeedViewPost[]; 52 + }, 53 + ) { 54 + return ( 55 + <div 56 + ref={(element) => props.scrollerRef(element)} 57 + class="feed-scroll-region min-h-0 overflow-y-auto overscroll-contain px-6 pb-8 pt-4" 58 + onScroll={(event) => props.setScrollTop(event.currentTarget.scrollTop)}> 59 + <ComposerLauncher activeHandle={props.activeHandle} onCompose={props.onCompose} /> 60 + <FeedContent 61 + activeFeedId={props.activeFeedId} 62 + activeFeedState={props.activeFeedState} 63 + focusedIndex={props.focusedIndex} 64 + likePendingByUri={props.likePendingByUri} 65 + likePulseUri={props.likePulseUri} 66 + onFocusIndex={props.onFocusIndex} 67 + onLike={props.onLike} 68 + onOpenThread={props.onOpenThread} 69 + onQuote={props.onQuote} 70 + onReply={props.onReply} 71 + onRepost={props.onRepost} 72 + postRefs={props.postRefs} 73 + repostPendingByUri={props.repostPendingByUri} 74 + repostPulseUri={props.repostPulseUri} 75 + sentinelRef={props.sentinelRef} 76 + visibleItems={props.visibleItems} /> 77 + </div> 78 + ); 79 + } 80 + 81 + function FeedPaneTitle( 82 + props: { 83 + activeFeed: SavedFeedItem; 84 + generators: Record<string, FeedGeneratorView>; 85 + onCompose: () => void; 86 + onToggleDrawer: () => void; 87 + }, 88 + ) { 89 + return ( 90 + <div class="flex items-start justify-between gap-4 max-[640px]:flex-col max-[640px]:items-stretch"> 91 + <div class="min-w-0"> 92 + <p class="m-0 text-xl font-semibold tracking-tight text-on-surface">Timeline</p> 93 + <p class="mt-1 wrap-break-word text-xs uppercase tracking-[0.12em] text-on-surface-variant"> 94 + {getFeedName(props.activeFeed, props.generators[props.activeFeed.value]?.displayName)} 95 + </p> 96 + </div> 97 + <FeedHeaderActions onCompose={props.onCompose} onToggleDrawer={props.onToggleDrawer} /> 98 + </div> 99 + ); 100 + } 101 + 102 + function FeedPaneHeader( 103 + props: { 104 + activeFeed: SavedFeedItem; 105 + generators: Record<string, FeedGeneratorView>; 106 + onCompose: () => void; 107 + onFeedSelect: (feedId: string) => void; 108 + onToggleDrawer: () => void; 109 + pinnedFeeds: SavedFeedItem[]; 110 + }, 111 + ) { 112 + return ( 113 + <header class="sticky top-0 z-20 rounded-t-4xl bg-[rgba(14,14,14,0.94)] px-6 pb-3 pt-5 backdrop-blur-[18px] shadow-[inset_0_-1px_0_rgba(255,255,255,0.04)]"> 114 + <FeedPaneTitle 115 + activeFeed={props.activeFeed} 116 + generators={props.generators} 117 + onCompose={props.onCompose} 118 + onToggleDrawer={props.onToggleDrawer} /> 119 + <FeedTabBar 120 + activeFeedId={props.activeFeed.id} 121 + generators={props.generators} 122 + onFeedSelect={props.onFeedSelect} 123 + onToggleDrawer={props.onToggleDrawer} 124 + pinnedFeeds={props.pinnedFeeds} /> 125 + </header> 126 + ); 127 + } 128 + 129 + export function FeedPane( 130 + props: { 131 + activeFeed: SavedFeedItem; 132 + activeFeedId: string; 133 + activeFeedState: FeedState | undefined; 134 + activeHandle: string; 135 + focusedIndex: number; 136 + generators: Record<string, FeedGeneratorView>; 137 + likePendingByUri: Record<string, boolean>; 138 + likePulseUri: string | null; 139 + onCompose: () => void; 140 + onFeedSelect: (feedId: string) => void; 141 + onFocusIndex: (index: number) => void; 142 + onLike: (post: PostView) => Promise<void>; 143 + onOpenThread: (uri: string) => Promise<void>; 144 + onQuote: (post: PostView) => void; 145 + onReply: (post: PostView, root: PostView) => void; 146 + onRepost: (post: PostView) => Promise<void>; 147 + onToggleDrawer: () => void; 148 + pinnedFeeds: SavedFeedItem[]; 149 + postRefs: Map<string, HTMLElement>; 150 + repostPendingByUri: Record<string, boolean>; 151 + repostPulseUri: string | null; 152 + scrollerRef: (element: HTMLDivElement) => void; 153 + sentinelRef: (element: HTMLDivElement) => void; 154 + setScrollTop: (top: number) => void; 155 + visibleItems: FeedViewPost[]; 156 + }, 157 + ) { 158 + return ( 159 + <section class="grid min-h-0 grid-rows-[auto_minmax(0,1fr)] rounded-4xl bg-[rgba(8,8,8,0.32)] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)]"> 160 + <FeedPaneHeader 161 + activeFeed={props.activeFeed} 162 + generators={props.generators} 163 + onCompose={props.onCompose} 164 + onFeedSelect={props.onFeedSelect} 165 + onToggleDrawer={props.onToggleDrawer} 166 + pinnedFeeds={props.pinnedFeeds} /> 167 + <FeedScroller 168 + activeFeedId={props.activeFeedId} 169 + activeFeedState={props.activeFeedState} 170 + activeHandle={props.activeHandle} 171 + focusedIndex={props.focusedIndex} 172 + generators={props.generators} 173 + likePendingByUri={props.likePendingByUri} 174 + likePulseUri={props.likePulseUri} 175 + onCompose={props.onCompose} 176 + onFocusIndex={props.onFocusIndex} 177 + onLike={props.onLike} 178 + onOpenThread={props.onOpenThread} 179 + onQuote={props.onQuote} 180 + onReply={props.onReply} 181 + onRepost={props.onRepost} 182 + postRefs={props.postRefs} 183 + repostPendingByUri={props.repostPendingByUri} 184 + repostPulseUri={props.repostPulseUri} 185 + scrollerRef={props.scrollerRef} 186 + sentinelRef={props.sentinelRef} 187 + setScrollTop={props.setScrollTop} 188 + visibleItems={props.visibleItems} /> 189 + </section> 190 + ); 191 + }
+2 -372
src/components/feeds/FeedWorkspace.tsx
··· 16 16 EmbedInput, 17 17 FeedGeneratorView, 18 18 FeedResponse, 19 - FeedViewPost, 20 19 FeedViewPrefItem, 21 20 PostView, 22 21 ReplyRefInput, 23 22 SavedFeedItem, 24 - ThreadNode, 25 23 ThreadResponse, 26 24 UserPreferences, 27 25 } from "$/lib/types"; ··· 33 31 import { Motion, Presence } from "solid-motionone"; 34 32 import { FeedChipAvatar } from "./FeedChipAvatar"; 35 33 import { FeedComposer } from "./FeedComposer"; 36 - import { FeedTabBar } from "./FeedTabs"; 37 - import { PostCard } from "./PostCard"; 34 + import { FeedPane } from "./FeedPane"; 38 35 import { ThreadPanel } from "./ThreadPanel"; 36 + import type { FeedState, FeedWorkspaceState } from "./types"; 39 37 40 38 type FeedWorkspaceProps = { 41 39 activeSession: ActiveSession; 42 40 onError: (message: string) => void; 43 41 onThreadRouteChange: (uri: string | null) => void; 44 42 threadUri: string | null; 45 - }; 46 - 47 - type FeedState = { 48 - cursor: string | null; 49 - error: string | null; 50 - items: FeedViewPost[]; 51 - loading: boolean; 52 - loadingMore: boolean; 53 - scrollTop: number; 54 - }; 55 - 56 - type FeedWorkspaceState = { 57 - activeFeedId: string | null; 58 - composer: { 59 - open: boolean; 60 - pending: boolean; 61 - quoteTarget: PostView | null; 62 - replyRoot: PostView | null; 63 - replyTarget: PostView | null; 64 - text: string; 65 - }; 66 - feedStates: Record<string, FeedState>; 67 - focusedIndex: number; 68 - generators: Record<string, FeedGeneratorView>; 69 - likePendingByUri: Record<string, boolean>; 70 - likePulseUri: string | null; 71 - localPrefs: Record<string, FeedViewPrefItem>; 72 - preferences: UserPreferences | null; 73 - repostPendingByUri: Record<string, boolean>; 74 - repostPulseUri: string | null; 75 - showFeedsDrawer: boolean; 76 - thread: { data: ThreadNode | null; error: string | null; loading: boolean; uri: string | null }; 77 43 }; 78 44 79 45 const DEFAULT_LIMIT = 30; ··· 686 652 } 687 653 } 688 654 689 - function FeedPane( 690 - props: { 691 - activeFeed: SavedFeedItem; 692 - activeFeedId: string; 693 - activeFeedState: FeedState | undefined; 694 - activeHandle: string; 695 - focusedIndex: number; 696 - generators: Record<string, FeedGeneratorView>; 697 - likePendingByUri: Record<string, boolean>; 698 - likePulseUri: string | null; 699 - onCompose: () => void; 700 - onFeedSelect: (feedId: string) => void; 701 - onFocusIndex: (index: number) => void; 702 - onLike: (post: PostView) => Promise<void>; 703 - onOpenThread: (uri: string) => Promise<void>; 704 - onQuote: (post: PostView) => void; 705 - onReply: (post: PostView, root: PostView) => void; 706 - onRepost: (post: PostView) => Promise<void>; 707 - onToggleDrawer: () => void; 708 - pinnedFeeds: SavedFeedItem[]; 709 - postRefs: Map<string, HTMLElement>; 710 - repostPendingByUri: Record<string, boolean>; 711 - repostPulseUri: string | null; 712 - scrollerRef: (element: HTMLDivElement) => void; 713 - sentinelRef: (element: HTMLDivElement) => void; 714 - setScrollTop: (top: number) => void; 715 - visibleItems: FeedViewPost[]; 716 - }, 717 - ) { 718 - return ( 719 - <section class="grid min-h-0 grid-rows-[auto_minmax(0,1fr)] rounded-4xl bg-[rgba(8,8,8,0.32)] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)]"> 720 - <FeedPaneHeader 721 - activeFeed={props.activeFeed} 722 - generators={props.generators} 723 - onCompose={props.onCompose} 724 - onFeedSelect={props.onFeedSelect} 725 - onToggleDrawer={props.onToggleDrawer} 726 - pinnedFeeds={props.pinnedFeeds} /> 727 - <FeedScroller 728 - activeFeedId={props.activeFeedId} 729 - activeFeedState={props.activeFeedState} 730 - activeHandle={props.activeHandle} 731 - focusedIndex={props.focusedIndex} 732 - generators={props.generators} 733 - likePendingByUri={props.likePendingByUri} 734 - likePulseUri={props.likePulseUri} 735 - onCompose={props.onCompose} 736 - onFocusIndex={props.onFocusIndex} 737 - onLike={props.onLike} 738 - onOpenThread={props.onOpenThread} 739 - onQuote={props.onQuote} 740 - onReply={props.onReply} 741 - onRepost={props.onRepost} 742 - postRefs={props.postRefs} 743 - repostPendingByUri={props.repostPendingByUri} 744 - repostPulseUri={props.repostPulseUri} 745 - scrollerRef={props.scrollerRef} 746 - sentinelRef={props.sentinelRef} 747 - setScrollTop={props.setScrollTop} 748 - visibleItems={props.visibleItems} /> 749 - </section> 750 - ); 751 - } 752 - 753 - function FeedPaneHeader( 754 - props: { 755 - activeFeed: SavedFeedItem; 756 - generators: Record<string, FeedGeneratorView>; 757 - onCompose: () => void; 758 - onFeedSelect: (feedId: string) => void; 759 - onToggleDrawer: () => void; 760 - pinnedFeeds: SavedFeedItem[]; 761 - }, 762 - ) { 763 - return ( 764 - <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)]"> 765 - <FeedPaneTitle 766 - activeFeed={props.activeFeed} 767 - generators={props.generators} 768 - onCompose={props.onCompose} 769 - onToggleDrawer={props.onToggleDrawer} /> 770 - <FeedTabBar 771 - activeFeedId={props.activeFeed.id} 772 - generators={props.generators} 773 - onFeedSelect={props.onFeedSelect} 774 - onToggleDrawer={props.onToggleDrawer} 775 - pinnedFeeds={props.pinnedFeeds} /> 776 - </header> 777 - ); 778 - } 779 - 780 - function FeedPaneTitle( 781 - props: { 782 - activeFeed: SavedFeedItem; 783 - generators: Record<string, FeedGeneratorView>; 784 - onCompose: () => void; 785 - onToggleDrawer: () => void; 786 - }, 787 - ) { 788 - return ( 789 - <div class="flex items-start justify-between gap-4 max-[640px]:flex-col max-[640px]:items-stretch"> 790 - <div class="min-w-0"> 791 - <p class="m-0 text-xl font-semibold tracking-tight text-on-surface">Timeline</p> 792 - <p class="mt-1 wrap-break-word text-xs uppercase tracking-[0.12em] text-on-surface-variant"> 793 - {getFeedName(props.activeFeed, props.generators[props.activeFeed.value]?.displayName)} 794 - </p> 795 - </div> 796 - <FeedHeaderActions onCompose={props.onCompose} onToggleDrawer={props.onToggleDrawer} /> 797 - </div> 798 - ); 799 - } 800 - 801 - function FeedHeaderActions(props: { onCompose: () => void; onToggleDrawer: () => void }) { 802 - return ( 803 - <div class="flex shrink-0 items-center gap-2 max-[640px]:justify-between"> 804 - <button 805 - class="inline-flex h-11 items-center gap-2 rounded-full border-0 bg-white/5 px-4 text-sm text-on-surface transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8" 806 - type="button" 807 - onClick={() => props.onCompose()}> 808 - <Icon aria-hidden="true" kind="quill" /> 809 - <span>New post</span> 810 - </button> 811 - <button 812 - class="inline-flex h-11 w-11 items-center justify-center rounded-full border-0 bg-white/5 text-on-surface transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8" 813 - type="button" 814 - onClick={() => props.onToggleDrawer()}> 815 - <Icon aria-hidden="true" kind="menu" /> 816 - </button> 817 - </div> 818 - ); 819 - } 820 - 821 - function FeedScroller( 822 - props: { 823 - activeFeedId: string; 824 - activeFeedState: FeedState | undefined; 825 - activeHandle: string; 826 - focusedIndex: number; 827 - generators: Record<string, FeedGeneratorView>; 828 - likePendingByUri: Record<string, boolean>; 829 - likePulseUri: string | null; 830 - onCompose: () => void; 831 - onFocusIndex: (index: number) => void; 832 - onLike: (post: PostView) => Promise<void>; 833 - onOpenThread: (uri: string) => Promise<void>; 834 - onQuote: (post: PostView) => void; 835 - onReply: (post: PostView, root: PostView) => void; 836 - onRepost: (post: PostView) => Promise<void>; 837 - postRefs: Map<string, HTMLElement>; 838 - repostPendingByUri: Record<string, boolean>; 839 - repostPulseUri: string | null; 840 - scrollerRef: (element: HTMLDivElement) => void; 841 - sentinelRef: (element: HTMLDivElement) => void; 842 - setScrollTop: (top: number) => void; 843 - visibleItems: FeedViewPost[]; 844 - }, 845 - ) { 846 - return ( 847 - <div 848 - ref={(element) => props.scrollerRef(element)} 849 - class="feed-scroll-region min-h-0 overflow-y-auto overscroll-contain px-6 pb-8 pt-4" 850 - onScroll={(event) => props.setScrollTop(event.currentTarget.scrollTop)}> 851 - <ComposerLauncher activeHandle={props.activeHandle} onCompose={props.onCompose} /> 852 - <FeedContent 853 - activeFeedId={props.activeFeedId} 854 - activeFeedState={props.activeFeedState} 855 - focusedIndex={props.focusedIndex} 856 - likePendingByUri={props.likePendingByUri} 857 - likePulseUri={props.likePulseUri} 858 - onFocusIndex={props.onFocusIndex} 859 - onLike={props.onLike} 860 - onOpenThread={props.onOpenThread} 861 - onQuote={props.onQuote} 862 - onReply={props.onReply} 863 - onRepost={props.onRepost} 864 - postRefs={props.postRefs} 865 - repostPendingByUri={props.repostPendingByUri} 866 - repostPulseUri={props.repostPulseUri} 867 - sentinelRef={props.sentinelRef} 868 - visibleItems={props.visibleItems} /> 869 - </div> 870 - ); 871 - } 872 - 873 - function ComposerLauncher(props: { activeHandle: string; onCompose: () => void }) { 874 - return ( 875 - <button 876 - 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" 877 - type="button" 878 - onClick={() => props.onCompose()}> 879 - <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"> 880 - {props.activeHandle.slice(0, 1).toUpperCase()} 881 - </div> 882 - <div class="min-w-0 flex-1"> 883 - <p class="m-0 wrap-break-word text-[0.9rem] text-on-surface-variant">What's happening?</p> 884 - </div> 885 - <div class="flex items-center gap-1 text-on-surface-variant"> 886 - <Icon aria-hidden="true" iconClass="i-ri-at-line" /> 887 - <Icon aria-hidden="true" iconClass="i-ri-hashtag" /> 888 - <Icon aria-hidden="true" iconClass="i-ri-chat-quote-line" /> 889 - </div> 890 - </button> 891 - ); 892 - } 893 - 894 - function FeedContent( 895 - props: { 896 - activeFeedId: string; 897 - activeFeedState: FeedState | undefined; 898 - focusedIndex: number; 899 - likePendingByUri: Record<string, boolean>; 900 - likePulseUri: string | null; 901 - onFocusIndex: (index: number) => void; 902 - onLike: (post: PostView) => Promise<void>; 903 - onOpenThread: (uri: string) => Promise<void>; 904 - onQuote: (post: PostView) => void; 905 - onReply: (post: PostView, root: PostView) => void; 906 - onRepost: (post: PostView) => Promise<void>; 907 - postRefs: Map<string, HTMLElement>; 908 - repostPendingByUri: Record<string, boolean>; 909 - repostPulseUri: string | null; 910 - sentinelRef: (element: HTMLDivElement) => void; 911 - visibleItems: FeedViewPost[]; 912 - }, 913 - ) { 914 - return ( 915 - <Presence exitBeforeEnter> 916 - <For each={[props.activeFeedId]}> 917 - {() => ( 918 - <Motion.div 919 - class="grid gap-3" 920 - initial={{ opacity: 0 }} 921 - animate={{ opacity: 1 }} 922 - exit={{ opacity: 0 }} 923 - transition={{ duration: 0.2 }}> 924 - <FeedStatus activeFeedState={props.activeFeedState} visibleItems={props.visibleItems} /> 925 - <For each={props.visibleItems}> 926 - {(item, index) => ( 927 - <PostCard 928 - focused={props.focusedIndex === index()} 929 - item={item} 930 - likePending={!!props.likePendingByUri[item.post.uri]} 931 - onFocus={() => props.onFocusIndex(index())} 932 - onLike={() => void props.onLike(item.post)} 933 - onOpenThread={() => void props.onOpenThread(item.post.uri)} 934 - onQuote={() => props.onQuote(item.post)} 935 - onReply={() => props.onReply(item.post, getReplyRootPost(item))} 936 - onRepost={() => void props.onRepost(item.post)} 937 - post={item.post} 938 - pulseLike={props.likePulseUri === item.post.uri} 939 - pulseRepost={props.repostPulseUri === item.post.uri} 940 - registerRef={(element) => props.postRefs.set(item.post.uri, element)} 941 - repostPending={!!props.repostPendingByUri[item.post.uri]} /> 942 - )} 943 - </For> 944 - <div ref={(element) => props.sentinelRef(element)} /> 945 - <LoadingMoreIndicator loading={!!props.activeFeedState?.loadingMore} /> 946 - </Motion.div> 947 - )} 948 - </For> 949 - </Presence> 950 - ); 951 - } 952 - 953 - function FeedStatus(props: { activeFeedState: FeedState | undefined; visibleItems: FeedViewPost[] }) { 954 - const loading = () => !props.activeFeedState || props.activeFeedState.loading; 955 - 956 - return ( 957 - <> 958 - <Show when={loading()}> 959 - <FeedSkeleton /> 960 - </Show> 961 - <Show when={props.activeFeedState?.error}> 962 - {(message) => ( 963 - <div class="rounded-3xl bg-[rgba(138,31,31,0.2)] p-4 text-sm text-error shadow-[inset_0_0_0_1px_rgba(255,128,128,0.2)]"> 964 - {message()} 965 - </div> 966 - )} 967 - </Show> 968 - <Show when={!loading() && !props.activeFeedState?.error && props.visibleItems.length === 0}> 969 - <EmptyFeedState /> 970 - </Show> 971 - </> 972 - ); 973 - } 974 - 975 - function LoadingMoreIndicator(props: { loading: boolean }) { 976 - return ( 977 - <Show when={props.loading}> 978 - <div class="flex items-center justify-center py-4 text-sm text-on-surface-variant"> 979 - <Icon aria-hidden="true" class="animate-spin" iconClass="i-ri-loader-4-line" /> 980 - <span class="ml-2">Loading more</span> 981 - </div> 982 - </Show> 983 - ); 984 - } 985 - 986 655 function WorkspaceSidebar( 987 656 props: { 988 657 activePref: UserPreferences["feedViewPrefs"][number]; ··· 1209 878 </div> 1210 879 ); 1211 880 } 1212 - 1213 - function FeedSkeleton() { 1214 - return ( 1215 - <div class="grid gap-3"> 1216 - <SkeletonCard /> 1217 - <SkeletonCard /> 1218 - <SkeletonCard /> 1219 - </div> 1220 - ); 1221 - } 1222 - 1223 - function SkeletonCard() { 1224 - return ( 1225 - <div class="rounded-3xl bg-white/3 p-5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)]"> 1226 - <div class="flex gap-3"> 1227 - <div class="skeleton-block h-11 w-11 rounded-full" /> 1228 - <div class="min-w-0 flex-1"> 1229 - <div class="skeleton-block h-4 w-48 rounded-full" /> 1230 - <div class="mt-3 grid gap-2"> 1231 - <div class="skeleton-block h-3.5 w-full rounded-full" /> 1232 - <div class="skeleton-block h-3.5 w-[88%] rounded-full" /> 1233 - <div class="skeleton-block h-3.5 w-[70%] rounded-full" /> 1234 - </div> 1235 - </div> 1236 - </div> 1237 - </div> 1238 - ); 1239 - } 1240 - 1241 - function EmptyFeedState() { 1242 - return ( 1243 - <div class="rounded-[1.6rem] bg-white/3 p-8 text-center shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 1244 - <p class="m-0 text-[1rem] font-semibold text-on-surface">Nothing to show yet</p> 1245 - <p class="mt-2 text-sm leading-[1.6] text-on-surface-variant"> 1246 - This feed is empty with the current filters. Try another tab or loosen the display settings. 1247 - </p> 1248 - </div> 1249 - ); 1250 - }
+5 -5
src/components/feeds/PostCard.test.tsx
··· 8 8 cid: "cid-post", 9 9 indexedAt: "2026-03-28T12:00:00.000Z", 10 10 likeCount: 4, 11 - record: { 12 - createdAt: "2026-03-28T12:00:00.000Z", 13 - text: "Visit https://example.com @bob.test #solid", 14 - }, 11 + record: { createdAt: "2026-03-28T12:00:00.000Z", text: "Visit https://example.com @bob.test #solid" }, 15 12 replyCount: 2, 16 13 repostCount: 1, 17 14 uri: "at://did:plc:alice/app.bsky.feed.post/123", ··· 32 29 const onOpenThread = vi.fn(); 33 30 render(() => <PostCard post={createPost()} onOpenThread={onOpenThread} />); 34 31 35 - await fireEvent.keyDown(screen.getByRole("article"), { key: "Enter" }); 32 + await new Promise((resolve) => { 33 + fireEvent.keyDown(screen.getByRole("article"), { key: "Enter" }); 34 + resolve(void 0); 35 + }); 36 36 37 37 expect(onOpenThread).toHaveBeenCalledTimes(1); 38 38 });
+40
src/components/feeds/types.ts
··· 1 + import type { 2 + FeedGeneratorView, 3 + FeedViewPost, 4 + FeedViewPrefItem, 5 + PostView, 6 + ThreadNode, 7 + UserPreferences, 8 + } from "$/lib/types"; 9 + 10 + export type FeedState = { 11 + cursor: string | null; 12 + error: string | null; 13 + items: FeedViewPost[]; 14 + loading: boolean; 15 + loadingMore: boolean; 16 + scrollTop: number; 17 + }; 18 + 19 + export type FeedWorkspaceState = { 20 + activeFeedId: string | null; 21 + composer: { 22 + open: boolean; 23 + pending: boolean; 24 + quoteTarget: PostView | null; 25 + replyRoot: PostView | null; 26 + replyTarget: PostView | null; 27 + text: string; 28 + }; 29 + feedStates: Record<string, FeedState>; 30 + focusedIndex: number; 31 + generators: Record<string, FeedGeneratorView>; 32 + likePendingByUri: Record<string, boolean>; 33 + likePulseUri: string | null; 34 + localPrefs: Record<string, FeedViewPrefItem>; 35 + preferences: UserPreferences | null; 36 + repostPendingByUri: Record<string, boolean>; 37 + repostPulseUri: string | null; 38 + showFeedsDrawer: boolean; 39 + thread: { data: ThreadNode | null; error: string | null; loading: boolean; uri: string | null }; 40 + };
+4
src/components/shared/Icon.tsx
··· 15 15 | "quill" 16 16 | "at" 17 17 | "hashtag" 18 + | "quote" 18 19 | "close"; 19 20 20 21 type IconProps = JSX.HTMLAttributes<HTMLSpanElement> & { ··· 77 78 </Match> 78 79 <Match when={local.kind === "close"}> 79 80 <i class="i-ri-close-line" /> 81 + </Match> 82 + <Match when={local.kind === "quote"}> 83 + <i class="i-ri-chat-quote-line" /> 80 84 </Match> 81 85 </Switch> 82 86 </span>
+9 -10
src/lib/types.ts
··· 63 63 [key: string]: unknown; 64 64 }; 65 65 66 - export type ImagesEmbedView = { 67 - $type: "app.bsky.embed.images#view"; 68 - images: Array<{ alt?: string; aspectRatio?: { height: number; width: number }; fullsize?: string; thumb?: string }>; 69 - }; 66 + type ImageEmbed = { alt?: string; aspectRatio?: { height: number; width: number }; fullsize?: string; thumb?: string }; 67 + 68 + export type ImagesEmbedView = { $type: "app.bsky.embed.images#view"; images: Array<ImageEmbed> }; 70 69 71 70 export type ExternalEmbedView = { 72 71 $type: "app.bsky.embed.external#view"; ··· 108 107 export type PostView = { 109 108 author: ProfileViewBasic; 110 109 cid: string; 111 - embed: Maybe<EmbedView>; 110 + embed?: EmbedView | null; 112 111 indexedAt: string; 113 - likeCount: Maybe<number>; 114 - quoteCount: Maybe<number>; 112 + likeCount?: number | null; 113 + quoteCount?: number | null; 115 114 record: PostRecord | Record<string, unknown>; 116 - replyCount: Maybe<number>; 117 - repostCount: Maybe<number>; 115 + replyCount?: number | null; 116 + repostCount?: number | null; 118 117 uri: string; 119 - viewer: Maybe<ViewerState>; 118 + viewer?: ViewerState | null; 120 119 }; 121 120 122 121 export type NotFoundPost = { $type: "app.bsky.feed.defs#notFoundPost"; notFound: boolean; uri: string };
+1
tsconfig.json
··· 9 9 "allowImportingTsExtensions": true, 10 10 "resolveJsonModule": true, 11 11 "isolatedModules": true, 12 + "verbatimModuleSyntax": true, 12 13 "noEmit": true, 13 14 "jsx": "preserve", 14 15 "jsxImportSource": "solid-js",