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 412 lines 14 kB view raw
1import { useThreadOverlayNavigation } from "$/components/posts/hooks/useThreadOverlayNavigation"; 2import { useAppSession } from "$/contexts/app-session"; 3import { addColumn, getColumns, removeColumn, reorderColumns, updateColumn } from "$/lib/api/columns"; 4import { FeedController } from "$/lib/api/feeds"; 5import type { Column, ColumnKind, ColumnWidth } from "$/lib/api/types/columns"; 6import { getFeedName } from "$/lib/feeds"; 7import type { FeedGeneratorView } from "$/lib/types"; 8import * as logger from "@tauri-apps/plugin-log"; 9import { createEffect, For, onCleanup, onMount, Show } from "solid-js"; 10import { createStore, produce } from "solid-js/store"; 11import { Motion } from "solid-motionone"; 12import { ActionIcon, Icon, LoadingIcon } from "../shared/Icon"; 13import { AddColumnPanel } from "./AddColumnPanel"; 14import { DeckColumn } from "./DeckColumn"; 15import { parseFeedConfig, resolveFeedColumn } from "./helpers"; 16import { type ResolvedFeedColumn } from "./types"; 17 18type DeckState = { 19 addPanelOpen: boolean; 20 columns: Column[]; 21 dragOverId: string | null; 22 error: string | null; 23 feedColumns: Record<string, ResolvedFeedColumn>; 24 loading: boolean; 25}; 26 27function DeckToolbar(props: { columnCount: number; onAdd: () => void }) { 28 return ( 29 <div class="flex shrink-0 items-center justify-between gap-4 pb-5"> 30 <div class="min-w-0"> 31 <p class="m-0 text-xl font-semibold tracking-tight text-on-surface">Deck</p> 32 <p class="m-0 mt-0.5 text-xs uppercase tracking-[0.12em] text-on-surface-variant"> 33 {props.columnCount === 0 ? "No columns" : `${props.columnCount} column${props.columnCount === 1 ? "" : "s"}`} 34 </p> 35 </div> 36 <button 37 type="button" 38 class="inline-flex h-11 items-center gap-2 rounded-full border-0 bg-surface-container-high px-4 text-sm text-on-surface transition duration-150 ease-out hover:-translate-y-px hover:bg-surface-bright" 39 aria-label="Add column (Ctrl+Shift+N)" 40 title="Add column (Ctrl+Shift+N)" 41 onClick={() => props.onAdd()}> 42 <ActionIcon kind="add" /> 43 Add column 44 </button> 45 </div> 46 ); 47} 48 49function EmptyDeck(props: { onAdd: () => void }) { 50 return ( 51 <div class="flex h-full min-h-104 flex-col items-center justify-center gap-4 rounded-[1.75rem] bg-surface-container px-6 text-center shadow-(--inset-shadow)"> 52 <Icon kind="deck" class="text-[1.75rem] text-on-surface-variant opacity-30" /> 53 <div> 54 <p class="m-0 text-sm font-medium text-on-surface">No columns yet</p> 55 <p class="m-0 mt-1 text-xs text-on-surface-variant"> 56 Add a feed, explorer, or diagnostics column to get started. 57 </p> 58 </div> 59 <button 60 type="button" 61 class="inline-flex h-9 items-center gap-2 rounded-full border-0 bg-primary/15 px-4 text-sm font-medium text-primary transition duration-150 hover:-translate-y-px hover:bg-primary/25" 62 onClick={() => props.onAdd()}> 63 <ActionIcon kind="add" /> 64 Add first column 65 </button> 66 </div> 67 ); 68} 69 70function ColumnList( 71 props: { 72 columns: Column[]; 73 dragOverId: string | null; 74 feedColumns: Record<string, ResolvedFeedColumn>; 75 onClose: (id: string) => void; 76 onDragEnd: () => void; 77 onDragOver: (id: string) => void; 78 onDragStart: (id: string) => void; 79 onDrop: (targetId: string) => void; 80 onMoveLeft: (id: string) => void; 81 onMoveRight: (id: string) => void; 82 onOpenThread: (uri: string) => void; 83 onWidthChange: (id: string, width: ColumnWidth) => void; 84 }, 85) { 86 return ( 87 <div class="flex h-full min-h-0 items-stretch gap-4 pb-3"> 88 <For each={props.columns}> 89 {(column) => ( 90 <Motion.div 91 class="flex h-full shrink-0" 92 initial={{ opacity: 0, scale: 0.95 }} 93 animate={{ opacity: 1, scale: 1 }} 94 transition={{ duration: 0.18, easing: [0.34, 1.56, 0.64, 1] }}> 95 <DeckColumn 96 column={column} 97 feedColumn={props.feedColumns[column.id]} 98 isDragOver={props.dragOverId === column.id} 99 onClose={props.onClose} 100 onDragEnd={props.onDragEnd} 101 onDragOver={props.onDragOver} 102 onDragStart={props.onDragStart} 103 onDrop={props.onDrop} 104 onMoveLeft={props.onMoveLeft} 105 onMoveRight={props.onMoveRight} 106 onOpenThread={props.onOpenThread} 107 onWidthChange={props.onWidthChange} /> 108 </Motion.div> 109 )} 110 </For> 111 </div> 112 ); 113} 114 115function createDeckKeyboardHandler(onAddColumn: () => void, onCloseLastColumn: () => void) { 116 return (e: KeyboardEvent) => { 117 if (!e.ctrlKey && !e.metaKey) return; 118 if (!e.shiftKey) return; 119 120 if (e.key === "N" || e.key === "n") { 121 e.preventDefault(); 122 onAddColumn(); 123 } else if (e.key === "W" || e.key === "w") { 124 e.preventDefault(); 125 onCloseLastColumn(); 126 } 127 }; 128} 129 130export function DeckWorkspace() { 131 const session = useAppSession(); 132 const threadOverlay = useThreadOverlayNavigation(); 133 let feedColumnRequest = 0; 134 let draggingColumnId: string | null = null; 135 136 const [state, setState] = createStore<DeckState>({ 137 addPanelOpen: false, 138 columns: [], 139 dragOverId: null, 140 error: null, 141 feedColumns: {}, 142 loading: true, 143 }); 144 145 const activeDid = () => session.activeDid; 146 147 async function loadColumns() { 148 const did = activeDid(); 149 if (!did) return; 150 try { 151 const cols = await getColumns(did); 152 setState("columns", cols); 153 setState("error", null); 154 void hydrateFeedColumns(cols); 155 } catch (err) { 156 const message = err instanceof Error ? err.message : String(err); 157 logger.error(`Failed to load deck columns: ${message}`); 158 setState("error", message); 159 } finally { 160 setState("loading", false); 161 } 162 } 163 164 async function hydrateFeedColumns(columns: Column[]) { 165 const currentRequest = ++feedColumnRequest; 166 const parsedFeedColumns = columns.flatMap((column) => { 167 if (column.kind !== "feed") { 168 return []; 169 } 170 171 const config = parseFeedConfig(column.config); 172 return config ? [{ columnId: column.id, config }] : []; 173 }); 174 175 if (parsedFeedColumns.length === 0) { 176 setState("feedColumns", {}); 177 return; 178 } 179 180 setState( 181 "feedColumns", 182 Object.fromEntries(parsedFeedColumns.map(({ columnId, config }) => [columnId, resolveFeedColumn(config)])), 183 ); 184 185 try { 186 const preferences = await FeedController.getPreferences(); 187 const savedFeedTitles = Object.fromEntries( 188 preferences.savedFeeds.map((feed) => [feed.value, getFeedName(feed, void 0)]), 189 ); 190 191 const generatorUris = [ 192 ...new Set( 193 parsedFeedColumns.filter(({ config }) => config.feedType === "feed").map(({ config }) => 194 config.feedUri 195 ), 196 ), 197 ]; 198 let generators: Record<string, FeedGeneratorView> = {}; 199 200 if (generatorUris.length > 0) { 201 const hydrated = await FeedController.getFeedGenerators(generatorUris); 202 generators = Object.fromEntries(hydrated.feeds.map((generator) => [generator.uri, generator])); 203 } 204 205 const nextFeedColumns = Object.fromEntries( 206 parsedFeedColumns.map(( 207 { columnId, config }, 208 ) => [ 209 columnId, 210 resolveFeedColumn(config, { 211 generator: generators[config.feedUri], 212 savedFeedTitle: savedFeedTitles[config.feedUri], 213 }), 214 ]), 215 ); 216 217 if (currentRequest !== feedColumnRequest) { 218 return; 219 } 220 221 setState("feedColumns", nextFeedColumns); 222 } catch (err) { 223 logger.warn(`Failed to hydrate deck feed columns: ${String(err)}`); 224 } 225 } 226 227 async function handleAdd(kind: ColumnKind, config: string) { 228 const did = activeDid(); 229 if (!did) return; 230 try { 231 const col = await addColumn(did, kind, config); 232 const nextColumns = [...state.columns, col]; 233 setState("columns", nextColumns); 234 setState("addPanelOpen", false); 235 if (kind === "feed") { 236 void hydrateFeedColumns(nextColumns); 237 } 238 } catch (err) { 239 logger.error(`Failed to add column: ${String(err)}`); 240 } 241 } 242 243 async function handleClose(id: string) { 244 try { 245 await removeColumn(id); 246 const nextColumns = state.columns.filter((column) => column.id !== id); 247 setState("columns", nextColumns); 248 void hydrateFeedColumns(nextColumns); 249 } catch (err) { 250 logger.error(`Failed to remove column: ${String(err)}`); 251 } 252 } 253 254 async function handleWidthChange(id: string, width: ColumnWidth) { 255 try { 256 const updated = await updateColumn(id, { width }); 257 setState("columns", (prev) => prev.map((c) => (c.id === id ? updated : c))); 258 } catch (err) { 259 logger.error(`Failed to update column width: ${String(err)}`); 260 } 261 } 262 263 async function handleMoveLeft(id: string) { 264 const cols = state.columns; 265 const idx = cols.findIndex((c) => c.id === id); 266 if (idx === -1 || idx === 0) return; 267 268 const newOrder = cols.map((c) => c.id); 269 newOrder.splice(idx, 1); 270 newOrder.splice(idx - 1, 0, id); 271 272 try { 273 await reorderColumns(newOrder); 274 setState( 275 "columns", 276 produce((draft) => { 277 const item = draft.splice(idx, 1)[0]; 278 if (item) draft.splice(idx - 1, 0, item); 279 }), 280 ); 281 } catch (err) { 282 logger.error(`Failed to reorder columns: ${String(err)}`); 283 } 284 } 285 286 async function handleMoveRight(id: string) { 287 const cols = state.columns; 288 const idx = cols.findIndex((c) => c.id === id); 289 if (idx === -1 || idx >= cols.length - 1) return; 290 291 const newOrder = cols.map((c) => c.id); 292 newOrder.splice(idx, 1); 293 newOrder.splice(idx + 1, 0, id); 294 295 try { 296 await reorderColumns(newOrder); 297 setState( 298 "columns", 299 produce((draft) => { 300 const item = draft.splice(idx, 1)[0]; 301 if (item) draft.splice(idx + 1, 0, item); 302 }), 303 ); 304 } catch (err) { 305 logger.error(`Failed to reorder columns: ${String(err)}`); 306 } 307 } 308 309 function handleDragStart(id: string) { 310 draggingColumnId = id; 311 } 312 313 function handleDragEnd() { 314 draggingColumnId = null; 315 setState("dragOverId", null); 316 } 317 318 function handleDragOver(id: string) { 319 if (draggingColumnId && draggingColumnId !== id) { 320 setState("dragOverId", id); 321 } 322 } 323 324 async function handleDrop(targetId: string) { 325 const sourceId = draggingColumnId; 326 draggingColumnId = null; 327 setState("dragOverId", null); 328 329 if (!sourceId || sourceId === targetId) return; 330 331 const cols = state.columns; 332 const fromIdx = cols.findIndex((c) => c.id === sourceId); 333 const toIdx = cols.findIndex((c) => c.id === targetId); 334 if (fromIdx === -1 || toIdx === -1) return; 335 336 const newOrder = cols.map((c) => c.id); 337 newOrder.splice(fromIdx, 1); 338 newOrder.splice(toIdx, 0, sourceId); 339 340 try { 341 await reorderColumns(newOrder); 342 setState( 343 "columns", 344 produce((draft) => { 345 const item = draft.splice(fromIdx, 1)[0]; 346 if (item) draft.splice(toIdx, 0, item); 347 }), 348 ); 349 } catch (err) { 350 logger.error(`Failed to reorder columns via drag: ${String(err)}`); 351 } 352 } 353 354 function handleOpenThread(uri: string) { 355 void threadOverlay.openThread(uri); 356 } 357 358 createEffect(() => { 359 const handler = createDeckKeyboardHandler(() => setState("addPanelOpen", true), () => { 360 const last = state.columns.at(-1); 361 if (last) void handleClose(last.id); 362 }); 363 globalThis.addEventListener("keydown", handler); 364 onCleanup(() => globalThis.removeEventListener("keydown", handler)); 365 }); 366 367 onMount(() => { 368 void loadColumns(); 369 }); 370 371 return ( 372 <div class="relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden px-6 py-5 max-md:px-4 max-md:py-4 max-sm:px-3 max-sm:py-3"> 373 <DeckToolbar columnCount={state.columns.length} onAdd={() => setState("addPanelOpen", true)} /> 374 375 <div class="min-h-0 flex-1 overflow-x-auto overflow-y-hidden overscroll-contain"> 376 <Show when={state.loading}> 377 <div class="flex h-full min-h-80 items-center justify-center"> 378 <LoadingIcon isLoading class="text-2xl text-on-surface-variant" /> 379 </div> 380 </Show> 381 382 <Show when={!state.loading && state.error}> 383 <div class="rounded-2xl 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)]"> 384 {state.error} 385 </div> 386 </Show> 387 388 <Show when={!state.loading && !state.error && state.columns.length === 0}> 389 <EmptyDeck onAdd={() => setState("addPanelOpen", true)} /> 390 </Show> 391 392 <Show when={!state.loading && state.columns.length > 0}> 393 <ColumnList 394 columns={state.columns} 395 dragOverId={state.dragOverId} 396 feedColumns={state.feedColumns} 397 onClose={handleClose} 398 onDragEnd={handleDragEnd} 399 onDragOver={handleDragOver} 400 onDragStart={handleDragStart} 401 onDrop={handleDrop} 402 onMoveLeft={handleMoveLeft} 403 onMoveRight={handleMoveRight} 404 onOpenThread={handleOpenThread} 405 onWidthChange={handleWidthChange} /> 406 </Show> 407 </div> 408 409 <AddColumnPanel open={state.addPanelOpen} onAdd={handleAdd} onClose={() => setState("addPanelOpen", false)} /> 410 </div> 411 ); 412}