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 237 lines 8.5 kB view raw
1import { Icon, LoadingIcon } from "$/components/shared/Icon"; 2import { SearchController } from "$/lib/api/search"; 3import type { SyncStatus } from "$/lib/api/types/search"; 4import { formatRelativeTime } from "$/lib/utils/text"; 5import * as logger from "@tauri-apps/plugin-log"; 6import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"; 7import { Motion, Presence } from "solid-motionone"; 8import { PostCount } from "../shared/PostCount"; 9 10function SourceStatusRow( 11 props: { count: number; cursor?: string | null; isActive: boolean; source: "like" | "bookmark" }, 12) { 13 const label = createMemo(() => (props.source === "like" ? "Liked posts" : "Bookmarked posts")); 14 15 return ( 16 <div class="grid gap-1.5"> 17 <div class="flex items-center justify-between gap-3 text-xs text-on-surface-variant"> 18 <span>{label()}</span> 19 <span>{props.count} synced</span> 20 </div> 21 22 <div class="h-1.5 overflow-hidden rounded-full bg-white/8"> 23 <div 24 class="h-full rounded-full bg-linear-to-r from-primary to-primary-dim transition-opacity" 25 classList={{ "animate-pulse": props.isActive }} 26 style={{ width: props.count > 0 ? "100%" : "0%" }} /> 27 </div> 28 29 <Show when={props.cursor}> 30 <p class="m-0 text-[0.68rem] text-on-surface-variant/70">Resume cursor saved for interrupted sync recovery.</p> 31 </Show> 32 </div> 33 ); 34} 35 36function ReindexButton(props: { isSyncing: boolean; isReindexing: boolean; onReindex: () => void }) { 37 return ( 38 <button 39 type="button" 40 onClick={() => void props.onReindex()} 41 disabled={props.isSyncing || props.isReindexing} 42 class="inline-flex items-center gap-2 rounded-xl border-0 bg-white/6 px-3 py-2 text-xs font-medium text-on-surface-variant transition hover:bg-white/10 hover:text-on-surface disabled:cursor-not-allowed disabled:opacity-50"> 43 <LoadingIcon isLoading={props.isReindexing} class="text-base" fallback={<Icon kind="refresh" />} /> 44 <Show when={props.isReindexing} fallback="Reindex">Reindexing...</Show> 45 </button> 46 ); 47} 48 49function SyncButton(props: { isSyncing: boolean; isReindexing: boolean; onSync: () => void }) { 50 return ( 51 <button 52 type="button" 53 onClick={() => void props.onSync()} 54 disabled={props.isSyncing || props.isReindexing} 55 class="inline-flex items-center gap-2 rounded-xl border-0 bg-white/6 px-3 py-2 text-xs font-medium text-on-surface-variant transition hover:bg-white/10 hover:text-on-surface disabled:cursor-not-allowed disabled:opacity-50"> 56 <LoadingIcon isLoading={props.isSyncing} class="text-base" fallback={<Icon kind="refresh" />} /> 57 <Show when={props.isSyncing} fallback="Sync now">Syncing...</Show> 58 </button> 59 ); 60} 61 62function SyncHeader( 63 props: { 64 hasAnyPosts: boolean; 65 icon: "db"; 66 lastSync: string | null; 67 totalPosts: number; 68 tone: { className: string; label: string }; 69 }, 70) { 71 return ( 72 <div class="flex items-center justify-between gap-3"> 73 <div class="grid gap-1"> 74 <div class="flex items-center gap-2"> 75 <p class="m-0 text-sm font-medium text-on-surface">Sync Status</p> 76 <span 77 class={`rounded-full px-2.5 py-1 text-[0.68rem] font-medium uppercase tracking-[0.12em] ${props.tone.className}`}> 78 {props.tone.label} 79 </span> 80 </div> 81 82 <Show 83 when={props.hasAnyPosts} 84 fallback={ 85 <p class="m-0 text-xs text-on-surface-variant"> 86 Local search stays empty until likes or bookmarks are indexed. 87 </p> 88 }> 89 <PostCount totalPosts={props.totalPosts} lastSync={props.lastSync} /> 90 </Show> 91 </div> 92 93 <span class="grid h-10 w-10 place-items-center rounded-2xl bg-primary/10 text-primary"> 94 <Icon kind={props.icon} class="text-lg" /> 95 </span> 96 </div> 97 ); 98} 99 100function SyncActions( 101 props: { hasAnyPosts: boolean; isReindexing: boolean; isSyncing: boolean; onReindex: () => void; onSync: () => void }, 102) { 103 return ( 104 <div class="flex flex-wrap items-center gap-2"> 105 <Show when={props.hasAnyPosts}> 106 <ReindexButton isSyncing={props.isSyncing} isReindexing={props.isReindexing} onReindex={props.onReindex} /> 107 </Show> 108 109 <SyncButton isSyncing={props.isSyncing} isReindexing={props.isReindexing} onSync={props.onSync} /> 110 </div> 111 ); 112} 113 114type SyncStatusPanelProps = { did: string; onStatusChange?: (status: SyncStatus[]) => void }; 115 116export function SyncStatusPanel(props: SyncStatusPanelProps) { 117 const [syncStatus, setSyncStatus] = createSignal<SyncStatus[]>([]); 118 const [isSyncing, setIsSyncing] = createSignal(false); 119 const [isReindexing, setIsReindexing] = createSignal(false); 120 121 async function loadSyncStatus() { 122 try { 123 const status = await SearchController.getSyncStatus(props.did); 124 setSyncStatus(status); 125 props.onStatusChange?.(status); 126 } catch (error) { 127 logger.error("failed to load sync status", { keyValues: { error: String(error) } }); 128 } 129 } 130 131 async function handleSync() { 132 setIsSyncing(true); 133 try { 134 await SearchController.syncPosts(props.did, "like"); 135 await SearchController.syncPosts(props.did, "bookmark"); 136 await loadSyncStatus(); 137 } catch (error) { 138 logger.error("sync failed", { keyValues: { error: String(error) } }); 139 } finally { 140 setIsSyncing(false); 141 } 142 } 143 144 async function handleReindex() { 145 setIsReindexing(true); 146 try { 147 const count = await SearchController.reindexEmbeddings(); 148 logger.info("reindex complete", { keyValues: { count: String(count) } }); 149 await loadSyncStatus(); 150 } catch (error) { 151 logger.error("reindex failed", { keyValues: { error: String(error) } }); 152 } finally { 153 setIsReindexing(false); 154 } 155 } 156 157 onMount(() => { 158 void loadSyncStatus(); 159 160 const interval = setInterval(() => { 161 void loadSyncStatus(); 162 }, 60_000); 163 164 onCleanup(() => clearInterval(interval)); 165 }); 166 167 const totalPosts = createMemo(() => syncStatus().reduce((sum, status) => sum + (status.postCount ?? 0), 0)); 168 const hasAnyPosts = createMemo(() => totalPosts() > 0); 169 const lastSync = createMemo(() => { 170 const timestamps = syncStatus().map((status) => status.lastSyncedAt).filter(Boolean) as string[]; 171 if (timestamps.length === 0) { 172 return null; 173 } 174 175 const latest = timestamps.toSorted((left, right) => right.localeCompare(left))[0]; 176 return formatRelativeTime(latest); 177 }); 178 const statusTone = createMemo(() => { 179 if (isSyncing() || isReindexing()) { 180 return { className: "bg-primary/15 text-primary", label: isReindexing() ? "Reindexing" : "Syncing" }; 181 } 182 183 if (hasAnyPosts()) { 184 return { className: "bg-emerald-400/15 text-emerald-300", label: "Ready" }; 185 } 186 187 return { className: "bg-white/8 text-on-surface-variant", label: "Empty" }; 188 }); 189 190 return ( 191 <section class="panel-surface grid gap-4 p-5"> 192 <div class="grid gap-3"> 193 <SyncHeader 194 hasAnyPosts={hasAnyPosts()} 195 icon="db" 196 lastSync={lastSync()} 197 totalPosts={totalPosts()} 198 tone={statusTone()} /> 199 <SyncActions 200 hasAnyPosts={hasAnyPosts()} 201 isReindexing={isReindexing()} 202 isSyncing={isSyncing()} 203 onReindex={handleReindex} 204 onSync={handleSync} /> 205 </div> 206 207 <Presence> 208 <Show when={isSyncing() || isReindexing()}> 209 <Motion.div 210 data-testid="sync-activity-bar" 211 class="h-1.5 overflow-hidden rounded-full bg-white/8" 212 initial={{ opacity: 0, height: 0 }} 213 animate={{ opacity: 1, height: "0.375rem" }} 214 exit={{ opacity: 0, height: 0 }} 215 transition={{ duration: 0.2 }}> 216 <Motion.div 217 class="h-full w-2/3 rounded-full bg-linear-to-r from-primary to-primary-dim" 218 animate={{ x: ["-40%", "120%"] }} 219 transition={{ duration: isReindexing() ? 1.8 : 1.1, repeat: Infinity, easing: "linear" }} /> 220 </Motion.div> 221 </Show> 222 </Presence> 223 224 <div class="grid gap-3 rounded-3xl bg-black/20 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.035)]"> 225 <For each={syncStatus()}> 226 {(status) => ( 227 <SourceStatusRow 228 count={status.postCount ?? 0} 229 isActive={isSyncing()} 230 source={status.source} 231 cursor={status.cursor} /> 232 )} 233 </For> 234 </div> 235 </section> 236 ); 237}