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.

feat: expand column kinds

+1010 -120
+30 -3
docs/specs/multicolumn.md
··· 1 1 # Multicolumn Views 2 2 3 - TweetDeck-style layout allowing users to view multiple feeds, AT Explorer panels, and social diagnostics panels side by side. Each column is an independent, scrollable pane with its own state. 3 + TweetDeck-style layout allowing users to view multiple feeds, AT Explorer panels, social diagnostics panels, DMs, search views, and profiles side by side. Each column is an independent, scrollable pane with its own state. 4 4 5 5 ## Layout Model 6 6 ··· 62 62 - Tab navigation within the column 63 63 - Compact card layout adapted to column width 64 64 65 + ### Messages Column 66 + 67 + Displays the authenticated user's DM inbox and active conversation. 68 + 69 + - Reuses the existing messages panel 70 + - Sensitive content is blurred by default until the column is hovered or focused 71 + - Width should remain user-adjustable because compact layouts can still be useful for list-first triage 72 + 73 + ### Search Column 74 + 75 + Displays a saved search query inside the deck. 76 + 77 + - Reuses the search panel in an embedded mode 78 + - Persists the initial query and search mode in column config 79 + - Supports network and local search modes 80 + 81 + ### Profile Column 82 + 83 + Displays a profile view for a selected actor. 84 + 85 + - Reuses the existing profile panel 86 + - Column picker should prefer actor typeahead to reduce handle/DID entry errors 87 + - Persist actor selection in column config so the column can be restored on launch 88 + 65 89 ## Column Management 66 90 67 91 ### Adding Columns ··· 71 95 - **Feed picker**: pinned feeds, saved feeds, list feeds 72 96 - **Explorer picker**: input field accepting at:// URI, handle, DID, or PDS URL 73 97 - **Diagnostics picker**: input field accepting handle or DID 98 + - **Messages picker**: opens the authenticated user's DM inbox 99 + - **Search picker**: query + search mode 100 + - **Profile picker**: actor lookup with typeahead 74 101 75 102 New columns append to the right by default. Optional position insertion via drag during add. 76 103 ··· 82 109 CREATE TABLE columns ( 83 110 id TEXT PRIMARY KEY, 84 111 account_did TEXT NOT NULL, 85 - kind TEXT NOT NULL, -- 'feed' | 'explorer' | 'diagnostics' 86 - config TEXT NOT NULL, -- JSON: feed → { feed_uri, feed_type }, explorer → { target_uri }, diagnostics → { did } 112 + kind TEXT NOT NULL, -- 'feed' | 'explorer' | 'diagnostics' | 'messages' | 'search' | 'profile' 113 + config TEXT NOT NULL, -- JSON: feed → { feed_uri, feed_type }, explorer → { target_uri }, diagnostics → { did }, messages → {}, search → { query, mode }, profile → { actor, handle?, did?, display_name? } 87 114 position INTEGER NOT NULL, 88 115 width TEXT NOT NULL DEFAULT 'standard', 89 116 created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+21 -3
docs/tasks/08-multicolumn.md
··· 11 11 ### Backend - `src-tauri/src/columns.rs` + `src-tauri/src/commands/columns.rs` 12 12 13 13 - [x] SQLite migration: `columns` table (`id TEXT PRIMARY KEY, account_did TEXT, kind TEXT, config TEXT, position INTEGER, width TEXT, created_at TEXT`) 14 - - `kind`: `feed` | `explorer` | `diagnostics` - determines the column type 15 - - `config`: JSON blob - for feeds: `{ feed_uri, feed_type }`, for explorer: `{ target_uri }`, for diagnostics: `{ did }` 14 + - `kind`: `feed` | `explorer` | `diagnostics` | `messages` | `search` | `profile` - determines the column type 15 + - `config`: JSON blob - for feeds: `{ feed_uri, feed_type }`, for explorer: `{ target_uri }`, for diagnostics: `{ did }`, for messages: `{}`, for search: `{ query, mode }`, for profile: `{ actor, handle?, did?, displayName? }` 16 16 - `width`: `narrow` | `standard` | `wide` 17 17 - [x] `get_columns(account_did: String)` - return ordered column list for the active account 18 18 - [x] `add_column(account_did: String, kind: String, config: String, position: Option<u32>)` - insert at position or append ··· 51 51 - [ ] Tab navigation within column for lists/labels/blocks/starter packs/backlinks 52 52 - [ ] Compact card layout adapted to column width 53 53 54 + #### Messages Column 55 + 56 + - [x] Reuse the existing messages panel inside deck columns 57 + - [x] Blur DM content until hovered or focused 58 + 59 + #### Search Column 60 + 61 + - [x] Reuse the existing search panel inside deck columns 62 + - [x] Persist search query + mode in column config 63 + 64 + #### Profile Column 65 + 66 + - [x] Reuse the existing profile panel inside deck columns 67 + - [x] Add profile column creation via actor typeahead 68 + 54 69 ### Frontend - Column Management 55 70 56 71 - [x] "Add column" button (`i-ri-add-line`) opens a picker panel: 57 72 - Feed picker: lists pinned feeds, saved feeds, list feeds 58 73 - Explorer picker: input field for at:// URI, handle, DID, or PDS URL 59 74 - Diagnostics picker: input field for handle or DID 75 + - Messages picker: opens DM inbox 76 + - Search picker: accepts query + mode 77 + - Profile picker: typeahead-first actor selection 60 78 - [ ] Right-click column header for context menu (resize, duplicate, close) 61 79 - [x] Keyboard shortcuts: `Ctrl+Shift+N` add column, `Ctrl+Shift+W` close focused column 62 80 - [x] Persist column layout to SQLite per account - restore on app launch ··· 65 83 66 84 - [ ] Column templates / saved layouts (e.g., "Research", "Timeline + Notifications") 67 85 - [ ] Notification column type 68 - - [ ] Search results column type 86 + - [x] Search results column type 69 87 - [ ] Column-level auto-refresh interval override 70 88 - [ ] Shared scroll sync between related columns
+2 -2
src-tauri/src/columns.rs
··· 185 185 186 186 fn validate_kind(kind: &str) -> Result<()> { 187 187 match kind { 188 - "feed" | "explorer" | "diagnostics" => Ok(()), 188 + "feed" | "explorer" | "diagnostics" | "messages" | "search" | "profile" => Ok(()), 189 189 _ => Err(AppError::validation(format!( 190 - "invalid column kind '{kind}': must be 'feed', 'explorer', or 'diagnostics'" 190 + "invalid column kind '{kind}': must be 'feed', 'explorer', 'diagnostics', 'messages', 'search', or 'profile'" 191 191 ))), 192 192 } 193 193 }
+49
src-tauri/src/db.rs
··· 48 48 include_str!("migrations/007_search_owner_scope.sql"), 49 49 ), 50 50 Migration::new(8, "columns", include_str!("migrations/008_columns.sql")), 51 + Migration::new( 52 + 9, 53 + "columns_expand_kinds", 54 + include_str!("migrations/009_columns_expand_kinds.sql"), 55 + ), 51 56 ]; 52 57 53 58 pub fn initialize_database(app: &AppHandle) -> Result<DbPool, AppError> { ··· 231 236 .expect("oauth session count should query"); 232 237 233 238 assert_eq!(stored_count, 1); 239 + } 240 + 241 + #[test] 242 + fn migration_nine_expands_column_kinds() { 243 + let connection = Connection::open_in_memory().expect("in-memory db should open"); 244 + 245 + connection 246 + .execute_batch(include_str!("migrations/008_columns.sql")) 247 + .expect("columns schema should apply"); 248 + 249 + let old_error = connection 250 + .execute( 251 + " 252 + INSERT INTO columns(id, account_did, kind, config, position, width) 253 + VALUES (?1, ?2, ?3, ?4, ?5, ?6) 254 + ", 255 + params!["column-1", "did:plc:test", "messages", "{}", 0_i64, "standard"], 256 + ) 257 + .expect_err("old schema should reject new column kinds"); 258 + 259 + assert!(old_error.to_string().contains("CHECK constraint failed")); 260 + 261 + connection 262 + .execute_batch(include_str!("migrations/009_columns_expand_kinds.sql")) 263 + .expect("migration nine should apply"); 264 + 265 + for (index, kind) in ["messages", "search", "profile"].into_iter().enumerate() { 266 + connection 267 + .execute( 268 + " 269 + INSERT INTO columns(id, account_did, kind, config, position, width) 270 + VALUES (?1, ?2, ?3, ?4, ?5, ?6) 271 + ", 272 + params![ 273 + format!("column-next-{index}"), 274 + "did:plc:test", 275 + kind, 276 + "{}", 277 + index as i64, 278 + "standard" 279 + ], 280 + ) 281 + .expect("expanded schema should accept new column kinds"); 282 + } 234 283 } 235 284 }
+19
src-tauri/src/migrations/009_columns_expand_kinds.sql
··· 1 + CREATE TABLE columns_next ( 2 + id TEXT PRIMARY KEY, 3 + account_did TEXT NOT NULL, 4 + kind TEXT NOT NULL CHECK(kind IN ('feed', 'explorer', 'diagnostics', 'messages', 'search', 'profile')), 5 + config TEXT NOT NULL, 6 + position INTEGER NOT NULL, 7 + width TEXT NOT NULL DEFAULT 'standard' CHECK(width IN ('narrow', 'standard', 'wide')), 8 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP 9 + ); 10 + 11 + INSERT INTO columns_next (id, account_did, kind, config, position, width, created_at) 12 + SELECT id, account_did, kind, config, position, width, created_at 13 + FROM columns; 14 + 15 + DROP TABLE columns; 16 + 17 + ALTER TABLE columns_next RENAME TO columns; 18 + 19 + CREATE INDEX IF NOT EXISTS columns_account_did ON columns(account_did, position);
+46 -2
src/components/deck/AddColumnPanel.test.tsx
··· 2 2 import { beforeEach, describe, expect, it, vi } from "vitest"; 3 3 import { AddColumnPanel } from "./AddColumnPanel"; 4 4 5 + const getFeedGeneratorsMock = vi.hoisted(() => vi.fn()); 5 6 const getPreferencesMock = vi.hoisted(() => vi.fn()); 6 7 7 - vi.mock("$/lib/api/feeds", () => ({ getPreferences: getPreferencesMock })); 8 + vi.mock("$/lib/api/feeds", () => ({ getFeedGenerators: getFeedGeneratorsMock, getPreferences: getPreferencesMock })); 8 9 vi.mock("@tauri-apps/plugin-log", () => ({ error: vi.fn(), info: vi.fn(), warn: vi.fn() })); 9 10 10 11 describe("AddColumnPanel", () => { 11 12 beforeEach(() => { 12 13 vi.resetAllMocks(); 14 + getFeedGeneratorsMock.mockResolvedValue({ feeds: [] }); 13 15 getPreferencesMock.mockResolvedValue({ 14 16 feedViewPrefs: [], 15 17 savedFeeds: [{ id: "following", pinned: true, type: "timeline", value: "following" }], ··· 32 34 fireEvent.click(await screen.findByRole("button", { name: /timeline/i })); 33 35 34 36 await waitFor(() => 35 - expect(onAdd).toHaveBeenCalledWith("feed", JSON.stringify({ feedType: "timeline", feedUri: "following" })) 37 + expect(onAdd).toHaveBeenCalledWith( 38 + "feed", 39 + JSON.stringify({ feedType: "timeline", feedUri: "following", title: "Following" }), 40 + ) 41 + ); 42 + }); 43 + 44 + it("hydrates feed generator labels in the picker and submission config", async () => { 45 + const onAdd = vi.fn(); 46 + getPreferencesMock.mockResolvedValue({ 47 + feedViewPrefs: [], 48 + savedFeeds: [{ 49 + id: "at://did:plc:alice/app.bsky.feed.generator/for-you", 50 + pinned: true, 51 + type: "feed", 52 + value: "at://did:plc:alice/app.bsky.feed.generator/for-you", 53 + }], 54 + }); 55 + getFeedGeneratorsMock.mockResolvedValue({ 56 + feeds: [{ 57 + avatar: "https://cdn.example.com/for-you.png", 58 + did: "did:plc:alice", 59 + displayName: "For You", 60 + uri: "at://did:plc:alice/app.bsky.feed.generator/for-you", 61 + }], 62 + }); 63 + 64 + render(() => <AddColumnPanel open={true} onAdd={onAdd} onClose={vi.fn()} />); 65 + 66 + expect(await screen.findByText("For You")).toBeInTheDocument(); 67 + expect(document.querySelector("img[src=\"https://cdn.example.com/for-you.png\"]")).toBeTruthy(); 68 + 69 + fireEvent.click(await screen.findByRole("button", { name: /for you/i })); 70 + 71 + await waitFor(() => 72 + expect(onAdd).toHaveBeenCalledWith( 73 + "feed", 74 + JSON.stringify({ 75 + feedType: "feed", 76 + feedUri: "at://did:plc:alice/app.bsky.feed.generator/for-you", 77 + title: "For You", 78 + }), 79 + ) 36 80 ); 37 81 }); 38 82 });
+477 -31
src/components/deck/AddColumnPanel.tsx
··· 1 - import type { ColumnKind } from "$/lib/api/columns"; 2 - import { getPreferences } from "$/lib/api/feeds"; 1 + import { getFeedGenerators, getPreferences } from "$/lib/api/feeds"; 2 + import type { SearchMode } from "$/lib/api/search"; 3 + import type { ColumnKind } from "$/lib/api/types/columns"; 3 4 import { getFeedName } from "$/lib/feeds"; 4 - import type { SavedFeedItem } from "$/lib/types"; 5 + import type { FeedGeneratorView, LoginSuggestion, SavedFeedItem } from "$/lib/types"; 6 + import { invoke } from "@tauri-apps/api/core"; 5 7 import * as logger from "@tauri-apps/plugin-log"; 6 8 import { createEffect, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js"; 7 9 import { Portal } from "solid-js/web"; 8 10 import { Motion, Presence } from "solid-motionone"; 9 - import { Icon } from "../shared/Icon"; 11 + import { AvatarBadge } from "../AvatarBadge"; 12 + import { FeedChipAvatar } from "../feeds/FeedChipAvatar"; 13 + import { Icon, SearchModeIcon } from "../shared/Icon"; 10 14 11 15 type AddColumnPanelProps = { onAdd: (kind: ColumnKind, config: string) => void; onClose: () => void; open: boolean }; 12 16 13 - type PanelTab = "feed" | "explorer" | "diagnostics"; 17 + type PanelTab = ColumnKind; 18 + type FeedPickerSelection = { feed: SavedFeedItem; title: string }; 14 19 15 - function FeedPicker(props: { onSelect: (feed: SavedFeedItem) => void }) { 20 + const ACTOR_TYPEAHEAD_DEBOUNCE_MS = 180; 21 + 22 + function feedKindLabel(feed: SavedFeedItem) { 23 + switch (feed.type) { 24 + case "timeline": { 25 + return "Timeline"; 26 + } 27 + case "list": { 28 + return "List"; 29 + } 30 + default: { 31 + return "Feed"; 32 + } 33 + } 34 + } 35 + 36 + function FeedPicker(props: { onSelect: (selection: FeedPickerSelection) => void }) { 16 37 const [feeds, setFeeds] = createSignal<SavedFeedItem[]>([]); 38 + const [generators, setGenerators] = createSignal<Record<string, FeedGeneratorView>>({}); 17 39 const [loading, setLoading] = createSignal(true); 18 40 19 41 onMount(async () => { 20 42 try { 21 43 const prefs = await getPreferences(); 22 44 setFeeds(prefs.savedFeeds); 45 + 46 + const uris = [...new Set(prefs.savedFeeds.filter((feed) => feed.type === "feed").map((feed) => feed.value))]; 47 + if (uris.length > 0) { 48 + const hydrated = await getFeedGenerators(uris); 49 + setGenerators(Object.fromEntries(hydrated.feeds.map((generator) => [generator.uri, generator]))); 50 + } 23 51 } catch (err) { 24 52 logger.error(`Failed to load feeds for column picker: ${String(err)}`); 25 53 } finally { ··· 52 80 <button 53 81 type="button" 54 82 class="flex w-full items-center gap-3 rounded-xl border-0 bg-white/4 px-4 py-3 text-left transition duration-150 hover:-translate-y-px hover:bg-white/8" 55 - onClick={() => props.onSelect(feed)}> 56 - <Switch> 57 - <Match when={feed.type === "timeline"}> 58 - <Icon kind="timeline" class="text-primary" /> 59 - </Match> 60 - <Match when={feed.type === "list"}> 61 - <Icon kind="list" class="text-primary" /> 62 - </Match> 63 - <Match when={feed.type === "feed"}> 64 - <Icon kind="rss" class="text-primary" /> 65 - </Match> 66 - </Switch> 83 + onClick={() => props.onSelect({ feed, title: getFeedName(feed, generators()[feed.value]?.displayName) })}> 84 + <FeedChipAvatar feed={feed} generator={generators()[feed.value]} /> 67 85 <span class="min-w-0 flex-1"> 68 - <span class="block truncate text-sm font-medium text-on-surface">{getFeedName(feed, void 0)}</span> 69 - <span class="block truncate text-xs text-on-surface-variant capitalize">{feed.type}</span> 86 + <span class="block truncate text-sm font-medium text-on-surface"> 87 + {getFeedName(feed, generators()[feed.value]?.displayName)} 88 + </span> 89 + <span class="block truncate text-xs text-on-surface-variant">{feedKindLabel(feed)}</span> 70 90 </span> 71 91 </button> 72 92 )} ··· 149 169 ); 150 170 } 151 171 172 + function MessagesPicker(props: { onSubmit: () => void }) { 173 + return ( 174 + <div class="grid gap-4"> 175 + <div class="rounded-2xl bg-white/4 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 176 + <div class="flex items-start gap-3"> 177 + <span class="mt-0.5 flex items-center text-primary"> 178 + <i class="i-ri-message-3-line" /> 179 + </span> 180 + <div class="grid gap-1.5"> 181 + <p class="m-0 text-sm font-medium text-on-surface">Direct messages</p> 182 + <p class="m-0 text-xs leading-relaxed text-on-surface-variant"> 183 + Opens your DM inbox inside the deck. Message content is blurred until you hover or focus the column. 184 + </p> 185 + </div> 186 + </div> 187 + </div> 188 + 189 + <button 190 + type="button" 191 + class="flex items-center justify-center gap-2 rounded-xl border-0 bg-primary/15 px-4 py-2.5 text-sm font-medium text-primary transition duration-150 hover:-translate-y-px hover:bg-primary/25" 192 + onClick={() => props.onSubmit()}> 193 + <span class="flex items-center"> 194 + <i class="i-ri-layout-column-line" /> 195 + </span> 196 + Add DM column 197 + </button> 198 + </div> 199 + ); 200 + } 201 + 202 + function SearchModeButton(props: { active: boolean; disabled?: boolean; mode: SearchMode; onClick: () => void }) { 203 + return ( 204 + <button 205 + type="button" 206 + disabled={props.disabled} 207 + class="inline-flex items-center justify-center gap-2 rounded-xl border-0 px-3 py-2 text-xs font-medium transition duration-150 disabled:cursor-not-allowed disabled:opacity-40" 208 + classList={{ 209 + "bg-primary/15 text-primary": props.active, 210 + "bg-white/4 text-on-surface-variant hover:bg-white/8 hover:text-on-surface": !props.active && !props.disabled, 211 + }} 212 + onClick={() => props.onClick()}> 213 + <SearchModeIcon mode={props.mode} class="text-sm" /> 214 + <span class="capitalize">{props.mode}</span> 215 + </button> 216 + ); 217 + } 218 + 219 + function SearchPicker(props: { onSubmit: (query: string, mode: SearchMode) => void }) { 220 + const [mode, setMode] = createSignal<SearchMode>("network"); 221 + const [query, setQuery] = createSignal(""); 222 + 223 + function handleSubmit(event: Event) { 224 + event.preventDefault(); 225 + const trimmed = query().trim(); 226 + if (!trimmed) { 227 + return; 228 + } 229 + 230 + props.onSubmit(trimmed, mode()); 231 + } 232 + 233 + return ( 234 + <form onSubmit={handleSubmit} class="grid gap-3"> 235 + <label class="grid gap-1.5"> 236 + <span class="text-xs font-medium uppercase tracking-wide text-on-surface-variant">Search query</span> 237 + <input 238 + type="text" 239 + class="rounded-xl border-0 bg-white/6 px-4 py-2.5 text-sm text-on-surface placeholder:text-on-surface-variant/50 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.06)] outline-none focus:shadow-[inset_0_0_0_1px_rgba(125,175,255,0.4)]" 240 + placeholder="from:alice at protocol" 241 + value={query()} 242 + onInput={(event) => setQuery(event.currentTarget.value)} /> 243 + </label> 244 + 245 + <div class="grid gap-1.5"> 246 + <span class="text-xs font-medium uppercase tracking-wide text-on-surface-variant">Search mode</span> 247 + <div class="grid grid-cols-2 gap-2"> 248 + <SearchModeButton active={mode() === "network"} mode="network" onClick={() => setMode("network")} /> 249 + <SearchModeButton active={mode() === "keyword"} mode="keyword" onClick={() => setMode("keyword")} /> 250 + <SearchModeButton active={mode() === "semantic"} mode="semantic" onClick={() => setMode("semantic")} /> 251 + <SearchModeButton active={mode() === "hybrid"} mode="hybrid" onClick={() => setMode("hybrid")} /> 252 + </div> 253 + </div> 254 + 255 + <button 256 + type="submit" 257 + disabled={!query().trim()} 258 + class="flex items-center justify-center gap-2 rounded-xl border-0 bg-primary/15 px-4 py-2.5 text-sm font-medium text-primary transition duration-150 hover:-translate-y-px hover:bg-primary/25 disabled:cursor-not-allowed disabled:opacity-40"> 259 + <span class="flex items-center"> 260 + <i class="i-ri-search-line" /> 261 + </span> 262 + Open search column 263 + </button> 264 + </form> 265 + ); 266 + } 267 + 268 + function ProfilePicker( 269 + props: { 270 + onSubmit: ( 271 + selection: { actor: string; did?: string | null; displayName?: string | null; handle?: string | null }, 272 + ) => void; 273 + }, 274 + ) { 275 + let container: HTMLDivElement | undefined; 276 + let input: HTMLInputElement | undefined; 277 + let requestId = 0; 278 + 279 + const [activeIndex, setActiveIndex] = createSignal(-1); 280 + const [loading, setLoading] = createSignal(false); 281 + const [open, setOpen] = createSignal(false); 282 + const [suggestions, setSuggestions] = createSignal<LoginSuggestion[]>([]); 283 + const [value, setValue] = createSignal(""); 284 + 285 + createEffect(() => { 286 + const query = normalizeActorSuggestionQuery(value()); 287 + const nextRequestId = requestId + 1; 288 + requestId = nextRequestId; 289 + 290 + if (!query) { 291 + setLoading(false); 292 + setOpen(false); 293 + setActiveIndex(-1); 294 + setSuggestions([]); 295 + return; 296 + } 297 + 298 + setLoading(true); 299 + 300 + const timeout = globalThis.setTimeout(() => { 301 + void invoke<LoginSuggestion[]>("search_login_suggestions", { query }).then((results) => { 302 + if (requestId !== nextRequestId) { 303 + return; 304 + } 305 + 306 + setSuggestions(results); 307 + setActiveIndex(results.length > 0 ? 0 : -1); 308 + setOpen(results.length > 0 && document.activeElement === input); 309 + }).catch((error) => { 310 + if (requestId !== nextRequestId) { 311 + return; 312 + } 313 + 314 + logger.warn(`Failed to load profile suggestions: ${String(error)}`); 315 + setSuggestions([]); 316 + setActiveIndex(-1); 317 + setOpen(false); 318 + }).finally(() => { 319 + if (requestId === nextRequestId) { 320 + setLoading(false); 321 + } 322 + }); 323 + }, ACTOR_TYPEAHEAD_DEBOUNCE_MS); 324 + 325 + onCleanup(() => globalThis.clearTimeout(timeout)); 326 + }); 327 + 328 + onMount(() => { 329 + const pointerListener = { 330 + handleEvent(event: Event) { 331 + if (!open()) { 332 + return; 333 + } 334 + 335 + if (container?.contains(event.target as Node)) { 336 + return; 337 + } 338 + 339 + setOpen(false); 340 + }, 341 + }; 342 + 343 + globalThis.addEventListener("pointerdown", pointerListener); 344 + onCleanup(() => globalThis.removeEventListener("pointerdown", pointerListener)); 345 + }); 346 + 347 + function moveActiveIndex(direction: 1 | -1) { 348 + const items = suggestions(); 349 + if (items.length === 0) { 350 + return; 351 + } 352 + 353 + setOpen(true); 354 + setActiveIndex((current) => { 355 + if (current < 0) { 356 + return direction > 0 ? 0 : items.length - 1; 357 + } 358 + 359 + return (current + direction + items.length) % items.length; 360 + }); 361 + } 362 + 363 + function submitManualActor() { 364 + const actor = value().trim(); 365 + if (!actor) { 366 + return; 367 + } 368 + 369 + props.onSubmit({ actor }); 370 + } 371 + 372 + function submitSuggestion(suggestion: LoginSuggestion) { 373 + props.onSubmit({ 374 + actor: suggestion.handle, 375 + did: suggestion.did, 376 + displayName: suggestion.displayName ?? null, 377 + handle: suggestion.handle, 378 + }); 379 + } 380 + 381 + function handleKeyDown(event: KeyboardEvent) { 382 + if (event.key === "ArrowDown") { 383 + event.preventDefault(); 384 + moveActiveIndex(1); 385 + return; 386 + } 387 + 388 + if (event.key === "ArrowUp") { 389 + event.preventDefault(); 390 + moveActiveIndex(-1); 391 + return; 392 + } 393 + 394 + if (event.key === "Escape") { 395 + setOpen(false); 396 + setActiveIndex(-1); 397 + return; 398 + } 399 + 400 + if (event.key === "Enter" && open() && activeIndex() >= 0) { 401 + event.preventDefault(); 402 + submitSuggestion(suggestions()[activeIndex()]); 403 + } 404 + } 405 + 406 + return ( 407 + <form 408 + class="grid gap-3" 409 + onSubmit={(event) => { 410 + event.preventDefault(); 411 + submitManualActor(); 412 + }}> 413 + <label class="grid gap-1.5"> 414 + <span class="text-xs font-medium uppercase tracking-wide text-on-surface-variant">Handle or DID</span> 415 + <div 416 + class="relative" 417 + ref={(element) => { 418 + container = element as HTMLDivElement; 419 + }}> 420 + <input 421 + ref={(element) => { 422 + input = element; 423 + }} 424 + type="text" 425 + role="combobox" 426 + aria-autocomplete="list" 427 + aria-controls="profile-suggestions" 428 + aria-activedescendant={activeIndex() >= 0 ? `profile-suggestion-${activeIndex()}` : undefined} 429 + aria-expanded={open()} 430 + class="w-full rounded-xl border-0 bg-white/6 px-4 py-2.5 pr-10 text-sm text-on-surface placeholder:text-on-surface-variant/50 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.06)] outline-none focus:shadow-[inset_0_0_0_1px_rgba(125,175,255,0.4)]" 431 + placeholder="alice.bsky.social" 432 + spellcheck={false} 433 + value={value()} 434 + onFocus={() => setOpen(suggestions().length > 0)} 435 + onInput={(event) => setValue(event.currentTarget.value)} 436 + onKeyDown={(event) => handleKeyDown(event)} /> 437 + 438 + <TypeaheadLoading visible={loading()} /> 439 + <ProfileSuggestionList 440 + activeIndex={activeIndex()} 441 + open={open()} 442 + suggestions={suggestions()} 443 + onSelect={submitSuggestion} /> 444 + </div> 445 + </label> 446 + 447 + <button 448 + type="submit" 449 + disabled={!value().trim()} 450 + class="flex items-center justify-center gap-2 rounded-xl border-0 bg-primary/15 px-4 py-2.5 text-sm font-medium text-primary transition duration-150 hover:-translate-y-px hover:bg-primary/25 disabled:cursor-not-allowed disabled:opacity-40"> 451 + <span class="flex items-center"> 452 + <i class="i-ri-user-3-line" /> 453 + </span> 454 + Open profile 455 + </button> 456 + </form> 457 + ); 458 + } 459 + 460 + function TypeaheadLoading(props: { visible: boolean }) { 461 + return ( 462 + <Show when={props.visible}> 463 + <span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-on-surface-variant"> 464 + <Icon kind="loader" class="animate-spin text-sm" /> 465 + </span> 466 + </Show> 467 + ); 468 + } 469 + 470 + function ProfileSuggestionList( 471 + props: { 472 + activeIndex: number; 473 + open: boolean; 474 + suggestions: LoginSuggestion[]; 475 + onSelect: (suggestion: LoginSuggestion) => void; 476 + }, 477 + ) { 478 + return ( 479 + <Show when={props.open && props.suggestions.length > 0}> 480 + <div 481 + id="profile-suggestions" 482 + role="listbox" 483 + class="absolute inset-x-0 top-[calc(100%+0.65rem)] z-10 rounded-3xl bg-(--surface-container-highest) p-2.5 shadow-[0_24px_40px_rgba(0,0,0,0.28)] backdrop-blur-[20px]"> 484 + <p class="px-2 pb-2 text-[0.68rem] uppercase tracking-[0.12em] text-on-surface-variant">Suggested profiles</p> 485 + <div class="grid gap-1.5"> 486 + <For each={props.suggestions}> 487 + {(suggestion, index) => ( 488 + <ProfileSuggestionOption 489 + active={props.activeIndex === index()} 490 + id={`profile-suggestion-${index()}`} 491 + suggestion={suggestion} 492 + onSelect={props.onSelect} /> 493 + )} 494 + </For> 495 + </div> 496 + </div> 497 + </Show> 498 + ); 499 + } 500 + 501 + function ProfileSuggestionOption( 502 + props: { active: boolean; id: string; suggestion: LoginSuggestion; onSelect: (suggestion: LoginSuggestion) => void }, 503 + ) { 504 + return ( 505 + <button 506 + id={props.id} 507 + type="button" 508 + role="option" 509 + aria-selected={props.active} 510 + class="grid w-full grid-cols-[auto_minmax(0,1fr)] items-center gap-3 rounded-xl border-0 bg-transparent px-3 py-2.5 text-left transition duration-150 ease-out hover:bg-white/6" 511 + classList={{ "bg-white/7 shadow-[inset_0_0_0_1px_rgba(125,175,255,0.12)]": props.active }} 512 + onPointerDown={(event) => event.preventDefault()} 513 + onClick={() => props.onSelect(props.suggestion)}> 514 + <ProfileSuggestionAvatar suggestion={props.suggestion} /> 515 + <div class="min-w-0"> 516 + <p class="m-0 truncate text-sm font-medium text-on-surface">{getSuggestionHeadline(props.suggestion)}</p> 517 + <p class="mt-0.5 truncate text-xs text-on-surface-variant">@{props.suggestion.handle.replace(/^@/, "")}</p> 518 + </div> 519 + </button> 520 + ); 521 + } 522 + 523 + function ProfileSuggestionAvatar(props: { suggestion: LoginSuggestion }) { 524 + return ( 525 + <Show when={props.suggestion.avatar} fallback={<AvatarBadge label={props.suggestion.handle} tone="muted" />}> 526 + {(avatar) => ( 527 + <img 528 + class="h-10 w-10 rounded-full object-cover shadow-[inset_0_0_0_1px_rgba(255,255,255,0.06)]" 529 + src={avatar()} 530 + alt="" 531 + loading="lazy" /> 532 + )} 533 + </Show> 534 + ); 535 + } 536 + 152 537 function PanelContent( 153 538 props: { 154 539 tab: PanelTab; 155 - onFeedSelect: (feed: SavedFeedItem) => void; 540 + onFeedSelect: (selection: FeedPickerSelection) => void; 156 541 onExplorerSubmit: (uri: string) => void; 157 542 onDiagnosticsSubmit: (did: string) => void; 543 + onMessagesSubmit: () => void; 544 + onProfileSubmit: ( 545 + selection: { actor: string; did?: string | null; displayName?: string | null; handle?: string | null }, 546 + ) => void; 547 + onSearchSubmit: (query: string, mode: SearchMode) => void; 158 548 }, 159 549 ) { 160 550 return ( ··· 171 561 <Match when={props.tab === "diagnostics"}> 172 562 <DiagnosticsPicker onSubmit={props.onDiagnosticsSubmit} /> 173 563 </Match> 564 + 565 + <Match when={props.tab === "messages"}> 566 + <MessagesPicker onSubmit={props.onMessagesSubmit} /> 567 + </Match> 568 + 569 + <Match when={props.tab === "search"}> 570 + <SearchPicker onSubmit={props.onSearchSubmit} /> 571 + </Match> 572 + 573 + <Match when={props.tab === "profile"}> 574 + <ProfilePicker onSubmit={props.onProfileSubmit} /> 575 + </Match> 174 576 </Switch> 175 577 </div> 176 578 ); ··· 181 583 <div class="flex shrink-0 items-center justify-between gap-3 px-5 py-4 shadow-[inset_0_-1px_0_rgba(255,255,255,0.04)]"> 182 584 <div> 183 585 <p id="add-column-panel-title" class="m-0 text-sm font-semibold text-on-surface">Add column</p> 184 - <p class="m-0 mt-1 text-xs uppercase tracking-[0.12em] text-on-surface-variant"> 185 - Feed, explorer, or diagnostics 186 - </p> 586 + <p class="m-0 mt-1 text-xs uppercase tracking-[0.12em] text-on-surface-variant">Choose a view</p> 187 587 </div> 188 588 <button 189 589 type="button" ··· 204 604 }, 205 605 ) { 206 606 return ( 207 - <div class="flex shrink-0 gap-1 px-5 py-3"> 607 + <div class="grid shrink-0 grid-cols-2 gap-1 px-5 py-3"> 208 608 <For each={props.tabs}> 209 609 {(tab) => ( 210 610 <button 211 611 type="button" 212 - class="flex flex-1 items-center justify-center gap-1.5 rounded-lg border-0 px-3 py-2 text-xs font-medium transition duration-150" 612 + class="flex items-center justify-center gap-1.5 rounded-lg border-0 px-3 py-2 text-xs font-medium transition duration-150" 213 613 classList={{ 214 614 "bg-primary/15 text-primary": props.activeTab === tab.id, 215 615 "bg-transparent text-on-surface-variant hover:bg-white/5 hover:text-on-surface": ··· 233 633 onClose: () => void; 234 634 onDiagnosticsSubmit: (did: string) => void; 235 635 onExplorerSubmit: (uri: string) => void; 236 - onFeedSelect: (feed: SavedFeedItem) => void; 636 + onFeedSelect: (selection: FeedPickerSelection) => void; 637 + onMessagesSubmit: () => void; 638 + onProfileSubmit: ( 639 + selection: { actor: string; did?: string | null; displayName?: string | null; handle?: string | null }, 640 + ) => void; 641 + onSearchSubmit: (query: string, mode: SearchMode) => void; 237 642 onTabChange: (tab: PanelTab) => void; 238 643 tabs: Array<{ icon: string; id: PanelTab; label: string }>; 239 644 }, ··· 254 659 tab={props.activeTab} 255 660 onFeedSelect={props.onFeedSelect} 256 661 onExplorerSubmit={props.onExplorerSubmit} 257 - onDiagnosticsSubmit={props.onDiagnosticsSubmit} /> 662 + onDiagnosticsSubmit={props.onDiagnosticsSubmit} 663 + onMessagesSubmit={props.onMessagesSubmit} 664 + onProfileSubmit={props.onProfileSubmit} 665 + onSearchSubmit={props.onSearchSubmit} /> 258 666 </Motion.aside> 259 667 ); 260 668 } ··· 262 670 export function AddColumnPanel(props: AddColumnPanelProps) { 263 671 const [activeTab, setActiveTab] = createSignal<PanelTab>("feed"); 264 672 265 - function handleFeedSelect(feed: SavedFeedItem) { 266 - const config = JSON.stringify({ feedType: feed.type, feedUri: feed.value }); 673 + function handleFeedSelect(selection: FeedPickerSelection) { 674 + const config = JSON.stringify({ 675 + feedType: selection.feed.type, 676 + feedUri: selection.feed.value, 677 + title: selection.title, 678 + }); 267 679 props.onAdd("feed", config); 268 680 } 269 681 ··· 277 689 props.onAdd("diagnostics", config); 278 690 } 279 691 692 + function handleMessagesSubmit() { 693 + props.onAdd("messages", JSON.stringify({})); 694 + } 695 + 696 + function handleSearchSubmit(query: string, mode: SearchMode) { 697 + props.onAdd("search", JSON.stringify({ mode, query })); 698 + } 699 + 700 + function handleProfileSubmit( 701 + selection: { actor: string; did?: string | null; displayName?: string | null; handle?: string | null }, 702 + ) { 703 + props.onAdd("profile", JSON.stringify(selection)); 704 + } 705 + 280 706 const tabs: Array<{ icon: string; id: PanelTab; label: string }> = [ 281 707 { icon: "i-ri-rss-line", id: "feed", label: "Feed" }, 282 708 { icon: "i-ri-compass-discover-line", id: "explorer", label: "Explorer" }, 283 709 { icon: "i-ri-stethoscope-line", id: "diagnostics", label: "Diagnostics" }, 710 + { icon: "i-ri-message-3-line", id: "messages", label: "DMs" }, 711 + { icon: "i-ri-search-line", id: "search", label: "Search" }, 712 + { icon: "i-ri-user-3-line", id: "profile", label: "Profile" }, 284 713 ]; 285 714 286 715 createEffect(() => { ··· 319 748 onTabChange={setActiveTab} 320 749 onFeedSelect={handleFeedSelect} 321 750 onExplorerSubmit={handleExplorerSubmit} 322 - onDiagnosticsSubmit={handleDiagnosticsSubmit} /> 751 + onDiagnosticsSubmit={handleDiagnosticsSubmit} 752 + onMessagesSubmit={handleMessagesSubmit} 753 + onProfileSubmit={handleProfileSubmit} 754 + onSearchSubmit={handleSearchSubmit} /> 323 755 </div> 324 756 </Portal> 325 757 </Show> 326 758 </Presence> 327 759 ); 328 760 } 761 + 762 + function getSuggestionHeadline(suggestion: LoginSuggestion) { 763 + const displayName = suggestion.displayName?.trim(); 764 + return displayName && displayName !== suggestion.handle ? displayName : suggestion.handle.replace(/^@/, ""); 765 + } 766 + 767 + function normalizeActorSuggestionQuery(value: string) { 768 + const trimmed = value.trim(); 769 + if (trimmed.length < 2 || trimmed.startsWith("did:") || /^https?:\/\//i.test(trimmed)) { 770 + return ""; 771 + } 772 + 773 + return trimmed.replace(/^@/, ""); 774 + }
+64 -22
src/components/deck/DeckColumn.tsx
··· 1 1 import { ExplorerPanel } from "$/components/explorer/ExplorerPanel"; 2 2 import { FeedContent } from "$/components/feeds/FeedContent"; 3 - import type { Column, ColumnWidth } from "$/lib/api/columns"; 4 - import type { PostView } from "$/lib/types"; 5 - import { createMemo, Match, Show, Switch } from "solid-js"; 3 + import { MessagesPanel } from "$/components/messages/MessagesPanel"; 4 + import { ProfilePanel } from "$/components/profile/ProfilePanel"; 5 + import { SearchPanel } from "$/components/search/SearchPanel"; 6 + import type { Column, ColumnWidth } from "$/lib/api/types/columns"; 7 + import type { PostView, SavedFeedItem } from "$/lib/types"; 8 + import { Match, Show, Switch } from "solid-js"; 6 9 import { DiagnosticsColumn } from "./DiagnosticsColumn"; 7 10 import { 8 11 COLUMN_WIDTH_PX, 9 12 columnTitle, 10 13 cycleWidth, 11 - feedConfigToSavedFeedItem, 12 14 parseDiagnosticsConfig, 13 - parseFeedConfig, 15 + parseProfileConfig, 16 + parseSearchConfig, 17 + type ResolvedFeedColumn, 14 18 } from "./types"; 15 19 import { useFeedColumnState } from "./useFeedColumnState"; 16 20 17 21 type DeckColumnProps = { 18 22 column: Column; 23 + feedColumn?: ResolvedFeedColumn; 19 24 onClose: (id: string) => void; 20 25 onMoveLeft: (id: string) => void; 21 26 onMoveRight: (id: string) => void; ··· 113 118 ); 114 119 } 115 120 116 - type FeedBodyProps = { columnId: string; config: string; onOpenThread: (uri: string) => void }; 121 + type FeedBodyProps = { feedColumn?: ResolvedFeedColumn; onOpenThread: (uri: string) => void }; 117 122 118 123 function FeedBody(props: FeedBodyProps) { 119 - const config = createMemo(() => parseFeedConfig(props.config)); 120 - const feed = createMemo(() => { 121 - const c = config(); 122 - return c ? feedConfigToSavedFeedItem(c) : null; 123 - }); 124 - 125 124 return ( 126 125 <Show 127 - when={feed()} 126 + when={props.feedColumn} 128 127 keyed 129 128 fallback={ 130 129 <div class="flex items-center justify-center p-6 text-sm text-on-surface-variant"> 131 130 Invalid feed configuration. 132 131 </div> 133 132 }> 134 - {(f) => <FeedBodyContent feed={f} onOpenThread={props.onOpenThread} />} 133 + {(feedColumn) => <FeedBodyContent feed={feedColumn.feed} onOpenThread={props.onOpenThread} />} 135 134 </Show> 136 135 ); 137 136 } 138 137 139 - type FeedBodyContentProps = { 140 - feed: { id: string; pinned: boolean; type: "feed" | "list" | "timeline"; value: string }; 141 - onOpenThread: (uri: string) => void; 142 - }; 138 + type FeedBodyContentProps = { feed: SavedFeedItem; onOpenThread: (uri: string) => void }; 143 139 144 140 function FeedBodyContent(props: FeedBodyContentProps) { 145 141 const { registerSentinel, state, toggleLike, toggleRepost } = useFeedColumnState(() => props.feed); ··· 174 170 ); 175 171 } 176 172 177 - function ColumnBody(props: { column: Column; onOpenThread: (uri: string) => void }) { 173 + function ColumnBody(props: { column: Column; feedColumn?: ResolvedFeedColumn; onOpenThread: (uri: string) => void }) { 178 174 const diagnosticsConfig = () => parseDiagnosticsConfig(props.column.config); 175 + const searchConfig = () => parseSearchConfig(props.column.config); 176 + const profileConfig = () => parseProfileConfig(props.column.config); 179 177 180 178 return ( 181 179 <Switch> 182 180 <Match when={props.column.kind === "feed"}> 183 - <FeedBody columnId={props.column.id} config={props.column.config} onOpenThread={props.onOpenThread} /> 181 + <FeedBody feedColumn={props.feedColumn} onOpenThread={props.onOpenThread} /> 184 182 </Match> 185 183 <Match when={props.column.kind === "explorer"}> 186 184 <div class="min-h-0 min-w-0 overflow-hidden"> ··· 190 188 <Match when={props.column.kind === "diagnostics"}> 191 189 <DiagnosticsColumn did={diagnosticsConfig()?.did ?? ""} /> 192 190 </Match> 191 + <Match when={props.column.kind === "messages"}> 192 + <BlurredMessagesBody /> 193 + </Match> 194 + <Match when={props.column.kind === "search"}> 195 + <SearchBody config={searchConfig()?.query ? searchConfig() : null} /> 196 + </Match> 197 + <Match when={props.column.kind === "profile"}> 198 + <ProfileBody actor={profileConfig()?.actor ?? profileConfig()?.handle ?? profileConfig()?.did ?? null} /> 199 + </Match> 193 200 </Switch> 194 201 ); 195 202 } 196 203 204 + function BlurredMessagesBody() { 205 + return ( 206 + <div class="group relative min-h-0 min-w-0 overflow-hidden"> 207 + <div class="pointer-events-none absolute right-3 top-3 z-10 rounded-full bg-black/55 px-2.5 py-1 text-[0.65rem] font-medium uppercase tracking-[0.12em] text-on-surface-variant backdrop-blur-sm transition duration-150 group-hover:opacity-0 group-focus-within:opacity-0"> 208 + Hover to reveal 209 + </div> 210 + <div class="h-full transition duration-200 ease-out blur-[14px] saturate-50 group-hover:blur-none group-hover:saturate-100 group-focus-within:blur-none group-focus-within:saturate-100"> 211 + <MessagesPanel embedded /> 212 + </div> 213 + </div> 214 + ); 215 + } 216 + 217 + function SearchBody(props: { config: { mode: "network" | "keyword" | "semantic" | "hybrid"; query: string } | null }) { 218 + return ( 219 + <div class="min-h-0 min-w-0 overflow-hidden px-3 pb-3 pt-3"> 220 + <SearchPanel embedded initialMode={props.config?.mode} initialQuery={props.config?.query} /> 221 + </div> 222 + ); 223 + } 224 + 225 + function ProfileBody(props: { actor: string | null }) { 226 + return ( 227 + <Show 228 + when={props.actor} 229 + fallback={ 230 + <div class="flex items-center justify-center p-6 text-sm text-on-surface-variant"> 231 + Invalid profile configuration. 232 + </div> 233 + }> 234 + {(actor) => <ProfilePanel actor={actor()} embedded />} 235 + </Show> 236 + ); 237 + } 238 + 197 239 export function DeckColumn(props: DeckColumnProps) { 198 - const title = () => columnTitle(props.column.kind, props.column.config); 240 + const title = () => props.feedColumn?.title ?? columnTitle(props.column.kind, props.column.config); 199 241 const widthPx = () => COLUMN_WIDTH_PX[props.column.width]; 200 242 201 243 return ( ··· 210 252 onMoveRight={() => props.onMoveRight(props.column.id)} 211 253 onWidthCycle={() => props.onWidthChange(props.column.id, cycleWidth(props.column.width))} /> 212 254 <div class="grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)]"> 213 - <ColumnBody column={props.column} onOpenThread={props.onOpenThread} /> 255 + <ColumnBody column={props.column} feedColumn={props.feedColumn} onOpenThread={props.onOpenThread} /> 214 256 </div> 215 257 </section> 216 258 );
+95 -5
src/components/deck/DeckWorkspace.tsx
··· 1 1 import { useAppSession } from "$/contexts/app-session"; 2 2 import { addColumn, getColumns, removeColumn, reorderColumns, updateColumn } from "$/lib/api/columns"; 3 - import type { Column, ColumnKind, ColumnWidth } from "$/lib/api/columns"; 3 + import { getFeedGenerators, getPreferences } from "$/lib/api/feeds"; 4 + import type { Column, ColumnKind, ColumnWidth } from "$/lib/api/types/columns"; 5 + import { getFeedName } from "$/lib/feeds"; 6 + import type { FeedGeneratorView } from "$/lib/types"; 4 7 import { useNavigate } from "@solidjs/router"; 5 8 import * as logger from "@tauri-apps/plugin-log"; 6 9 import { createEffect, For, onCleanup, onMount, Show } from "solid-js"; ··· 9 12 import { ActionIcon, Icon } from "../shared/Icon"; 10 13 import { AddColumnPanel } from "./AddColumnPanel"; 11 14 import { DeckColumn } from "./DeckColumn"; 15 + import { parseFeedConfig, type ResolvedFeedColumn, resolveFeedColumn } from "./types"; 12 16 13 - type DeckState = { addPanelOpen: boolean; columns: Column[]; error: string | null; loading: boolean }; 17 + type DeckState = { 18 + addPanelOpen: boolean; 19 + columns: Column[]; 20 + error: string | null; 21 + feedColumns: Record<string, ResolvedFeedColumn>; 22 + loading: boolean; 23 + }; 14 24 15 25 function DeckToolbar(props: { columnCount: number; onAdd: () => void }) { 16 26 return ( ··· 62 72 function ColumnList( 63 73 props: { 64 74 columns: Column[]; 75 + feedColumns: Record<string, ResolvedFeedColumn>; 65 76 onClose: (id: string) => void; 66 77 onMoveLeft: (id: string) => void; 67 78 onMoveRight: (id: string) => void; ··· 80 91 transition={{ duration: 0.18, easing: [0.34, 1.56, 0.64, 1] }}> 81 92 <DeckColumn 82 93 column={column} 94 + feedColumn={props.feedColumns[column.id]} 83 95 onClose={props.onClose} 84 96 onMoveLeft={props.onMoveLeft} 85 97 onMoveRight={props.onMoveRight} ··· 110 122 export function DeckWorkspace() { 111 123 const session = useAppSession(); 112 124 const navigate = useNavigate(); 125 + let feedColumnRequest = 0; 113 126 114 - const [state, setState] = createStore<DeckState>({ addPanelOpen: false, columns: [], error: null, loading: true }); 127 + const [state, setState] = createStore<DeckState>({ 128 + addPanelOpen: false, 129 + columns: [], 130 + error: null, 131 + feedColumns: {}, 132 + loading: true, 133 + }); 115 134 116 135 const activeDid = () => session.activeDid; 117 136 ··· 122 141 const cols = await getColumns(did); 123 142 setState("columns", cols); 124 143 setState("error", null); 144 + void hydrateFeedColumns(cols); 125 145 } catch (err) { 126 146 const message = err instanceof Error ? err.message : String(err); 127 147 logger.error(`Failed to load deck columns: ${message}`); ··· 131 151 } 132 152 } 133 153 154 + async function hydrateFeedColumns(columns: Column[]) { 155 + const currentRequest = ++feedColumnRequest; 156 + const parsedFeedColumns = columns.flatMap((column) => { 157 + if (column.kind !== "feed") { 158 + return []; 159 + } 160 + 161 + const config = parseFeedConfig(column.config); 162 + return config ? [{ columnId: column.id, config }] : []; 163 + }); 164 + 165 + if (parsedFeedColumns.length === 0) { 166 + setState("feedColumns", {}); 167 + return; 168 + } 169 + 170 + setState( 171 + "feedColumns", 172 + Object.fromEntries(parsedFeedColumns.map(({ columnId, config }) => [columnId, resolveFeedColumn(config)])), 173 + ); 174 + 175 + try { 176 + const preferences = await getPreferences(); 177 + const savedFeedTitles = Object.fromEntries( 178 + preferences.savedFeeds.map((feed) => [feed.value, getFeedName(feed, void 0)]), 179 + ); 180 + 181 + const generatorUris = [ 182 + ...new Set( 183 + parsedFeedColumns.filter(({ config }) => config.feedType === "feed").map(({ config }) => 184 + config.feedUri 185 + ), 186 + ), 187 + ]; 188 + let generators: Record<string, FeedGeneratorView> = {}; 189 + 190 + if (generatorUris.length > 0) { 191 + const hydrated = await getFeedGenerators(generatorUris); 192 + generators = Object.fromEntries(hydrated.feeds.map((generator) => [generator.uri, generator])); 193 + } 194 + 195 + const nextFeedColumns = Object.fromEntries( 196 + parsedFeedColumns.map(( 197 + { columnId, config }, 198 + ) => [ 199 + columnId, 200 + resolveFeedColumn(config, { 201 + generator: generators[config.feedUri], 202 + savedFeedTitle: savedFeedTitles[config.feedUri], 203 + }), 204 + ]), 205 + ); 206 + 207 + if (currentRequest !== feedColumnRequest) { 208 + return; 209 + } 210 + 211 + setState("feedColumns", nextFeedColumns); 212 + } catch (err) { 213 + logger.warn(`Failed to hydrate deck feed columns: ${String(err)}`); 214 + } 215 + } 216 + 134 217 async function handleAdd(kind: ColumnKind, config: string) { 135 218 const did = activeDid(); 136 219 if (!did) return; 137 220 try { 138 221 const col = await addColumn(did, kind, config); 139 - setState("columns", (prev) => [...prev, col]); 222 + const nextColumns = [...state.columns, col]; 223 + setState("columns", nextColumns); 140 224 setState("addPanelOpen", false); 225 + if (kind === "feed") { 226 + void hydrateFeedColumns(nextColumns); 227 + } 141 228 } catch (err) { 142 229 logger.error(`Failed to add column: ${String(err)}`); 143 230 } ··· 146 233 async function handleClose(id: string) { 147 234 try { 148 235 await removeColumn(id); 149 - setState("columns", (prev) => prev.filter((c) => c.id !== id)); 236 + const nextColumns = state.columns.filter((column) => column.id !== id); 237 + setState("columns", nextColumns); 238 + void hydrateFeedColumns(nextColumns); 150 239 } catch (err) { 151 240 logger.error(`Failed to remove column: ${String(err)}`); 152 241 } ··· 248 337 <Show when={!state.loading && state.columns.length > 0}> 249 338 <ColumnList 250 339 columns={state.columns} 340 + feedColumns={state.feedColumns} 251 341 onClose={handleClose} 252 342 onMoveLeft={handleMoveLeft} 253 343 onMoveRight={handleMoveRight}
+33
src/components/deck/types.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { parseFeedConfig, resolveFeedColumn } from "./types"; 3 + 4 + describe("deck feed column helpers", () => { 5 + it("rejects malformed feed configs", () => { 6 + expect(parseFeedConfig(JSON.stringify({ feedType: "timeline" }))).toBeNull(); 7 + expect(parseFeedConfig(JSON.stringify({ feedType: "wat", feedUri: "following" }))).toBeNull(); 8 + expect(parseFeedConfig(JSON.stringify({ feedType: "feed", feedUri: 42 }))).toBeNull(); 9 + expect(parseFeedConfig(JSON.stringify({ feedType: "feed", feedUri: "at://feed", title: 42 }))).toBeNull(); 10 + }); 11 + 12 + it("resolves deck feed columns from the shared feed model", () => { 13 + const resolved = resolveFeedColumn({ 14 + feedType: "feed", 15 + feedUri: "at://did:plc:alice/app.bsky.feed.generator/test-feed", 16 + }, { 17 + generator: { 18 + did: "did:plc:alice", 19 + displayName: "For You", 20 + uri: "at://did:plc:alice/app.bsky.feed.generator/test-feed", 21 + }, 22 + }); 23 + 24 + expect(resolved.feed).toEqual({ 25 + id: "at://did:plc:alice/app.bsky.feed.generator/test-feed", 26 + pinned: false, 27 + type: "feed", 28 + value: "at://did:plc:alice/app.bsky.feed.generator/test-feed", 29 + }); 30 + expect(resolved.title).toBe("For You"); 31 + expect(resolved.generator?.displayName).toBe("For You"); 32 + }); 33 + });
+91 -13
src/components/deck/types.ts
··· 1 - import type { ColumnWidth, DiagnosticsColumnConfig, ExplorerColumnConfig, FeedColumnConfig } from "$/lib/api/columns"; 2 - import type { SavedFeedItem } from "$/lib/types"; 1 + import type { 2 + ColumnWidth, 3 + DiagnosticsColumnConfig, 4 + ExplorerColumnConfig, 5 + FeedColumnConfig, 6 + MessagesColumnConfig, 7 + ProfileColumnConfig, 8 + SearchColumnConfig, 9 + } from "$/lib/api/types/columns"; 10 + import { getFeedName } from "$/lib/feeds"; 11 + import type { FeedGeneratorView, SavedFeedItem } from "$/lib/types"; 3 12 4 13 export const COLUMN_WIDTH_PX: Record<ColumnWidth, number> = { narrow: 320, standard: 420, wide: 560 }; 5 14 15 + export type ResolvedFeedColumn = { 16 + config: FeedColumnConfig; 17 + feed: SavedFeedItem; 18 + generator?: FeedGeneratorView; 19 + title: string; 20 + }; 21 + 6 22 export function cycleWidth(current: ColumnWidth): ColumnWidth { 7 23 switch (current) { 8 24 case "narrow": { ··· 17 33 } 18 34 } 19 35 36 + function isFeedType(value: unknown): value is FeedColumnConfig["feedType"] { 37 + return value === "timeline" || value === "feed" || value === "list"; 38 + } 39 + 20 40 export function parseFeedConfig(config: string): FeedColumnConfig | null { 21 41 try { 22 - const parsed = JSON.parse(config) as unknown; 23 - if (parsed && typeof parsed === "object" && "feedType" in parsed) { 24 - return parsed as FeedColumnConfig; 42 + const parsed = JSON.parse(config) as Record<string, unknown>; 43 + if (!parsed || typeof parsed !== "object") { 44 + return null; 45 + } 46 + 47 + if (!isFeedType(parsed.feedType) || typeof parsed.feedUri !== "string") { 48 + return null; 49 + } 50 + 51 + if (parsed.title !== undefined && parsed.title !== null && typeof parsed.title !== "string") { 52 + return null; 25 53 } 26 - return null; 54 + 55 + return { feedType: parsed.feedType, feedUri: parsed.feedUri, title: parsed.title as string | null | undefined }; 27 56 } catch { 28 57 return null; 29 58 } ··· 53 82 } 54 83 } 55 84 85 + export function parseMessagesConfig(config: string): MessagesColumnConfig | null { 86 + try { 87 + const parsed = JSON.parse(config) as unknown; 88 + return parsed && typeof parsed === "object" ? parsed as MessagesColumnConfig : null; 89 + } catch { 90 + return null; 91 + } 92 + } 93 + 94 + export function parseSearchConfig(config: string): SearchColumnConfig | null { 95 + try { 96 + const parsed = JSON.parse(config) as unknown; 97 + if (parsed && typeof parsed === "object" && "mode" in parsed && "query" in parsed) { 98 + return parsed as SearchColumnConfig; 99 + } 100 + return null; 101 + } catch { 102 + return null; 103 + } 104 + } 105 + 106 + export function parseProfileConfig(config: string): ProfileColumnConfig | null { 107 + try { 108 + const parsed = JSON.parse(config) as unknown; 109 + if (parsed && typeof parsed === "object" && "actor" in parsed) { 110 + return parsed as ProfileColumnConfig; 111 + } 112 + return null; 113 + } catch { 114 + return null; 115 + } 116 + } 117 + 56 118 export function feedConfigToSavedFeedItem(config: FeedColumnConfig): SavedFeedItem { 57 119 return { 58 120 id: config.feedUri || "following", ··· 62 124 }; 63 125 } 64 126 127 + export function resolveFeedColumn( 128 + config: FeedColumnConfig, 129 + options: { generator?: FeedGeneratorView; savedFeedTitle?: string | null } = {}, 130 + ): ResolvedFeedColumn { 131 + const feed = feedConfigToSavedFeedItem(config); 132 + const hydratedTitle = options.generator?.displayName || config.title?.trim() || options.savedFeedTitle?.trim(); 133 + 134 + return { config, feed, generator: options.generator, title: getFeedName(feed, hydratedTitle) }; 135 + } 136 + 65 137 export function columnTitle(kind: string, config: string): string { 66 138 switch (kind) { 67 139 case "feed": { 68 - const parsed = parseFeedConfig(config); 69 - if (!parsed) return "Feed"; 70 - if (parsed.feedType === "timeline") return "Timeline"; 71 - const segment = parsed.feedUri.split("/").at(-1)?.trim(); 72 - return segment ? segment.replaceAll("-", " ") : (parsed.feedType === "list" ? "List" : "Feed"); 140 + return "Feed"; 73 141 } 74 142 case "explorer": { 75 143 const parsed = parseExplorerConfig(config); ··· 80 148 const parsed = parseDiagnosticsConfig(config); 81 149 return parsed?.did ?? "Diagnostics"; 82 150 } 151 + case "messages": { 152 + return "Messages"; 153 + } 154 + case "search": { 155 + const parsed = parseSearchConfig(config); 156 + const query = parsed?.query.trim(); 157 + return query ? `Search: ${query}` : "Search"; 158 + } 159 + case "profile": { 160 + const parsed = parseProfileConfig(config); 161 + return parsed?.displayName?.trim() || parsed?.handle?.trim() || parsed?.actor || "Profile"; 162 + } 83 163 default: { 84 164 return "Column"; 85 165 } 86 166 } 87 167 } 88 - 89 - export { type Column, type ColumnWidth } from "$/lib/api/columns";
+9 -3
src/components/messages/MessagesPanel.tsx
··· 8 8 import { AvatarBadge } from "../AvatarBadge"; 9 9 import { Icon } from "../shared/Icon"; 10 10 11 - type MessagesPanelProps = { memberDid?: string | null }; 11 + type MessagesPanelProps = { embedded?: boolean; memberDid?: string | null }; 12 12 13 13 type MessagesState = { 14 14 convos: ConvoView[]; ··· 611 611 612 612 return ( 613 613 <div class="flex h-full min-h-0 gap-0"> 614 - <aside class="flex w-80 shrink-0 flex-col overflow-hidden rounded-2xl border-r border-white/5 bg-surface-container/40"> 614 + <aside 615 + class="flex shrink-0 flex-col overflow-hidden border-r border-white/5 bg-surface-container/40" 616 + classList={{ 617 + "rounded-2xl": !props.embedded, 618 + "max-w-64 min-w-40 w-[44%]": props.embedded, 619 + "w-80": !props.embedded, 620 + }}> 615 621 <header class="flex shrink-0 items-center justify-between border-b border-white/5 bg-surface-container/80 px-5 py-4 backdrop-blur-[12px]"> 616 622 <div> 617 623 <h1 class="m-0 text-lg font-semibold tracking-tight text-on-surface">Messages</h1> ··· 667 673 </div> 668 674 </aside> 669 675 670 - <div class="flex min-w-0 flex-1 flex-col overflow-hidden"> 676 + <div class="flex min-w-0 flex-1 flex-col overflow-hidden bg-surface/10"> 671 677 <Show when={activeConvo()} keyed fallback={<EmptyChatPane />}> 672 678 {(convo) => ( 673 679 <ChatPane
+4 -2
src/components/profile/ProfilePanel.tsx
··· 75 75 }; 76 76 } 77 77 78 - export function ProfilePanel(props: { actor: string | null }) { 78 + export function ProfilePanel(props: { actor: string | null; embedded?: boolean }) { 79 79 const navigate = useNavigate(); 80 80 const session = useAppSession(); 81 81 const [state, setState] = createStore<ProfilePanelState>(createProfilePanelState()); ··· 373 373 } 374 374 375 375 return ( 376 - <section class="relative grid min-h-0 overflow-hidden rounded-4xl bg-[rgba(8,8,8,0.32)] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)]"> 376 + <section 377 + class="relative grid min-h-0 overflow-hidden bg-[rgba(8,8,8,0.32)]" 378 + classList={{ "rounded-4xl shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)]": !props.embedded }}> 377 379 <div 378 380 class="min-h-0 overflow-y-auto overscroll-contain" 379 381 onScroll={(event) => setState("scrollTop", event.currentTarget.scrollTop)}>
+37 -14
src/components/search/SearchPanel.tsx
··· 2 2 import { useAppPreferences } from "$/contexts/app-preferences"; 3 3 import { useAppSession } from "$/contexts/app-session"; 4 4 import { 5 + getSyncStatus, 5 6 type LocalPostResult, 6 7 type NetworkSearchResult, 7 8 type SearchMode, ··· 35 36 syncStatus: SyncStatus[]; 36 37 }; 37 38 39 + type SearchPanelProps = { embedded?: boolean; initialMode?: SearchMode; initialQuery?: string }; 40 + 38 41 function ModeLabel(props: { mode: SearchMode }) { 39 42 return ( 40 43 <span class="flex items-center gap-1.5"> ··· 49 52 ); 50 53 } 51 54 52 - export function SearchPanel() { 55 + export function SearchPanel(props: SearchPanelProps = {}) { 53 56 const preferences = useAppPreferences(); 54 57 const session = useAppSession(); 55 58 const [search, setSearch] = createStore<SearchPanelState>({ 56 59 error: null, 57 60 hasSearched: false, 58 61 loading: false, 59 - mode: "network", 62 + mode: props.initialMode ?? "network", 60 63 networkResults: null, 61 - query: "", 64 + query: props.initialQuery ?? "", 62 65 resultCount: 0, 63 66 results: [], 64 67 syncStatus: [], ··· 181 184 } 182 185 183 186 onMount(() => { 184 - document.addEventListener("keydown", handleGlobalKeyDown); 187 + if (!props.embedded) { 188 + document.addEventListener("keydown", handleGlobalKeyDown); 189 + } 190 + if (props.embedded && session.activeDid) { 191 + void getSyncStatus(session.activeDid).then((status) => { 192 + setSearch("syncStatus", status); 193 + }).catch((error) => { 194 + logger.warn("failed to load embedded search sync status", { keyValues: { error: normalizeError(error) } }); 195 + }); 196 + } 197 + if (search.query.trim()) { 198 + void performSearch(search.query, search.mode); 199 + } 185 200 186 201 onCleanup(() => { 187 - document.removeEventListener("keydown", handleGlobalKeyDown); 202 + if (!props.embedded) { 203 + document.removeEventListener("keydown", handleGlobalKeyDown); 204 + } 188 205 clearTimeout(debounceTimer); 189 206 }); 190 207 }); ··· 199 216 }); 200 217 201 218 return ( 202 - <div class="grid min-h-0 gap-6 xl:grid-cols-[minmax(0,1fr)_20rem]"> 203 - <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)]"> 219 + <div class="grid min-h-0 gap-6" classList={{ "xl:grid-cols-[minmax(0,1fr)_20rem]": !props.embedded }}> 220 + <section 221 + class="grid min-h-0 grid-rows-[auto_1fr] overflow-hidden" 222 + classList={{ 223 + "rounded-4xl bg-surface-container shadow-[inset_0_0_0_1px_rgba(255,255,255,0.035)]": !props.embedded, 224 + }}> 204 225 <SearchHeader 205 226 error={search.error} 206 227 hasSearched={search.hasSearched} ··· 230 251 query={search.query} /> 231 252 </section> 232 253 233 - <aside class="grid content-start gap-4 overflow-y-auto"> 234 - <Show when={session.activeDid}> 235 - {(did) => <SyncStatusPanel did={did()} onStatusChange={(status) => setSearch("syncStatus", status)} />} 236 - </Show> 237 - <EmbeddingsSettings /> 238 - <SearchTipsCard /> 239 - </aside> 254 + <Show when={!props.embedded}> 255 + <aside class="grid content-start gap-4 overflow-y-auto"> 256 + <Show when={session.activeDid}> 257 + {(did) => <SyncStatusPanel did={did()} onStatusChange={(status) => setSearch("syncStatus", status)} />} 258 + </Show> 259 + <EmbeddingsSettings /> 260 + <SearchTipsCard /> 261 + </aside> 262 + </Show> 240 263 </div> 241 264 ); 242 265 }
+1 -20
src/lib/api/columns.ts
··· 1 1 import { invoke } from "@tauri-apps/api/core"; 2 - 3 - export type ColumnKind = "feed" | "explorer" | "diagnostics"; 4 - 5 - export type ColumnWidth = "narrow" | "standard" | "wide"; 6 - 7 - export type Column = { 8 - id: string; 9 - accountDid: string; 10 - kind: ColumnKind; 11 - config: string; 12 - position: number; 13 - width: ColumnWidth; 14 - createdAt: string; 15 - }; 16 - 17 - export type FeedColumnConfig = { feedUri: string; feedType: "timeline" | "feed" | "list" }; 18 - 19 - export type ExplorerColumnConfig = { targetUri: string }; 20 - 21 - export type DiagnosticsColumnConfig = { did: string }; 2 + import type { Column, ColumnKind, ColumnWidth } from "./types/columns"; 22 3 23 4 export function getColumns(accountDid: string) { 24 5 return invoke<Column[]>("get_columns", { accountDid });
+32
src/lib/api/types/columns.ts
··· 1 + import type { SearchMode } from "../search"; 2 + 3 + export type ColumnKind = "feed" | "explorer" | "diagnostics" | "messages" | "search" | "profile"; 4 + 5 + export type ColumnWidth = "narrow" | "standard" | "wide"; 6 + 7 + export type Column = { 8 + id: string; 9 + accountDid: string; 10 + kind: ColumnKind; 11 + config: string; 12 + position: number; 13 + width: ColumnWidth; 14 + createdAt: string; 15 + }; 16 + 17 + export type FeedColumnConfig = { feedUri: string; feedType: "timeline" | "feed" | "list"; title?: string | null }; 18 + 19 + export type ExplorerColumnConfig = { targetUri: string }; 20 + 21 + export type DiagnosticsColumnConfig = { did: string }; 22 + 23 + export type MessagesColumnConfig = Record<string, never>; 24 + 25 + export type SearchColumnConfig = { mode: SearchMode; query: string }; 26 + 27 + export type ProfileColumnConfig = { 28 + actor: string; 29 + did?: string | null; 30 + displayName?: string | null; 31 + handle?: string | null; 32 + };