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.

at main 197 lines 7.2 kB view raw
1import { PostCard } from "$/components/feeds/PostCard"; 2import { usePostNavigation } from "$/components/posts/hooks/usePostNavigation"; 3import { Icon } from "$/components/shared/Icon"; 4import { SearchController } from "$/lib/api/search"; 5import type { NetworkSearchResult } from "$/lib/api/types/search"; 6import { 7 buildHashtagQuery, 8 buildPostSearchRoute, 9 decodeHashtagRouteTag, 10 formatHashtagLabel, 11 parsePostSearchFilters, 12 toLocalDayStartIso, 13 toLocalDayUntilIso, 14} from "$/lib/search-routes"; 15import type { PostSearchFilters } from "$/lib/search-routes"; 16import { normalizeError } from "$/lib/utils/text"; 17import { useLocation, useNavigate, useParams } from "@solidjs/router"; 18import * as logger from "@tauri-apps/plugin-log"; 19import { createEffect, createMemo, For, Match, Show, Switch } from "solid-js"; 20import { createStore } from "solid-js/store"; 21import { Motion, Presence } from "solid-motionone"; 22import { PostSearchFiltersRow } from "./PostSearchFilters"; 23import { SearchEmptyState } from "./SearchEmptyState"; 24import type { EmptyStateReason } from "./types"; 25 26type HashtagPanelState = { 27 error: string | null; 28 hasSearched: boolean; 29 loading: boolean; 30 results: NetworkSearchResult | null; 31}; 32 33export function HashtagPanel() { 34 const location = useLocation(); 35 const navigate = useNavigate(); 36 const params = useParams<{ hashtag: string }>(); 37 const postNavigation = usePostNavigation(); 38 const [state, setState] = createStore<HashtagPanelState>({ 39 error: null, 40 hasSearched: false, 41 loading: false, 42 results: null, 43 }); 44 let debounceTimer: ReturnType<typeof setTimeout> | undefined; 45 46 const tag = createMemo(() => decodeHashtagRouteTag(params.hashtag)); 47 const filters = createMemo(() => parsePostSearchFilters(location.search)); 48 const hashtagLabel = createMemo(() => formatHashtagLabel(tag() ?? "")); 49 50 function replaceRoute(next: Partial<PostSearchFilters>) { 51 const currentTag = tag(); 52 if (!currentTag) { 53 return; 54 } 55 56 void navigate(buildPostSearchRoute(location.pathname, location.search, { ...filters(), ...next })); 57 } 58 59 async function performSearch(f: PostSearchFilters, t: string) { 60 try { 61 const results = await SearchController.searchPostsNetwork({ 62 author: f.author || null, 63 limit: 25, 64 mentions: f.mentions || null, 65 query: buildHashtagQuery(t), 66 since: f.since ? toLocalDayStartIso(f.since) : null, 67 sort: f.sort, 68 tags: f.tags, 69 until: f.until ? toLocalDayUntilIso(f.until) : null, 70 }); 71 setState({ error: null, hasSearched: true, loading: false, results }); 72 } catch (error) { 73 const errorMessage = normalizeError(error); 74 logger.error("hashtag search failed", { keyValues: { error: errorMessage, hashtag: t, sort: f.sort } }); 75 setState({ error: errorMessage, hasSearched: true, loading: false, results: null }); 76 } 77 } 78 79 createEffect(() => { 80 const currentTag = tag(); 81 const activeFilters = filters(); 82 clearTimeout(debounceTimer); 83 debounceTimer = setTimeout(() => { 84 if (!currentTag) { 85 setState({ error: "This hashtag could not be opened.", hasSearched: false, loading: false, results: null }); 86 return; 87 } 88 89 setState((previous) => ({ ...previous, error: null, loading: true })); 90 void performSearch(activeFilters, currentTag); 91 }, 300); 92 }); 93 94 return ( 95 <section class="grid min-h-0 grid-rows-[auto_1fr] overflow-hidden rounded-4xl bg-surface-container shadow-[inset_0_0_0_1px_rgba(255,255,255,0.035)]"> 96 <header class="grid gap-4 px-6 pb-5 pt-6"> 97 <HashtagHero hashtagLabel={hashtagLabel()} /> 98 99 <PostSearchFiltersRow 100 collapsible 101 defaultExpanded={hasAdvancedNetworkFilters(filters())} 102 filters={filters()} 103 helperText="Filter this hashtag feed by date window, mentions, author, and additional tags." 104 onChange={(next) => replaceRoute(next)} /> 105 </header> 106 107 <div class="min-h-0 overflow-y-auto px-3 pb-3"> 108 <Show when={state.loading} fallback={<HashtagState {...state} onOpenThread={postNavigation.openPost} />}> 109 <div class="grid gap-2 py-1"> 110 <For each={Array.from({ length: 5 })}> 111 {() => <div class="h-40 animate-pulse rounded-3xl bg-white/4" aria-hidden />} 112 </For> 113 </div> 114 </Show> 115 </div> 116 </section> 117 ); 118} 119 120function hasAdvancedNetworkFilters(filters: PostSearchFilters) { 121 return !!(filters.author || filters.mentions || filters.since || filters.until || filters.tags.length > 0); 122} 123 124function HashtagState(props: HashtagPanelState & { onOpenThread: (uri: string) => void }) { 125 return ( 126 <Presence> 127 <Switch> 128 <Match when={props.error}> 129 <EmptyState reason="error" /> 130 </Match> 131 <Match when={!props.hasSearched}> 132 <EmptyState reason="initial" /> 133 </Match> 134 <Match when={props.results?.posts.length === 0}> 135 <EmptyState reason="no-results" /> 136 </Match> 137 <Match when={props.results}> 138 {(results) => ( 139 <Motion.div 140 class="grid gap-2" 141 initial={{ opacity: 0 }} 142 animate={{ opacity: 1 }} 143 exit={{ opacity: 0 }} 144 transition={{ duration: 0.15 }}> 145 <div class="grid gap-2" role="list"> 146 <For each={results().posts}> 147 {(post, index) => ( 148 <Motion.div 149 role="listitem" 150 initial={{ opacity: 0, y: -6 }} 151 animate={{ opacity: 1, y: 0 }} 152 transition={{ duration: 0.2, delay: Math.min(index() * 0.03, 0.18) }}> 153 <PostCard 154 post={post} 155 showActions={false} 156 onOpenThread={(uri) => props.onOpenThread(uri)} /> 157 </Motion.div> 158 )} 159 </For> 160 </div> 161 </Motion.div> 162 )} 163 </Match> 164 </Switch> 165 </Presence> 166 ); 167} 168 169function EmptyState(props: { reason: EmptyStateReason }) { 170 return ( 171 <Motion.div 172 class="grid place-items-center px-6 py-16" 173 initial={{ opacity: 0 }} 174 animate={{ opacity: 1 }} 175 exit={{ opacity: 0 }} 176 transition={{ duration: 0.15 }}> 177 <SearchEmptyState reason={props.reason} scope="network" /> 178 </Motion.div> 179 ); 180} 181 182function HashtagHero(props: { hashtagLabel: string }) { 183 return ( 184 <div class="flex flex-wrap items-center justify-between gap-4"> 185 <div class="grid gap-2"> 186 <div class="inline-flex items-center gap-2 rounded-full bg-primary/12 px-3 py-1.5 text-xs font-medium uppercase tracking-[0.12em] text-primary"> 187 <Icon kind="hashtag" class="text-sm" /> 188 <span>Hashtag</span> 189 </div> 190 <div class="grid gap-1"> 191 <h1 class="m-0 text-3xl font-semibold tracking-[-0.03em] text-on-surface">{props.hashtagLabel}</h1> 192 <p class="m-0 text-sm text-on-surface-variant">Search Bluesky for this hashtag.</p> 193 </div> 194 </div> 195 </div> 196 ); 197}