BlueSky & more on desktop
lazurite.stormlightlabs.org/
tauri
rust
typescript
bluesky
appview
atproto
solid
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}