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: saved/liked post screen

+1324 -220
+32 -32
src-tauri/src/auth.rs
··· 1 1 use super::db::DbPool; 2 - use super::error::{AppError, TypeaheadFetchError, TypeaheadFetchErrorKind}; 2 + use super::error::{AppError, Result, TypeaheadFetchError, TypeaheadFetchErrorKind}; 3 3 use super::state::{AccountSummary, ActiveSession}; 4 4 use jacquard::api::app_bsky::actor::get_profile::GetProfile; 5 5 use jacquard::api::com_atproto::server::get_session::GetSession; ··· 85 85 Self { db_pool } 86 86 } 87 87 88 - pub fn lock_connection(&self) -> Result<MutexGuard<'_, rusqlite::Connection>, AppError> { 88 + pub fn lock_connection(&self) -> Result<MutexGuard<'_, rusqlite::Connection>> { 89 89 self.db_pool.lock().map_err(|_| AppError::StatePoisoned("db_pool")) 90 90 } 91 91 92 - pub fn load_accounts(&self) -> Result<Vec<StoredAccount>, AppError> { 92 + pub fn load_accounts(&self) -> Result<Vec<StoredAccount>> { 93 93 let connection = self.lock_connection()?; 94 94 let mut statement = connection.prepare( 95 95 " ··· 124 124 Ok(accounts) 125 125 } 126 126 127 - pub fn get_account(&self, did: &str) -> Result<Option<StoredAccount>, AppError> { 127 + pub fn get_account(&self, did: &str) -> Result<Option<StoredAccount>> { 128 128 let connection = self.lock_connection()?; 129 129 connection 130 130 .query_row( ··· 155 155 .map_err(AppError::from) 156 156 } 157 157 158 - pub fn get_latest_session_id(&self, did: &str) -> Result<Option<String>, AppError> { 158 + pub fn get_latest_session_id(&self, did: &str) -> Result<Option<String>> { 159 159 let connection = self.lock_connection()?; 160 160 connection 161 161 .query_row( ··· 173 173 .map_err(AppError::from) 174 174 } 175 175 176 - pub fn update_account_session_id(&self, did: &str, session_id: &str) -> Result<(), AppError> { 176 + pub fn update_account_session_id(&self, did: &str, session_id: &str) -> Result<()> { 177 177 let connection = self.lock_connection()?; 178 178 let rows_updated = connection.execute( 179 179 "UPDATE accounts SET session_id = ?2 WHERE did = ?1", ··· 189 189 Ok(()) 190 190 } 191 191 192 - pub fn upsert_account( 193 - &self, account: &AccountSummary, session_id: &str, make_active: bool, 194 - ) -> Result<(), AppError> { 192 + pub fn upsert_account(&self, account: &AccountSummary, session_id: &str, make_active: bool) -> Result<()> { 195 193 let mut connection = self.lock_connection()?; 196 194 let transaction = connection.transaction()?; 197 195 ··· 229 227 Ok(()) 230 228 } 231 229 232 - pub fn set_active_account(&self, did: &str) -> Result<(), AppError> { 230 + pub fn set_active_account(&self, did: &str) -> Result<()> { 233 231 let mut connection = self.lock_connection()?; 234 232 let transaction = connection.transaction()?; 235 233 transaction.execute("UPDATE accounts SET active = 0 WHERE active = 1", [])?; ··· 245 243 Ok(()) 246 244 } 247 245 248 - pub fn clear_active_account(&self) -> Result<(), AppError> { 246 + pub fn clear_active_account(&self) -> Result<()> { 249 247 let connection = self.lock_connection()?; 250 248 connection.execute("UPDATE accounts SET active = 0 WHERE active = 1", [])?; 251 249 Ok(()) 252 250 } 253 251 254 - pub fn prune_orphaned_sessions(&self) -> Result<(), AppError> { 252 + pub fn prune_orphaned_sessions(&self) -> Result<()> { 255 253 let connection = self.lock_connection()?; 256 254 connection.execute( 257 255 " ··· 263 261 Ok(()) 264 262 } 265 263 266 - pub fn delete_persisted_session(&self, did: &str, session_id: &str) -> Result<(), AppError> { 264 + pub fn delete_persisted_session(&self, did: &str, session_id: &str) -> Result<()> { 267 265 let connection = self.lock_connection()?; 268 266 connection.execute( 269 267 "DELETE FROM oauth_sessions WHERE did = ?1 AND session_id = ?2", ··· 272 270 Ok(()) 273 271 } 274 272 275 - pub fn delete_account(&self, did: &str) -> Result<Option<String>, AppError> { 273 + pub fn delete_account(&self, did: &str) -> Result<Option<String>> { 276 274 let mut connection = self.lock_connection()?; 277 275 let transaction = connection.transaction()?; 278 276 ··· 313 311 impl ClientAuthStore for PersistentAuthStore { 314 312 async fn get_session( 315 313 &self, did: &Did<'_>, session_id: &str, 316 - ) -> Result<Option<ClientSessionData<'_>>, SessionStoreError> { 314 + ) -> std::result::Result<Option<ClientSessionData<'_>>, SessionStoreError> { 317 315 let connection = self.lock_connection().map_err(app_to_store_error)?; 318 316 let payload: Option<String> = connection 319 317 .query_row( ··· 334 332 .map_err(SessionStoreError::from) 335 333 } 336 334 337 - async fn upsert_session(&self, session: ClientSessionData<'_>) -> Result<(), SessionStoreError> { 335 + async fn upsert_session(&self, session: ClientSessionData<'_>) -> std::result::Result<(), SessionStoreError> { 338 336 let connection = self.lock_connection().map_err(app_to_store_error)?; 339 337 let payload = serde_json::to_string(&session).map_err(SessionStoreError::from)?; 340 338 ··· 354 352 Ok(()) 355 353 } 356 354 357 - async fn delete_session(&self, did: &Did<'_>, session_id: &str) -> Result<(), SessionStoreError> { 355 + async fn delete_session(&self, did: &Did<'_>, session_id: &str) -> std::result::Result<(), SessionStoreError> { 358 356 let connection = self.lock_connection().map_err(app_to_store_error)?; 359 357 connection 360 358 .execute( ··· 365 363 Ok(()) 366 364 } 367 365 368 - async fn get_auth_req_info(&self, state: &str) -> Result<Option<AuthRequestData<'_>>, SessionStoreError> { 366 + async fn get_auth_req_info( 367 + &self, state: &str, 368 + ) -> std::result::Result<Option<AuthRequestData<'_>>, SessionStoreError> { 369 369 let connection = self.lock_connection().map_err(app_to_store_error)?; 370 370 let payload: Option<String> = connection 371 371 .query_row( ··· 382 382 .map_err(SessionStoreError::from) 383 383 } 384 384 385 - async fn save_auth_req_info(&self, auth_req_info: &AuthRequestData<'_>) -> Result<(), SessionStoreError> { 385 + async fn save_auth_req_info( 386 + &self, auth_req_info: &AuthRequestData<'_>, 387 + ) -> std::result::Result<(), SessionStoreError> { 386 388 let connection = self.lock_connection().map_err(app_to_store_error)?; 387 389 let payload = serde_json::to_string(auth_req_info).map_err(SessionStoreError::from)?; 388 390 ··· 402 404 Ok(()) 403 405 } 404 406 405 - async fn delete_auth_req_info(&self, state: &str) -> Result<(), SessionStoreError> { 407 + async fn delete_auth_req_info(&self, state: &str) -> std::result::Result<(), SessionStoreError> { 406 408 let connection = self.lock_connection().map_err(app_to_store_error)?; 407 409 connection 408 410 .execute("DELETE FROM oauth_auth_requests WHERE state = ?1", params![state]) ··· 420 422 build_client_metadata("http://127.0.0.1/callback") 421 423 } 422 424 423 - pub async fn login_with_loopback( 424 - oauth_client: &LazuriteOAuthClient, identifier: &str, 425 - ) -> Result<LazuriteOAuthSession, AppError> { 425 + pub async fn login_with_loopback(oauth_client: &LazuriteOAuthClient, identifier: &str) -> Result<LazuriteOAuthSession> { 426 426 let config = LoopbackConfig::default(); 427 427 let options = AuthorizeOptions::default(); 428 428 let bind_addr = loopback_bind_addr(&config)?; ··· 442 442 complete_loopback_login(flow_client, callback_handle, config).await 443 443 } 444 444 445 - pub async fn fetch_account_summary(session: &LazuriteOAuthSession, active: bool) -> Result<AccountSummary, AppError> { 445 + pub async fn fetch_account_summary(session: &LazuriteOAuthSession, active: bool) -> Result<AccountSummary> { 446 446 let response = session 447 447 .send(GetSession) 448 448 .await ··· 476 476 OAuthSession::new(oauth_client.registry.clone(), oauth_client.client.clone(), session_data) 477 477 } 478 478 479 - pub fn emit_account_switch(app: &AppHandle, active_session: Option<ActiveSession>) -> Result<(), AppError> { 479 + pub fn emit_account_switch(app: &AppHandle, active_session: Option<ActiveSession>) -> Result<()> { 480 480 app.emit(ACCOUNT_SWITCHED_EVENT, active_session)?; 481 481 Ok(()) 482 482 } ··· 503 503 504 504 pub fn remove_cached_session( 505 505 sessions: &RwLock<HashMap<String, std::sync::Arc<LazuriteOAuthSession>>>, did: &str, 506 - ) -> Result<(), AppError> { 506 + ) -> Result<()> { 507 507 sessions 508 508 .write() 509 509 .map_err(|_| AppError::StatePoisoned("sessions"))? ··· 533 533 534 534 async fn complete_loopback_login( 535 535 flow_client: LazuriteOAuthClient, callback_handle: LocalCallbackServerHandle, config: LoopbackConfig, 536 - ) -> Result<LazuriteOAuthSession, AppError> { 536 + ) -> Result<LazuriteOAuthSession> { 537 537 let callback = tokio::time::timeout(Duration::from_millis(config.timeout_ms), callback_handle.callback_rx) 538 538 .await 539 539 .map_err(|_| AppError::validation("oauth loopback callback timed out"))? ··· 545 545 Ok(flow_client.callback(callback).await?) 546 546 } 547 547 548 - fn loopback_bind_addr(config: &LoopbackConfig) -> Result<SocketAddr, AppError> { 548 + fn loopback_bind_addr(config: &LoopbackConfig) -> Result<SocketAddr> { 549 549 let port = match config.port { 550 550 LoopbackPort::Fixed(port) => port, 551 551 LoopbackPort::Ephemeral => 0, ··· 556 556 .map_err(|error| AppError::Validation(format!("invalid loopback bind address: {error}"))) 557 557 } 558 558 559 - fn start_loopback_callback_server(bind_addr: SocketAddr) -> Result<(SocketAddr, LocalCallbackServerHandle), AppError> { 559 + fn start_loopback_callback_server(bind_addr: SocketAddr) -> Result<(SocketAddr, LocalCallbackServerHandle)> { 560 560 let listener = TcpListener::bind(bind_addr)?; 561 561 listener.set_nonblocking(true)?; 562 562 let local_addr = listener.local_addr()?; ··· 627 627 } 628 628 } 629 629 630 - fn parse_loopback_callback(request_target: &str) -> Result<CallbackParams<'static>, AppError> { 630 + fn parse_loopback_callback(request_target: &str) -> Result<CallbackParams<'static>> { 631 631 let url = reqwest::Url::parse(&format!("http://127.0.0.1{request_target}")) 632 632 .map_err(|error| AppError::validation(format!("invalid loopback callback URL: {error}")))?; 633 633 ··· 699 699 SessionStoreError::Other(Box::new(error)) 700 700 } 701 701 702 - pub async fn search_login_suggestions(query: &str) -> Result<Vec<LoginSuggestion>, AppError> { 702 + pub async fn search_login_suggestions(query: &str) -> Result<Vec<LoginSuggestion>> { 703 703 let Some(normalized_query) = normalize_login_suggestion_query(query) else { 704 704 return Ok(Vec::new()); 705 705 }; ··· 721 721 722 722 async fn fetch_login_suggestions_from_endpoint( 723 723 client: &reqwest::Client, base_url: &str, query: &str, 724 - ) -> Result<Vec<LoginSuggestion>, TypeaheadFetchError> { 724 + ) -> std::result::Result<Vec<LoginSuggestion>, TypeaheadFetchError> { 725 725 let response = client 726 726 .get(format!("{base_url}/xrpc/app.bsky.actor.searchActorsTypeahead")) 727 727 .header("X-Client", LOGIN_TYPEAHEAD_CLIENT)
+21 -18
src-tauri/src/commands/search.rs
··· 1 1 #![allow(clippy::needless_pass_by_value)] 2 - 3 - use super::super::error::AppError; 4 - use super::super::search::{self, PostResult, SyncStatus}; 5 - use super::super::state::AppState; 2 + use crate::search::{self, PostResult, SavedPostsPage, SyncStatus}; 3 + use crate::{error::Result, state::AppState}; 6 4 use serde_json::Value; 7 5 use tauri::{AppHandle, State}; 8 6 9 7 #[tauri::command] 10 8 pub async fn search_posts_network( 11 9 query: String, sort: Option<String>, limit: Option<u32>, cursor: Option<String>, state: State<'_, AppState>, 12 - ) -> Result<Value, AppError> { 10 + ) -> Result<Value> { 13 11 search::search_posts_network(query, sort, limit, cursor, &state).await 14 12 } 15 13 16 14 #[tauri::command] 17 15 pub fn search_posts( 18 16 query: String, mode: String, limit: u32, app: AppHandle, state: State<'_, AppState>, 19 - ) -> Result<Vec<PostResult>, AppError> { 17 + ) -> Result<Vec<PostResult>> { 20 18 search::search_posts(&query, &mode, limit, &app, &state) 21 19 } 22 20 23 21 #[tauri::command] 24 22 pub async fn search_actors( 25 23 query: String, limit: Option<u32>, cursor: Option<String>, state: State<'_, AppState>, 26 - ) -> Result<Value, AppError> { 24 + ) -> Result<Value> { 27 25 search::search_actors(query, limit, cursor, &state).await 28 26 } 29 27 30 28 #[tauri::command] 31 29 pub async fn search_starter_packs( 32 30 query: String, limit: Option<u32>, cursor: Option<String>, state: State<'_, AppState>, 33 - ) -> Result<Value, AppError> { 31 + ) -> Result<Value> { 34 32 search::search_starter_packs(query, limit, cursor, &state).await 35 33 } 36 34 37 35 #[tauri::command] 38 - pub async fn sync_posts(did: String, source: String, state: State<'_, AppState>) -> Result<SyncStatus, AppError> { 36 + pub async fn sync_posts(did: String, source: String, state: State<'_, AppState>) -> Result<SyncStatus> { 39 37 search::sync_posts(did, source, &state).await 40 38 } 41 39 42 40 #[tauri::command] 43 - pub fn get_sync_status(did: String, state: State<'_, AppState>) -> Result<Vec<SyncStatus>, AppError> { 41 + pub fn get_sync_status(did: String, state: State<'_, AppState>) -> Result<Vec<SyncStatus>> { 44 42 search::get_sync_status(&did, &state) 45 43 } 46 44 47 45 #[tauri::command] 48 - pub fn embed_pending_posts(app: AppHandle, state: State<'_, AppState>) -> Result<usize, AppError> { 46 + pub fn list_saved_posts( 47 + source: String, limit: u32, offset: u32, query: Option<String>, state: State<'_, AppState>, 48 + ) -> Result<SavedPostsPage> { 49 + search::list_saved_posts(&source, limit, offset, query.as_deref(), &state) 50 + } 51 + 52 + #[tauri::command] 53 + pub fn embed_pending_posts(app: AppHandle, state: State<'_, AppState>) -> Result<usize> { 49 54 search::embed_pending_posts(&app, &state) 50 55 } 51 56 52 57 #[tauri::command] 53 - pub fn reindex_embeddings(app: AppHandle, state: State<'_, AppState>) -> Result<usize, AppError> { 58 + pub fn reindex_embeddings(app: AppHandle, state: State<'_, AppState>) -> Result<usize> { 54 59 search::reindex_embeddings(&app, &state) 55 60 } 56 61 57 62 #[tauri::command] 58 - pub fn set_embeddings_enabled(enabled: bool, state: State<'_, AppState>) -> Result<(), AppError> { 63 + pub fn set_embeddings_enabled(enabled: bool, state: State<'_, AppState>) -> Result<()> { 59 64 search::set_embeddings_enabled(enabled, &state) 60 65 } 61 66 62 67 #[tauri::command] 63 - pub fn get_embeddings_enabled(state: State<'_, AppState>) -> Result<bool, AppError> { 68 + pub fn get_embeddings_enabled(state: State<'_, AppState>) -> Result<bool> { 64 69 search::get_embeddings_enabled(&state) 65 70 } 66 71 67 72 #[tauri::command] 68 - pub fn get_embeddings_config(app: AppHandle, state: State<'_, AppState>) -> Result<search::EmbeddingsConfig, AppError> { 73 + pub fn get_embeddings_config(app: AppHandle, state: State<'_, AppState>) -> Result<search::EmbeddingsConfig> { 69 74 search::get_embeddings_config(&app, &state) 70 75 } 71 76 72 77 #[tauri::command] 73 - pub fn prepare_embeddings_model( 74 - app: AppHandle, state: State<'_, AppState>, 75 - ) -> Result<search::EmbeddingsConfig, AppError> { 78 + pub fn prepare_embeddings_model(app: AppHandle, state: State<'_, AppState>) -> Result<search::EmbeddingsConfig> { 76 79 search::prepare_embeddings_model(&app, &state) 77 80 }
+1
src-tauri/src/lib.rs
··· 117 117 cmd::search::search_starter_packs, 118 118 cmd::search::sync_posts, 119 119 cmd::search::get_sync_status, 120 + cmd::search::list_saved_posts, 120 121 cmd::search::embed_pending_posts, 121 122 cmd::search::reindex_embeddings, 122 123 cmd::search::set_embeddings_enabled,
+216 -4
src-tauri/src/search.rs
··· 63 63 pub semantic_match: bool, 64 64 } 65 65 66 + #[derive(Debug, Serialize)] 67 + #[serde(rename_all = "camelCase")] 68 + pub struct SavedPostsPage { 69 + pub posts: Vec<PostResult>, 70 + pub total: i64, 71 + pub next_offset: Option<u32>, 72 + } 73 + 66 74 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 67 75 enum SearchMode { 68 76 Keyword, ··· 321 329 .map_err(AppError::from) 322 330 } 323 331 332 + fn map_saved_post_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<PostResult> { 333 + Ok(PostResult { 334 + uri: row.get(0)?, 335 + cid: row.get(1)?, 336 + author_did: row.get(2)?, 337 + author_handle: row.get(3)?, 338 + text: row.get(4)?, 339 + created_at: row.get(5)?, 340 + source: row.get(6)?, 341 + score: 0.0, 342 + keyword_match: false, 343 + semantic_match: false, 344 + }) 345 + } 346 + 347 + fn db_list_saved_posts( 348 + conn: &Connection, owner_did: &str, source: &str, limit: usize, offset: usize, query: Option<&str>, 349 + ) -> Result<SavedPostsPage> { 350 + let trimmed_query = query.map(str::trim).filter(|query| !query.is_empty()); 351 + let total = match trimmed_query { 352 + Some(query) => { 353 + let match_query = build_fts_match_query(query); 354 + conn.query_row( 355 + "SELECT COUNT(*) 356 + FROM posts_fts 357 + JOIN posts p ON p.rowid = posts_fts.rowid 358 + WHERE p.owner_did = ?1 359 + AND p.source = ?2 360 + AND posts_fts MATCH ?3", 361 + params![owner_did, source, match_query], 362 + |row| row.get(0), 363 + )? 364 + } 365 + None => db_post_count(conn, owner_did, source)?, 366 + }; 367 + 368 + let posts = match trimmed_query { 369 + Some(query) => { 370 + let match_query = build_fts_match_query(query); 371 + let mut stmt = conn.prepare( 372 + "SELECT p.uri, p.cid, p.author_did, p.author_handle, p.text, p.created_at, p.source 373 + FROM posts_fts 374 + JOIN posts p ON p.rowid = posts_fts.rowid 375 + WHERE p.owner_did = ?1 376 + AND p.source = ?2 377 + AND posts_fts MATCH ?3 378 + ORDER BY p.created_at DESC, p.uri DESC 379 + LIMIT ?4 OFFSET ?5", 380 + )?; 381 + 382 + let q = stmt.query_map( 383 + params![owner_did, source, match_query, limit as i64, offset as i64], 384 + map_saved_post_row, 385 + )?; 386 + 387 + q.collect::<rusqlite::Result<Vec<_>>>().map_err(AppError::from)? 388 + } 389 + None => { 390 + let mut stmt = conn.prepare( 391 + "SELECT uri, cid, author_did, author_handle, text, created_at, source 392 + FROM posts 393 + WHERE owner_did = ?1 AND source = ?2 394 + ORDER BY created_at DESC, uri DESC 395 + LIMIT ?3 OFFSET ?4", 396 + )?; 397 + 398 + let q = stmt.query_map( 399 + params![owner_did, source, limit as i64, offset as i64], 400 + map_saved_post_row, 401 + )?; 402 + 403 + q.collect::<rusqlite::Result<Vec<_>>>().map_err(AppError::from)? 404 + } 405 + }; 406 + 407 + let consumed = offset.saturating_add(posts.len()); 408 + let next_offset = (consumed < total as usize).then_some(consumed as u32); 409 + 410 + Ok(SavedPostsPage { posts, total, next_offset }) 411 + } 412 + 324 413 fn db_sync_status(conn: &Connection, did: &str, source: &str) -> Result<SyncStatus> { 325 414 let post_count = db_post_count(conn, did, source)?; 326 415 let (cursor, last_synced_at) = conn ··· 564 653 .into_iter() 565 654 .map(|source| db_sync_status(&conn, did, source)) 566 655 .collect() 656 + } 657 + 658 + pub fn list_saved_posts( 659 + source: &str, limit: u32, offset: u32, query: Option<&str>, state: &AppState, 660 + ) -> Result<SavedPostsPage> { 661 + validate_source(source)?; 662 + let limit = validate_limit(limit)?; 663 + let owner_did = active_session_did(state)?.ok_or_else(|| AppError::validation("no active account"))?; 664 + let conn = state.auth_store.lock_connection()?; 665 + db_list_saved_posts(&conn, &owner_did, source, limit, offset as usize, query) 567 666 } 568 667 569 668 const EMBED_BATCH_SIZE: usize = 32; ··· 1201 1300 #[cfg(test)] 1202 1301 mod tests { 1203 1302 use super::{ 1204 - build_fts_match_query, db_get_embeddings_enabled, db_load_sync_cursor, db_post_count, db_save_sync_state, 1205 - db_semantic_search, db_set_embeddings_enabled, db_sync_status, db_upsert_embedding, db_upsert_post, 1206 - run_local_search, storage_key, sync_due, validate_limit, validate_query, validate_search_mode, validate_source, 1207 - SearchMode, 1303 + build_fts_match_query, db_get_embeddings_enabled, db_list_saved_posts, db_load_sync_cursor, db_post_count, 1304 + db_save_sync_state, db_semantic_search, db_set_embeddings_enabled, db_sync_status, db_upsert_embedding, 1305 + db_upsert_post, run_local_search, storage_key, sync_due, validate_limit, validate_query, validate_search_mode, 1306 + validate_source, SearchMode, 1208 1307 }; 1209 1308 use rusqlite::{ffi::sqlite3_auto_extension, Connection}; 1210 1309 use sqlite_vec::sqlite3_vec_init; ··· 1530 1629 assert_eq!(db_post_count(&conn, "did:plc:alice", "like").unwrap(), 1); 1531 1630 assert_eq!(db_post_count(&conn, "did:plc:alice", "bookmark").unwrap(), 1); 1532 1631 assert_eq!(db_post_count(&conn, "did:plc:bob", "like").unwrap(), 1); 1632 + } 1633 + 1634 + #[test] 1635 + fn list_saved_posts_is_scoped_and_sorted_by_created_at_then_uri() { 1636 + let conn = test_db(); 1637 + insert_post( 1638 + &conn, 1639 + "did:plc:alice", 1640 + "at://alice/app.bsky.feed.post/2", 1641 + "bookmark", 1642 + "second", 1643 + "2024-01-02T00:00:00Z", 1644 + ); 1645 + insert_post( 1646 + &conn, 1647 + "did:plc:alice", 1648 + "at://alice/app.bsky.feed.post/3", 1649 + "bookmark", 1650 + "third", 1651 + "2024-01-02T00:00:00Z", 1652 + ); 1653 + insert_post( 1654 + &conn, 1655 + "did:plc:alice", 1656 + "at://alice/app.bsky.feed.post/1", 1657 + "bookmark", 1658 + "first", 1659 + "2024-01-01T00:00:00Z", 1660 + ); 1661 + insert_post( 1662 + &conn, 1663 + "did:plc:alice", 1664 + "at://alice/app.bsky.feed.post/4", 1665 + "like", 1666 + "liked", 1667 + "2024-01-03T00:00:00Z", 1668 + ); 1669 + insert_post( 1670 + &conn, 1671 + "did:plc:bob", 1672 + "at://bob/app.bsky.feed.post/1", 1673 + "bookmark", 1674 + "bob saved", 1675 + "2024-01-04T00:00:00Z", 1676 + ); 1677 + 1678 + let page = db_list_saved_posts(&conn, "did:plc:alice", "bookmark", 10, 0, None).unwrap(); 1679 + let uris: Vec<&str> = page.posts.iter().map(|post| post.uri.as_str()).collect(); 1680 + 1681 + assert_eq!( 1682 + uris, 1683 + vec![ 1684 + "at://alice/app.bsky.feed.post/3", 1685 + "at://alice/app.bsky.feed.post/2", 1686 + "at://alice/app.bsky.feed.post/1", 1687 + ] 1688 + ); 1689 + assert_eq!(page.total, 3); 1690 + assert!(page.next_offset.is_none()); 1691 + } 1692 + 1693 + #[test] 1694 + fn list_saved_posts_returns_next_offset_when_more_results_exist() { 1695 + let conn = test_db(); 1696 + insert_post( 1697 + &conn, 1698 + "did:plc:alice", 1699 + "at://alice/app.bsky.feed.post/1", 1700 + "like", 1701 + "first", 1702 + "2024-01-01T00:00:00Z", 1703 + ); 1704 + insert_post( 1705 + &conn, 1706 + "did:plc:alice", 1707 + "at://alice/app.bsky.feed.post/2", 1708 + "like", 1709 + "second", 1710 + "2024-01-02T00:00:00Z", 1711 + ); 1712 + 1713 + let page = db_list_saved_posts(&conn, "did:plc:alice", "like", 1, 0, None).unwrap(); 1714 + 1715 + assert_eq!(page.posts.len(), 1); 1716 + assert_eq!(page.total, 2); 1717 + assert_eq!(page.next_offset, Some(1)); 1718 + } 1719 + 1720 + #[test] 1721 + fn list_saved_posts_can_filter_with_query() { 1722 + let conn = test_db(); 1723 + insert_post( 1724 + &conn, 1725 + "did:plc:alice", 1726 + "at://alice/app.bsky.feed.post/1", 1727 + "bookmark", 1728 + "rust sqlite", 1729 + "2024-01-01T00:00:00Z", 1730 + ); 1731 + insert_post( 1732 + &conn, 1733 + "did:plc:alice", 1734 + "at://alice/app.bsky.feed.post/2", 1735 + "bookmark", 1736 + "garden notes", 1737 + "2024-01-02T00:00:00Z", 1738 + ); 1739 + 1740 + let page = db_list_saved_posts(&conn, "did:plc:alice", "bookmark", 10, 0, Some("rust")).unwrap(); 1741 + 1742 + assert_eq!(page.total, 1); 1743 + assert_eq!(page.posts.len(), 1); 1744 + assert_eq!(page.posts[0].uri, "at://alice/app.bsky.feed.post/1"); 1533 1745 } 1534 1746 1535 1747 #[test]
+1
src-tauri/vendor/jacquard-oauth/src/lib.rs
··· 46 46 //! 47 47 //! See [`atproto`] module for AT Protocol-specific metadata helpers. 48 48 49 + #![allow(warnings)] 49 50 #![warn(missing_docs)] 50 51 /// AT Protocol-specific OAuth client metadata helpers and builder types. 51 52 pub mod atproto;
+4 -9
src/components/account/AccountSwitcher.tsx
··· 4 4 import { createMemo, onCleanup, onMount, Show } from "solid-js"; 5 5 import { SwitcherIdentity } from "./AccountSwitcherIdentity"; 6 6 import { AccountSwitcherMenuList } from "./AccountSwitcherMenuList"; 7 + import type { AccountIdentity } from "./types"; 7 8 8 9 export function AccountSwitcher() { 9 10 const session = useAppSession(); 10 11 const shell = useAppShellUi(); 11 12 const previewAccount = createMemo(() => session.activeAccount ?? session.accounts[0] ?? null); 12 - const identity = createMemo(() => { 13 + const identity = createMemo<AccountIdentity>(() => { 13 14 if (session.activeSession) { 14 15 return { 15 16 avatar: session.activeAvatar, ··· 68 69 69 70 return ( 70 71 <div 71 - class="relative mt-auto w-full transition-[width,max-width] duration-300 ease-out max-[1180px]:mt-0 max-[1180px]:max-w-none" 72 + class="relative w-full transition-[width,max-width] duration-300 ease-out max-[1180px]:max-w-none" 72 73 classList={{ 73 74 "z-40": shell.showSwitcher, 74 75 "w-auto": compact(), ··· 89 90 aria-expanded={shell.showSwitcher} 90 91 aria-label={session.activeSession ? `Current account ${session.activeSession.handle}` : identity().name} 91 92 onClick={shell.toggleSwitcher}> 92 - <SwitcherIdentity 93 - avatar={identity().avatar} 94 - compact={compact()} 95 - label={identity().label} 96 - meta={identity().meta} 97 - name={identity().name} 98 - tone={identity().tone} /> 93 + <SwitcherIdentity identity={identity()} compact={compact()} /> 99 94 <span 100 95 class="absolute flex items-center justify-center text-on-surface-variant" 101 96 classList={{
+11 -13
src/components/account/AccountSwitcherIdentity.tsx
··· 1 1 import { AvatarBadge } from "$/components/AvatarBadge"; 2 2 import { Show } from "solid-js"; 3 + import type { AccountIdentity } from "./types"; 3 4 4 - export function SwitcherIdentity( 5 - props: { 6 - avatar?: string | null; 7 - compact?: boolean; 8 - label: string; 9 - name: string; 10 - meta: string; 11 - tone: "primary" | "muted"; 12 - }, 13 - ) { 5 + export function SwitcherIdentity(props: { compact: boolean; identity: AccountIdentity }) { 6 + const label = () => props.identity.label; 7 + const avatar = () => props.identity.avatar; 8 + const name = () => props.identity.name; 9 + const meta = () => props.identity.meta; 10 + const tone = () => props.identity.tone; 11 + 14 12 return ( 15 13 <div class="flex min-w-0 items-center gap-3" classList={{ "justify-center": !!props.compact }}> 16 - <AvatarBadge label={props.label} src={props.avatar} tone={props.tone} /> 14 + <AvatarBadge label={label()} src={avatar()} tone={tone()} /> 17 15 <Show when={!props.compact}> 18 16 <div class="grid min-w-0"> 19 - <span class="truncate text-[0.92rem] font-semibold">{props.name}</span> 20 - <span class="text-xs text-on-surface-variant">{props.meta}</span> 17 + <span class="truncate text-[0.92rem] font-semibold">{name()}</span> 18 + <span class="text-xs text-on-surface-variant">{meta()}</span> 21 19 </div> 22 20 </Show> 23 21 </div>
+7
src/components/account/types.ts
··· 1 + export type AccountIdentity = { 2 + avatar: string | null; 3 + label: string; 4 + meta: string; 5 + name: string; 6 + tone: "primary" | "muted"; 7 + };
+49
src/components/rail/AppRail.test.tsx
··· 1 + import { AppTestProviders } from "$/test/providers"; 2 + import { HashRouter, Route } from "@solidjs/router"; 3 + import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; 4 + import { beforeEach, describe, expect, it, vi } from "vitest"; 5 + import { AppRail } from "./AppRail"; 6 + 7 + const openUrlMock = vi.hoisted(() => vi.fn()); 8 + 9 + vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: openUrlMock })); 10 + 11 + function renderRail() { 12 + globalThis.location.hash = "#/timeline"; 13 + 14 + return render(() => ( 15 + <AppTestProviders 16 + session={{ 17 + activeDid: "did:plc:alice", 18 + activeHandle: "alice.test", 19 + activeSession: { did: "did:plc:alice", handle: "alice.test" }, 20 + hasSession: true, 21 + }}> 22 + <HashRouter> 23 + <Route path="*" component={AppRail} /> 24 + </HashRouter> 25 + </AppTestProviders> 26 + )); 27 + } 28 + 29 + describe("AppRail", () => { 30 + beforeEach(() => { 31 + openUrlMock.mockReset(); 32 + }); 33 + 34 + it("renders the saved navigation link", async () => { 35 + renderRail(); 36 + 37 + const link = await screen.findByRole("link", { name: "Saved" }); 38 + expect(link).toHaveAttribute("href", "#/saved"); 39 + }); 40 + 41 + it("opens the support URL with the opener plugin", async () => { 42 + renderRail(); 43 + 44 + fireEvent.click(await screen.findByRole("button", { name: "Support" })); 45 + 46 + await waitFor(() => expect(openUrlMock).toHaveBeenCalledWith("https://github.com/sponsors/desertthunder")); 47 + expect(screen.queryByRole("link", { name: "Support" })).not.toBeInTheDocument(); 48 + }); 49 + });
+19 -2
src/components/rail/AppRail.tsx
··· 1 1 import { useAppSession } from "$/contexts/app-session"; 2 2 import { useAppShellUi } from "$/contexts/app-shell-ui"; 3 + import { openUrl } from "@tauri-apps/plugin-opener"; 3 4 import { Show } from "solid-js"; 4 5 import { AccountSwitcher } from "../account/AccountSwitcher"; 5 6 import { ArrowIcon } from "../shared/Icon"; 6 7 import { Wordmark } from "../Wordmark"; 7 - import { RailButton } from "./AppRailButton"; 8 + import { RailActionButton, RailButton } from "./AppRailButton"; 8 9 9 10 function RailHeader(props: { collapsed: boolean; onToggleCollapse: () => void }) { 10 11 return ( ··· 35 36 <RailButton end compact={props.collapsed} href="/timeline" label="Timeline" icon="timeline" /> 36 37 <RailButton compact={props.collapsed} href="/profile" label="Profile" icon="profile" /> 37 38 <RailButton end compact={props.collapsed} href="/search" label="Search" icon="search" /> 39 + <RailButton end compact={props.collapsed} href="/saved" label="Saved" icon="bookmark" /> 38 40 <RailButton 39 41 end 40 42 badge={props.unreadNotifications} ··· 51 53 ); 52 54 } 53 55 56 + function RailSecondaryActions(props: { collapsed: boolean }) { 57 + return ( 58 + <div class="grid gap-1 max-[1180px]:col-span-full max-[1180px]:grid-flow-col max-[1180px]:justify-start"> 59 + <RailActionButton 60 + compact={props.collapsed} 61 + icon="heart" 62 + label="Support" 63 + onClick={() => void openUrl("https://github.com/sponsors/desertthunder")} /> 64 + </div> 65 + ); 66 + } 67 + 54 68 export function AppRail() { 55 69 const session = useAppSession(); 56 70 const shell = useAppShellUi(); ··· 68 82 collapsed={shell.railCondensed} 69 83 hasSession={session.hasSession} 70 84 unreadNotifications={session.unreadNotifications} /> 71 - <AccountSwitcher /> 85 + <div class="mt-auto grid gap-3 max-[1180px]:contents"> 86 + <RailSecondaryActions collapsed={shell.railCondensed} /> 87 + <AccountSwitcher /> 88 + </div> 72 89 </aside> 73 90 ); 74 91 }
+40 -18
src/components/rail/AppRailButton.tsx
··· 3 3 import { Motion, Presence } from "solid-motionone"; 4 4 import { Icon, type IconKind } from "../shared/Icon"; 5 5 6 - type RailButtonProps = { 7 - badge?: number; 8 - compact?: boolean; 9 - end?: boolean; 10 - href: string; 11 - icon: IconKind; 12 - label: string; 13 - }; 6 + type RailButtonVisualProps = { badge?: number; compact?: boolean; icon: IconKind; label: string }; 7 + 8 + type RailButtonProps = RailButtonVisualProps & { end?: boolean; href: string }; 9 + 10 + type RailActionButtonProps = RailButtonVisualProps & { onClick: () => void }; 14 11 15 - export function RailButton(props: RailButtonProps) { 12 + function RailButtonContent(props: RailButtonVisualProps) { 16 13 return ( 17 - <A 18 - href={props.href} 19 - end={props.end} 20 - class="relative flex h-11 shrink-0 items-center gap-2.5 rounded-lg border-0 bg-transparent text-on-surface-variant no-underline transition duration-150 ease-out hover:-translate-y-px hover:bg-surface-bright hover:text-on-surface" 21 - activeClass="bg-surface-container text-primary" 22 - inactiveClass="" 23 - classList={{ "w-[2.75rem] justify-center": !!props.compact, "px-3": !props.compact }} 24 - aria-label={props.label} 25 - title={props.label}> 14 + <> 26 15 <div class="relative"> 27 16 <Icon kind={props.icon} name={props.label} aria-hidden="true" class="shrink-0 text-[1.25rem]" /> 28 17 <Presence> ··· 41 30 <Show when={!props.compact}> 42 31 <span class="text-sm font-medium leading-none">{props.label}</span> 43 32 </Show> 33 + </> 34 + ); 35 + } 36 + 37 + const railButtonClass = 38 + "relative flex h-11 shrink-0 items-center gap-2.5 rounded-lg border-0 bg-transparent text-on-surface-variant no-underline transition duration-150 ease-out hover:-translate-y-px hover:bg-surface-bright hover:text-on-surface"; 39 + 40 + export function RailButton(props: RailButtonProps) { 41 + return ( 42 + <A 43 + href={props.href} 44 + end={props.end} 45 + class={railButtonClass} 46 + activeClass="bg-surface-container text-primary" 47 + inactiveClass="" 48 + classList={{ "w-[2.75rem] justify-center": !!props.compact, "px-3": !props.compact }} 49 + aria-label={props.label} 50 + title={props.label}> 51 + <RailButtonContent {...props} /> 44 52 </A> 45 53 ); 46 54 } 55 + 56 + export function RailActionButton(props: RailActionButtonProps) { 57 + return ( 58 + <button 59 + type="button" 60 + class={railButtonClass} 61 + classList={{ "w-[2.75rem] justify-center": !!props.compact, "px-3": !props.compact }} 62 + aria-label={props.label} 63 + title={props.label} 64 + onClick={() => props.onClick()}> 65 + <RailButtonContent {...props} /> 66 + </button> 67 + ); 68 + }
+163
src/components/saved/SavedPostsPanel.test.tsx
··· 1 + import { AppTestProviders } from "$/test/providers"; 2 + import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; 3 + import { beforeEach, describe, expect, it, vi } from "vitest"; 4 + import { SavedPostsPanel } from "./SavedPostsPanel"; 5 + 6 + const getSyncStatusMock = vi.hoisted(() => vi.fn()); 7 + const listSavedPostsMock = vi.hoisted(() => vi.fn()); 8 + const syncPostsMock = vi.hoisted(() => vi.fn()); 9 + const loggerErrorMock = vi.hoisted(() => vi.fn()); 10 + 11 + vi.mock( 12 + "$/lib/api/search", 13 + () => ({ getSyncStatus: getSyncStatusMock, listSavedPosts: listSavedPostsMock, syncPosts: syncPostsMock }), 14 + ); 15 + vi.mock("@tauri-apps/plugin-log", () => ({ error: loggerErrorMock })); 16 + 17 + function createStatus(source: "bookmark" | "like", count: number) { 18 + return { cursor: null, did: "did:plc:alice", lastSyncedAt: "2026-04-03T12:00:00.000Z", postCount: count, source }; 19 + } 20 + 21 + function createPost(source: "bookmark" | "like", id: string, text = `${source} post ${id}`) { 22 + return { 23 + authorDid: `did:plc:author:${id}`, 24 + authorHandle: `author-${id}.test`, 25 + cid: `cid-${id}`, 26 + createdAt: "2026-04-03T11:00:00.000Z", 27 + keywordMatch: false, 28 + score: 0, 29 + semanticMatch: false, 30 + source, 31 + text, 32 + uri: `at://did:plc:author:${id}/app.bsky.feed.post/${id}`, 33 + }; 34 + } 35 + 36 + function renderPanel() { 37 + return render(() => ( 38 + <AppTestProviders 39 + session={{ 40 + activeDid: "did:plc:alice", 41 + activeHandle: "alice.test", 42 + activeSession: { did: "did:plc:alice", handle: "alice.test" }, 43 + }}> 44 + <SavedPostsPanel /> 45 + </AppTestProviders> 46 + )); 47 + } 48 + 49 + describe("SavedPostsPanel", () => { 50 + beforeEach(() => { 51 + vi.useFakeTimers(); 52 + vi.setSystemTime(new Date("2026-04-03T12:30:00.000Z")); 53 + getSyncStatusMock.mockReset(); 54 + listSavedPostsMock.mockReset(); 55 + syncPostsMock.mockReset(); 56 + loggerErrorMock.mockReset(); 57 + getSyncStatusMock.mockResolvedValue([createStatus("bookmark", 2), createStatus("like", 1)]); 58 + syncPostsMock.mockResolvedValue({}); 59 + }); 60 + 61 + it("defaults to Saved, loads counts, and fetches Liked on first tab switch", async () => { 62 + listSavedPostsMock.mockResolvedValueOnce({ nextOffset: null, posts: [createPost("bookmark", "1")], total: 2 }) 63 + .mockResolvedValueOnce({ nextOffset: null, posts: [createPost("like", "2")], total: 1 }); 64 + 65 + renderPanel(); 66 + 67 + expect(await screen.findByText("bookmark post 1")).toBeInTheDocument(); 68 + expect(screen.getByRole("button", { name: /saved/i })).toHaveAttribute("aria-pressed", "true"); 69 + expect(screen.getByText("3")).toBeInTheDocument(); 70 + expect(listSavedPostsMock).toHaveBeenCalledWith("bookmark", 50, 0); 71 + 72 + fireEvent.click(screen.getByRole("button", { name: /liked/i })); 73 + 74 + expect(await screen.findByText("like post 2")).toBeInTheDocument(); 75 + expect(listSavedPostsMock).toHaveBeenNthCalledWith(2, "like", 50, 0); 76 + }); 77 + 78 + it("refreshes bookmarks then likes before reloading sync status and the active tab", async () => { 79 + listSavedPostsMock.mockResolvedValueOnce({ 80 + nextOffset: null, 81 + posts: [createPost("bookmark", "1", "old bookmark")], 82 + total: 1, 83 + }).mockResolvedValueOnce({ nextOffset: null, posts: [createPost("bookmark", "2", "fresh bookmark")], total: 1 }); 84 + 85 + renderPanel(); 86 + 87 + expect(await screen.findByText("old bookmark")).toBeInTheDocument(); 88 + 89 + fireEvent.click(screen.getByRole("button", { name: /refresh/i })); 90 + 91 + await waitFor(() => { 92 + expect(syncPostsMock).toHaveBeenNthCalledWith(1, "did:plc:alice", "bookmark"); 93 + expect(syncPostsMock).toHaveBeenNthCalledWith(2, "did:plc:alice", "like"); 94 + }); 95 + expect(await screen.findByText("fresh bookmark")).toBeInTheDocument(); 96 + expect(getSyncStatusMock).toHaveBeenCalledTimes(2); 97 + expect(listSavedPostsMock).toHaveBeenNthCalledWith(2, "bookmark", 50, 0); 98 + }); 99 + 100 + it("loads more posts for the active tab", async () => { 101 + listSavedPostsMock.mockResolvedValueOnce({ nextOffset: 50, posts: [createPost("bookmark", "1")], total: 2 }) 102 + .mockResolvedValueOnce({ nextOffset: null, posts: [createPost("bookmark", "2")], total: 2 }); 103 + 104 + renderPanel(); 105 + 106 + expect(await screen.findByText("bookmark post 1")).toBeInTheDocument(); 107 + 108 + fireEvent.click(screen.getByRole("button", { name: /load more/i })); 109 + 110 + expect(await screen.findByText("bookmark post 2")).toBeInTheDocument(); 111 + expect(listSavedPostsMock).toHaveBeenNthCalledWith(2, "bookmark", 50, 50); 112 + }); 113 + 114 + it("renders tab-specific empty states", async () => { 115 + listSavedPostsMock.mockResolvedValueOnce({ nextOffset: null, posts: [], total: 0 }).mockResolvedValueOnce({ 116 + nextOffset: null, 117 + posts: [], 118 + total: 0, 119 + }); 120 + 121 + renderPanel(); 122 + 123 + expect(await screen.findByText("No bookmarked posts synced yet.")).toBeInTheDocument(); 124 + 125 + fireEvent.click(screen.getByRole("button", { name: /liked/i })); 126 + 127 + expect(await screen.findByText("No liked posts synced yet.")).toBeInTheDocument(); 128 + }); 129 + 130 + it("renders a human-readable error state when loading fails", async () => { 131 + listSavedPostsMock.mockRejectedValue(new Error("saved posts unavailable")); 132 + 133 + renderPanel(); 134 + 135 + expect(await screen.findByText("saved posts unavailable")).toBeInTheDocument(); 136 + expect(loggerErrorMock).toHaveBeenCalled(); 137 + }); 138 + 139 + it("searches within the active tab and preserves tab-specific queries", async () => { 140 + listSavedPostsMock.mockResolvedValueOnce({ nextOffset: null, posts: [createPost("bookmark", "1")], total: 1 }) 141 + .mockResolvedValueOnce({ nextOffset: null, posts: [createPost("bookmark", "2", "rust archive")], total: 1 }) 142 + .mockResolvedValueOnce({ nextOffset: null, posts: [createPost("like", "3", "rust like")], total: 1 }); 143 + 144 + renderPanel(); 145 + 146 + expect(await screen.findByText("bookmark post 1")).toBeInTheDocument(); 147 + 148 + fireEvent.input(screen.getByRole("textbox"), { target: { value: "rust" } }); 149 + await vi.advanceTimersByTimeAsync(350); 150 + await Promise.resolve(); 151 + await Promise.resolve(); 152 + 153 + expect(listSavedPostsMock).toHaveBeenNthCalledWith(2, "bookmark", 50, 0, "rust"); 154 + expect(screen.getByText((_, element) => element?.textContent === "rust archive")).toBeInTheDocument(); 155 + 156 + fireEvent.click(screen.getByRole("button", { name: /liked/i })); 157 + await Promise.resolve(); 158 + await Promise.resolve(); 159 + 160 + expect(listSavedPostsMock).toHaveBeenNthCalledWith(3, "like", 50, 0, "rust"); 161 + expect(screen.getByText((_, element) => element?.textContent === "rust like")).toBeInTheDocument(); 162 + }, 5000); 163 + });
+585
src/components/saved/SavedPostsPanel.tsx
··· 1 + import { LocalPostResultsList, LocalPostResultsSkeletons } from "$/components/search/LocalPostResultsList"; 2 + import { SearchEmptyState } from "$/components/search/SearchEmptyState"; 3 + import { SearchQueryInput } from "$/components/search/SearchQueryInput"; 4 + import { Icon } from "$/components/shared/Icon"; 5 + import { PostCount } from "$/components/shared/PostCount"; 6 + import { useAppSession } from "$/contexts/app-session"; 7 + import { getSyncStatus, listSavedPosts, syncPosts } from "$/lib/api/search"; 8 + import type { LocalPostResult, SavedPostSource, SyncStatus } from "$/lib/api/search"; 9 + import { formatRelativeTime } from "$/lib/feeds"; 10 + import { normalizeError } from "$/lib/utils/text"; 11 + import * as logger from "@tauri-apps/plugin-log"; 12 + import { createEffect, createMemo, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"; 13 + import { createStore } from "solid-js/store"; 14 + import { Motion, Presence } from "solid-motionone"; 15 + 16 + const PAGE_SIZE = 50; 17 + const SEARCH_DEBOUNCE_MS = 300; 18 + 19 + type TabKey = SavedPostSource; 20 + type TabState = { 21 + error: string | null; 22 + items: LocalPostResult[]; 23 + loaded: boolean; 24 + loading: boolean; 25 + loadingMore: boolean; 26 + nextOffset: number | null; 27 + total: number; 28 + }; 29 + 30 + type SearchTabState = { 31 + error: string | null; 32 + items: LocalPostResult[]; 33 + loadedQuery: string | null; 34 + loading: boolean; 35 + loadingMore: boolean; 36 + nextOffset: number | null; 37 + total: number; 38 + }; 39 + 40 + type SavedPanelState = { 41 + query: string; 42 + refreshing: boolean; 43 + searchTabs: Record<TabKey, SearchTabState>; 44 + syncStatus: SyncStatus[]; 45 + syncStatusLoading: boolean; 46 + tabs: Record<TabKey, TabState>; 47 + }; 48 + 49 + const TAB_ITEMS: Array<{ key: TabKey; label: string }> = [{ key: "bookmark", label: "Saved" }, { 50 + key: "like", 51 + label: "Liked", 52 + }]; 53 + 54 + function createTabState(): TabState { 55 + return { error: null, items: [], loaded: false, loading: false, loadingMore: false, nextOffset: null, total: 0 }; 56 + } 57 + 58 + function createSearchTabState(): SearchTabState { 59 + return { error: null, items: [], loadedQuery: null, loading: false, loadingMore: false, nextOffset: null, total: 0 }; 60 + } 61 + 62 + function createPanelState(): SavedPanelState { 63 + return { 64 + query: "", 65 + refreshing: false, 66 + searchTabs: { bookmark: createSearchTabState(), like: createSearchTabState() }, 67 + syncStatus: [], 68 + syncStatusLoading: false, 69 + tabs: { bookmark: createTabState(), like: createTabState() }, 70 + }; 71 + } 72 + 73 + function LoadMoreButton(props: { next: number | null; onLoadMore: () => void; loadingMore: boolean }) { 74 + return ( 75 + <Show when={props.next}> 76 + <div class="flex justify-center pt-2"> 77 + <button 78 + type="button" 79 + class="inline-flex items-center gap-2 rounded-full border-0 bg-surface px-4 py-2.5 text-sm font-medium text-on-surface-variant transition duration-150 hover:-translate-y-px hover:text-on-surface disabled:cursor-not-allowed disabled:opacity-60" 80 + disabled={props.loadingMore} 81 + onClick={() => props.onLoadMore()}> 82 + <Show 83 + when={props.loadingMore} 84 + fallback={ 85 + <> 86 + <Icon kind="bookmark" aria-hidden="true" /> 87 + Load More 88 + </> 89 + }> 90 + <Icon iconClass="i-ri-loader-4-line animate-spin" aria-hidden="true" /> 91 + Loading more... 92 + </Show> 93 + </button> 94 + </div> 95 + </Show> 96 + ); 97 + } 98 + 99 + function SavedPostsMessage(props: { body: string; title: string }) { 100 + return ( 101 + <Motion.div 102 + class="grid place-items-center px-6 py-16" 103 + initial={{ opacity: 0 }} 104 + animate={{ opacity: 1 }} 105 + exit={{ opacity: 0 }} 106 + transition={{ duration: 0.15 }}> 107 + <div class="grid max-w-md gap-3 text-center"> 108 + <p class="m-0 text-base font-medium text-on-surface">{props.title}</p> 109 + <p class="m-0 text-sm text-on-surface-variant">{props.body}</p> 110 + </div> 111 + </Motion.div> 112 + ); 113 + } 114 + 115 + export function SavedPostsPanel() { 116 + const session = useAppSession(); 117 + const [activeTab, setActiveTab] = createSignal<TabKey>("bookmark"); 118 + const [state, setState] = createStore<SavedPanelState>(createPanelState()); 119 + const browseRequestIds: Record<TabKey, number> = { bookmark: 0, like: 0 }; 120 + const searchRequestIds: Record<TabKey, number> = { bookmark: 0, like: 0 }; 121 + const trimmedQuery = createMemo(() => state.query.trim()); 122 + const isSearching = createMemo(() => trimmedQuery().length > 0); 123 + const activeTabState = createMemo(() => state.tabs[activeTab()]); 124 + const activeSearchState = createMemo(() => state.searchTabs[activeTab()]); 125 + const statusBySource = createMemo(() => 126 + Object.fromEntries(state.syncStatus.map((status) => [status.source, status])) as Partial<Record<TabKey, SyncStatus>> 127 + ); 128 + const totalIndexedPosts = createMemo(() => 129 + state.syncStatus.reduce((sum, status) => sum + (status.postCount ?? 0), 0) 130 + ); 131 + const lastSync = createMemo(() => { 132 + const timestamps = state.syncStatus.map((status) => status.lastSyncedAt).filter(Boolean) as string[]; 133 + if (timestamps.length === 0) { 134 + return null; 135 + } 136 + 137 + return formatRelativeTime(timestamps.toSorted((left, right) => right.localeCompare(left))[0]); 138 + }); 139 + const activeResultCount = createMemo(() => isSearching() ? activeSearchState().total : activeTabState().total); 140 + 141 + let activeDid: string | null = null; 142 + let debounceTimer: ReturnType<typeof setTimeout> | undefined; 143 + let searchInputRef: HTMLInputElement | undefined; 144 + 145 + createEffect(() => { 146 + void refreshForDid(session.activeDid); 147 + }); 148 + 149 + onCleanup(() => clearTimeout(debounceTimer)); 150 + 151 + async function refreshForDid(did: string | null) { 152 + if (did === activeDid) { 153 + return; 154 + } 155 + 156 + activeDid = did; 157 + setActiveTab("bookmark"); 158 + setState(createPanelState()); 159 + 160 + if (!did) { 161 + return; 162 + } 163 + 164 + await Promise.all([loadSyncStatus(did), ensureActiveViewLoaded("bookmark", did)]); 165 + } 166 + 167 + async function loadSyncStatus(did = session.activeDid) { 168 + if (!did) { 169 + setState("syncStatus", []); 170 + return; 171 + } 172 + 173 + setState("syncStatusLoading", true); 174 + 175 + try { 176 + const status = await getSyncStatus(did); 177 + if (did !== activeDid) { 178 + return; 179 + } 180 + 181 + setState("syncStatus", status); 182 + } catch (error) { 183 + logger.error("failed to load saved-post sync status", { keyValues: { did, error: normalizeError(error) } }); 184 + } finally { 185 + if (did === activeDid) { 186 + setState("syncStatusLoading", false); 187 + } 188 + } 189 + } 190 + 191 + async function ensureActiveViewLoaded(source: TabKey, did = session.activeDid) { 192 + if (isSearching()) { 193 + await ensureSearchLoaded(source, trimmedQuery(), did); 194 + return; 195 + } 196 + 197 + await ensureBrowseLoaded(source, did); 198 + } 199 + 200 + async function ensureBrowseLoaded(source: TabKey, did = session.activeDid) { 201 + if (!did || state.tabs[source].loaded || state.tabs[source].loading) { 202 + return; 203 + } 204 + 205 + await loadBrowseTab(source, { did }); 206 + } 207 + 208 + async function ensureSearchLoaded(source: TabKey, query: string, did = session.activeDid) { 209 + if (!did || !query) { 210 + return; 211 + } 212 + 213 + const current = state.searchTabs[source]; 214 + if (current.loading || current.loadedQuery === query) { 215 + return; 216 + } 217 + 218 + await loadSearchTab(source, { did, query }); 219 + } 220 + 221 + async function loadBrowseTab(source: TabKey, options: { append?: boolean; did?: string | null } = {}) { 222 + const did = options.did ?? session.activeDid; 223 + if (!did) { 224 + return; 225 + } 226 + 227 + const current = state.tabs[source]; 228 + const offset = options.append ? current.nextOffset ?? 0 : 0; 229 + if (options.append && current.nextOffset === null) { 230 + return; 231 + } 232 + 233 + const requestId = ++browseRequestIds[source]; 234 + setState("tabs", source, options.append ? "loadingMore" : "loading", true); 235 + setState("tabs", source, "error", null); 236 + 237 + try { 238 + const page = await listSavedPosts(source, PAGE_SIZE, offset); 239 + if (did !== activeDid || requestId !== browseRequestIds[source]) { 240 + return; 241 + } 242 + 243 + setState("tabs", source, "items", options.append ? [...current.items, ...page.posts] : page.posts); 244 + setState("tabs", source, "total", page.total); 245 + setState("tabs", source, "nextOffset", page.nextOffset ?? null); 246 + setState("tabs", source, "loaded", true); 247 + } catch (error) { 248 + const message = normalizeError(error); 249 + if (did !== activeDid || requestId !== browseRequestIds[source]) { 250 + return; 251 + } 252 + 253 + setState("tabs", source, "error", message); 254 + logger.error("failed to load saved posts", { keyValues: { did, source, error: message } }); 255 + } finally { 256 + if (did === activeDid && requestId === browseRequestIds[source]) { 257 + setState("tabs", source, "loading", false); 258 + setState("tabs", source, "loadingMore", false); 259 + } 260 + } 261 + } 262 + 263 + async function loadSearchTab(source: TabKey, options: { append?: boolean; did?: string | null; query: string }) { 264 + const did = options.did ?? session.activeDid; 265 + const query = options.query.trim(); 266 + if (!did || !query) { 267 + return; 268 + } 269 + 270 + const current = state.searchTabs[source]; 271 + const offset = options.append ? current.nextOffset ?? 0 : 0; 272 + if (options.append && current.nextOffset === null) { 273 + return; 274 + } 275 + 276 + const requestId = ++searchRequestIds[source]; 277 + setState("searchTabs", source, options.append ? "loadingMore" : "loading", true); 278 + setState("searchTabs", source, "error", null); 279 + 280 + try { 281 + const page = await listSavedPosts(source, PAGE_SIZE, offset, query); 282 + if (did !== activeDid || requestId !== searchRequestIds[source] || trimmedQuery() !== query) { 283 + return; 284 + } 285 + 286 + setState("searchTabs", source, "items", options.append ? [...current.items, ...page.posts] : page.posts); 287 + setState("searchTabs", source, "total", page.total); 288 + setState("searchTabs", source, "nextOffset", page.nextOffset ?? null); 289 + setState("searchTabs", source, "loadedQuery", query); 290 + } catch (error) { 291 + const message = normalizeError(error); 292 + if (did !== activeDid || requestId !== searchRequestIds[source] || trimmedQuery() !== query) { 293 + return; 294 + } 295 + 296 + setState("searchTabs", source, "error", message); 297 + logger.error("failed to search saved posts", { keyValues: { did, source, query, error: message } }); 298 + } finally { 299 + if (did === activeDid && requestId === searchRequestIds[source] && trimmedQuery() === query) { 300 + setState("searchTabs", source, "loading", false); 301 + setState("searchTabs", source, "loadingMore", false); 302 + } 303 + } 304 + } 305 + 306 + function clearSearch() { 307 + clearTimeout(debounceTimer); 308 + setState("query", ""); 309 + void ensureBrowseLoaded(activeTab()); 310 + searchInputRef?.focus(); 311 + } 312 + 313 + function handleSearchInput(value: string) { 314 + setState("query", value); 315 + clearTimeout(debounceTimer); 316 + 317 + const nextQuery = value.trim(); 318 + if (!nextQuery) { 319 + void ensureBrowseLoaded(activeTab()); 320 + return; 321 + } 322 + 323 + debounceTimer = setTimeout(() => { 324 + void loadSearchTab(activeTab(), { query: nextQuery }); 325 + }, SEARCH_DEBOUNCE_MS); 326 + } 327 + 328 + function handleSearchKeyDown(event: KeyboardEvent) { 329 + if (event.key === "Escape" && state.query) { 330 + clearSearch(); 331 + } 332 + } 333 + 334 + async function handleSelectTab(source: TabKey) { 335 + setActiveTab(source); 336 + await ensureActiveViewLoaded(source); 337 + } 338 + 339 + async function handleRefresh() { 340 + if (!session.activeDid || state.refreshing) { 341 + return; 342 + } 343 + 344 + setState("refreshing", true); 345 + 346 + try { 347 + await syncPosts(session.activeDid, "bookmark"); 348 + await syncPosts(session.activeDid, "like"); 349 + await Promise.all([ 350 + loadSyncStatus(session.activeDid), 351 + isSearching() 352 + ? loadSearchTab(activeTab(), { did: session.activeDid, query: trimmedQuery() }) 353 + : loadBrowseTab(activeTab(), { did: session.activeDid }), 354 + ]); 355 + } catch (error) { 356 + logger.error("failed to refresh saved posts", { 357 + keyValues: { did: session.activeDid, error: normalizeError(error) }, 358 + }); 359 + } finally { 360 + setState("refreshing", false); 361 + } 362 + } 363 + 364 + return ( 365 + <article 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)]"> 366 + <SavedPostsHeader 367 + activeResultCount={activeResultCount()} 368 + activeTab={activeTab()} 369 + counts={{ bookmark: statusBySource().bookmark?.postCount ?? 0, like: statusBySource().like?.postCount ?? 0 }} 370 + loading={state.refreshing} 371 + onQueryChange={handleSearchInput} 372 + onRefresh={() => void handleRefresh()} 373 + onSearchClear={clearSearch} 374 + onSearchKeyDown={handleSearchKeyDown} 375 + onSelectTab={(tab) => void handleSelectTab(tab)} 376 + query={state.query} 377 + queryRef={(element) => { 378 + searchInputRef = element; 379 + }} 380 + searchLoading={activeSearchState().loading} 381 + searching={isSearching()} 382 + syncLoading={state.syncStatusLoading} 383 + totalIndexedPosts={totalIndexedPosts()} 384 + lastSync={lastSync()} /> 385 + <SavedPostsViewport 386 + activeTab={activeTab()} 387 + browsingState={activeTabState()} 388 + query={trimmedQuery()} 389 + searching={isSearching()} 390 + searchingState={activeSearchState()} 391 + onLoadMore={() => void (isSearching() 392 + ? loadSearchTab(activeTab(), { append: true, query: trimmedQuery() }) 393 + : loadBrowseTab(activeTab(), { append: true }))} /> 394 + </article> 395 + ); 396 + } 397 + 398 + function SavedPostsHeader( 399 + props: { 400 + activeResultCount: number; 401 + activeTab: TabKey; 402 + counts: Record<TabKey, number>; 403 + lastSync: string | null; 404 + loading: boolean; 405 + onQueryChange: (value: string) => void; 406 + onRefresh: () => void; 407 + onSearchClear: () => void; 408 + onSearchKeyDown: (event: KeyboardEvent) => void; 409 + onSelectTab: (tab: TabKey) => void; 410 + query: string; 411 + queryRef: (element: HTMLInputElement) => void; 412 + searchLoading: boolean; 413 + searching: boolean; 414 + syncLoading: boolean; 415 + totalIndexedPosts: number; 416 + }, 417 + ) { 418 + return ( 419 + <header class="grid gap-5 px-6 pb-4 pt-6"> 420 + <div class="flex flex-wrap items-center justify-between gap-4"> 421 + <div class="grid gap-1"> 422 + <p class="overline-copy text-xs text-on-surface-variant">Library</p> 423 + <h1 class="m-0 text-xl font-semibold tracking-tight text-on-surface">Saved posts</h1> 424 + <Show 425 + when={props.syncLoading} 426 + fallback={<PostCount totalPosts={props.totalIndexedPosts} lastSync={props.lastSync} inline />}> 427 + <p class="m-0 text-xs text-on-surface-variant">Loading sync status...</p> 428 + </Show> 429 + </div> 430 + 431 + <button 432 + type="button" 433 + class="inline-flex h-10 items-center gap-2 rounded-full border-0 bg-surface-container-high px-4 text-sm font-medium text-on-surface-variant transition duration-150 hover:-translate-y-px hover:text-on-surface disabled:cursor-not-allowed disabled:opacity-60" 434 + disabled={props.loading} 435 + onClick={() => props.onRefresh()}> 436 + <Show when={props.loading} fallback={<Icon kind="refresh" aria-hidden="true" />}> 437 + <Icon iconClass="i-ri-loader-4-line animate-spin" aria-hidden="true" /> 438 + </Show> 439 + <Show when={props.loading} fallback="Refresh">Refreshing...</Show> 440 + </button> 441 + </div> 442 + 443 + <SearchQueryInput 444 + error={null} 445 + inputRef={props.queryRef} 446 + loading={props.searchLoading} 447 + placeholder={props.activeTab === "bookmark" ? "Search saved posts..." : "Search liked posts..."} 448 + query={props.query} 449 + onClear={props.onSearchClear} 450 + onKeyDown={props.onSearchKeyDown} 451 + onQueryChange={props.onQueryChange} /> 452 + 453 + <div class="flex items-center justify-between gap-4"> 454 + <nav class="flex flex-wrap gap-2" aria-label="Saved post tabs"> 455 + <For each={TAB_ITEMS}> 456 + {(tab) => ( 457 + <button 458 + type="button" 459 + aria-pressed={props.activeTab === tab.key} 460 + class="inline-flex items-center gap-2 rounded-full border-0 px-4 py-2.5 text-sm font-medium transition duration-150" 461 + classList={{ 462 + "bg-surface text-primary shadow-[inset_0_0_0_1px_rgba(125,175,255,0.18)]": 463 + props.activeTab === tab.key, 464 + "bg-transparent text-on-surface-variant hover:bg-surface-container-high hover:text-on-surface": 465 + props.activeTab !== tab.key, 466 + }} 467 + onClick={() => props.onSelectTab(tab.key)}> 468 + {tab.label} 469 + <span class="min-w-5 rounded-full bg-white/10 px-1.5 py-0.5 text-center text-[0.7rem] leading-none"> 470 + {props.counts[tab.key]} 471 + </span> 472 + </button> 473 + )} 474 + </For> 475 + </nav> 476 + 477 + <span class="text-xs text-on-surface-variant"> 478 + <Show 479 + when={props.searching} 480 + fallback={`Browsing ${props.activeTab === "bookmark" ? "saved" : "liked"} posts`}> 481 + Found <span class="font-medium text-on-surface">{props.activeResultCount}</span> matches 482 + </Show> 483 + </span> 484 + </div> 485 + </header> 486 + ); 487 + } 488 + 489 + function SavedPostsViewport( 490 + props: { 491 + activeTab: TabKey; 492 + browsingState: TabState; 493 + onLoadMore: () => void; 494 + query: string; 495 + searching: boolean; 496 + searchingState: SearchTabState; 497 + }, 498 + ) { 499 + return ( 500 + <div class="min-h-0 overflow-y-auto px-3 pb-3"> 501 + <Presence> 502 + <Show when={props.activeTab === "bookmark"} keyed> 503 + <SavedPostsBody 504 + browsingState={props.browsingState} 505 + onLoadMore={props.onLoadMore} 506 + query={props.query} 507 + searching={props.searching} 508 + searchingState={props.searchingState} 509 + source={props.activeTab} /> 510 + </Show> 511 + <Show when={props.activeTab === "like"} keyed> 512 + <SavedPostsBody 513 + browsingState={props.browsingState} 514 + onLoadMore={props.onLoadMore} 515 + query={props.query} 516 + searching={props.searching} 517 + searchingState={props.searchingState} 518 + source={props.activeTab} /> 519 + </Show> 520 + </Presence> 521 + </div> 522 + ); 523 + } 524 + 525 + function SavedPostsBody( 526 + props: { 527 + browsingState: TabState; 528 + onLoadMore: () => void; 529 + query: string; 530 + searching: boolean; 531 + searchingState: SearchTabState; 532 + source: TabKey; 533 + }, 534 + ) { 535 + const activeState = createMemo(() => props.searching ? props.searchingState : props.browsingState); 536 + const emptyTitle = createMemo(() => 537 + props.searching 538 + ? `No ${props.source === "bookmark" ? "saved" : "liked"} matches found` 539 + : `No ${props.source === "bookmark" ? "bookmarked" : "liked"} posts synced yet.` 540 + ); 541 + 542 + return ( 543 + <Motion.div 544 + class="grid gap-3" 545 + initial={{ opacity: 0 }} 546 + animate={{ opacity: 1 }} 547 + exit={{ opacity: 0 }} 548 + transition={{ duration: 0.15 }}> 549 + <Switch> 550 + <Match when={activeState().loading && activeState().items.length === 0}> 551 + <LocalPostResultsSkeletons count={4} /> 552 + </Match> 553 + <Match when={!!activeState().error}> 554 + <SavedPostsMessage 555 + body="Try the query again or refresh after syncing if the local archive is stale." 556 + title={activeState().error ?? "Search failed"} /> 557 + </Match> 558 + <Match when={props.searching && activeState().items.length === 0}> 559 + <Motion.div 560 + class="grid place-items-center px-6 py-16" 561 + initial={{ opacity: 0 }} 562 + animate={{ opacity: 1 }} 563 + exit={{ opacity: 0 }} 564 + transition={{ duration: 0.15 }}> 565 + <SearchEmptyState reason="no-results" scope="local" /> 566 + </Motion.div> 567 + </Match> 568 + <Match when={!props.searching && activeState().items.length === 0}> 569 + <SavedPostsMessage 570 + body={`Refresh after syncing to populate your ${props.source === "bookmark" ? "saved" : "liked"} archive.`} 571 + title={emptyTitle()} /> 572 + </Match> 573 + <Match when={activeState().items.length > 0}> 574 + <div class="grid gap-3"> 575 + <LocalPostResultsList query={props.query} results={activeState().items} /> 576 + <LoadMoreButton 577 + next={activeState().nextOffset} 578 + onLoadMore={props.onLoadMore} 579 + loadingMore={activeState().loadingMore} /> 580 + </div> 581 + </Match> 582 + </Switch> 583 + </Motion.div> 584 + ); 585 + }
+57
src/components/search/LocalPostResultsList.tsx
··· 1 + import type { LocalPostResult } from "$/lib/api/search"; 2 + import { For } from "solid-js"; 3 + import { Motion } from "solid-motionone"; 4 + import { SearchResultCard } from "./SearchResultCard"; 5 + 6 + function LocalPostResultsSkeleton() { 7 + return ( 8 + <div class="flex animate-pulse items-start gap-4 rounded-2xl bg-surface px-4 py-4" aria-hidden="true"> 9 + <div class="h-10 w-10 shrink-0 rounded-full bg-white/5" /> 10 + <div class="min-w-0 flex-1 space-y-2"> 11 + <For each={["w-48", "w-full", "w-2/3"]}> 12 + {(width) => <div class={`h-3 rounded-full bg-white/5 ${width}`} />} 13 + </For> 14 + </div> 15 + </div> 16 + ); 17 + } 18 + 19 + export function LocalPostResultsSkeletons(props: { count?: number }) { 20 + return ( 21 + <div class="grid gap-2 py-1"> 22 + <For each={Array.from({ length: props.count ?? 5 })}>{() => <LocalPostResultsSkeleton />}</For> 23 + </div> 24 + ); 25 + } 26 + 27 + export function LocalPostResultsList(props: { query: string; results: LocalPostResult[] }) { 28 + return ( 29 + <Motion.div 30 + class="grid gap-2" 31 + initial={{ opacity: 0 }} 32 + animate={{ opacity: 1 }} 33 + exit={{ opacity: 0 }} 34 + transition={{ duration: 0.15 }}> 35 + <div class="grid gap-2" role="list"> 36 + <For each={props.results}> 37 + {(result, index) => ( 38 + <Motion.div 39 + initial={{ opacity: 0, y: -6 }} 40 + animate={{ opacity: 1, y: 0 }} 41 + transition={{ duration: 0.2, delay: Math.min(index() * 0.03, 0.18) }} 42 + role="listitem"> 43 + <SearchResultCard 44 + authorDid={result.authorDid} 45 + authorHandle={result.authorHandle ?? "unknown"} 46 + source={result.source} 47 + text={result.text ?? ""} 48 + createdAt={result.createdAt ?? ""} 49 + isSemanticMatch={result.semanticMatch && !result.keywordMatch} 50 + query={props.query} /> 51 + </Motion.div> 52 + )} 53 + </For> 54 + </div> 55 + </Motion.div> 56 + ); 57 + }
+5 -119
src/components/search/SearchPanel.tsx
··· 18 18 import { Motion, Presence } from "solid-motionone"; 19 19 import { PostCount } from "../shared/PostCount"; 20 20 import { EmbeddingsSettings } from "./EmbeddingsSettings"; 21 + import { LocalPostResultsList, LocalPostResultsSkeletons } from "./LocalPostResultsList"; 21 22 import { SearchEmptyState } from "./SearchEmptyState"; 23 + import { SearchQueryInput } from "./SearchQueryInput"; 22 24 import { SearchResultCard } from "./SearchResultCard"; 23 25 import { SyncStatusPanel } from "./SyncStatusPanel"; 24 26 ··· 284 286 ) { 285 287 return ( 286 288 <header class="grid gap-4 px-6 pb-5 pt-6"> 287 - <SearchInput 289 + <SearchQueryInput 288 290 error={props.error} 289 291 inputRef={props.inputRef} 290 292 loading={props.loading} ··· 348 350 ); 349 351 } 350 352 351 - function SearchInput( 352 - props: { 353 - error: string | null; 354 - inputRef: (el: HTMLInputElement) => void; 355 - loading: boolean; 356 - placeholder: string; 357 - query: string; 358 - onClear: () => void; 359 - onKeyDown: (event: KeyboardEvent) => void; 360 - onQueryChange: (value: string) => void; 361 - }, 362 - ) { 363 - return ( 364 - <div class="grid gap-2"> 365 - <div class="relative"> 366 - <div class="absolute left-4 top-1/2 -translate-y-1/2 text-on-surface-variant"> 367 - <Icon kind="search" class="text-lg" /> 368 - </div> 369 - 370 - <input 371 - ref={props.inputRef} 372 - type="text" 373 - value={props.query} 374 - placeholder={props.placeholder} 375 - class="w-full rounded-3xl border-0 bg-black/40 py-3.5 pl-12 pr-20 text-base text-on-surface placeholder:text-on-surface-variant/50 outline-none ring-1 ring-white/5 transition-all focus:ring-primary/50" 376 - onInput={(event) => props.onQueryChange(event.currentTarget.value)} 377 - onKeyDown={(event) => props.onKeyDown(event)} /> 378 - 379 - <div class="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-2"> 380 - <LoadingIndicator loading={props.loading} /> 381 - <ClearButton query={props.query} loading={props.loading} onClear={props.onClear} /> 382 - </div> 383 - </div> 384 - 385 - <Show when={props.error}> 386 - {(message) => ( 387 - <div class="rounded-2xl bg-red-500/10 px-3 py-2 text-sm text-red-200 shadow-[inset_0_0_0_1px_rgba(239,68,68,0.15)]"> 388 - {message()} 389 - </div> 390 - )} 391 - </Show> 392 - </div> 393 - ); 394 - } 395 - 396 - function LoadingIndicator(props: { loading: boolean }) { 397 - return ( 398 - <Show when={props.loading}> 399 - <span class="flex items-center text-on-surface-variant"> 400 - <Icon iconClass="i-ri-loader-4-line animate-spin" class="text-base" /> 401 - </span> 402 - </Show> 403 - ); 404 - } 405 - 406 - function ClearButton(props: { query: string; loading: boolean; onClear: () => void }) { 407 - return ( 408 - <Show when={props.query && !props.loading}> 409 - <button 410 - type="button" 411 - onClick={() => props.onClear()} 412 - class="inline-flex items-center gap-1.5 rounded-lg border-0 bg-white/10 px-2 py-1 text-xs text-on-surface-variant transition hover:bg-white/20 hover:text-on-surface"> 413 - <kbd class="rounded bg-white/10 px-1">ESC</kbd> 414 - clear 415 - </button> 416 - </Show> 417 - ); 418 - } 419 - 420 353 function ModeSelector( 421 354 props: { activeMode: SearchMode; semanticEnabled: boolean; onModeChange: (mode: SearchMode) => void }, 422 355 ) { ··· 489 422 return ( 490 423 <div class="min-h-0 overflow-y-auto px-3 pb-3"> 491 424 <Show when={props.loading} fallback={<SearchState {...props} />}> 492 - <div class="grid gap-2 py-1"> 493 - <For each={Array.from({ length: 5 })}>{() => <SearchSkeleton />}</For> 494 - </div> 425 + <LocalPostResultsSkeletons /> 495 426 </Show> 496 427 </div> 497 428 ); ··· 532 463 </Match> 533 464 534 465 <Match when={props.isLocalMode}> 535 - <LocalResultsList query={props.query} results={props.localResults} /> 466 + <LocalPostResultsList query={props.query} results={props.localResults} /> 536 467 </Match> 537 468 538 469 <Match when={!props.isLocalMode && props.networkResults}> ··· 556 487 ); 557 488 } 558 489 559 - function LocalResultsList(props: { query: string; results: LocalPostResult[] }) { 560 - return ( 561 - <Motion.div 562 - class="grid gap-2" 563 - initial={{ opacity: 0 }} 564 - animate={{ opacity: 1 }} 565 - exit={{ opacity: 0 }} 566 - transition={{ duration: 0.15 }}> 567 - <div class="grid gap-2" role="list"> 568 - <For each={props.results}> 569 - {(result, index) => ( 570 - <Motion.div 571 - initial={{ opacity: 0, y: -6 }} 572 - animate={{ opacity: 1, y: 0 }} 573 - transition={{ duration: 0.2, delay: Math.min(index() * 0.03, 0.18) }} 574 - role="listitem"> 575 - <SearchResultCard 576 - authorDid={result.authorDid} 577 - authorHandle={result.authorHandle ?? "unknown"} 578 - source={result.source} 579 - text={result.text ?? ""} 580 - createdAt={result.createdAt ?? ""} 581 - isSemanticMatch={result.semanticMatch && !result.keywordMatch} 582 - query={props.query} /> 583 - </Motion.div> 584 - )} 585 - </For> 586 - </div> 587 - </Motion.div> 588 - ); 589 - } 590 - 591 490 function NetworkResultsList(props: { query: string; results: NetworkSearchResult | null }) { 592 491 return ( 593 492 <Motion.div ··· 618 517 </For> 619 518 </div> 620 519 </Motion.div> 621 - ); 622 - } 623 - 624 - function SearchSkeleton() { 625 - return ( 626 - <div class="flex animate-pulse items-start gap-4 rounded-2xl bg-surface px-4 py-4" aria-hidden="true"> 627 - <div class="h-10 w-10 shrink-0 rounded-full bg-white/5" /> 628 - <div class="min-w-0 flex-1 space-y-2"> 629 - <div class="h-4 w-48 rounded-full bg-white/5" /> 630 - <div class="h-3 w-full rounded-full bg-white/5" /> 631 - <div class="h-3 w-2/3 rounded-full bg-white/5" /> 632 - </div> 633 - </div> 634 520 ); 635 521 } 636 522
+71
src/components/search/SearchQueryInput.tsx
··· 1 + import { Show } from "solid-js"; 2 + import { Icon } from "../shared/Icon"; 3 + 4 + type SearchQueryInputProps = { 5 + error: string | null; 6 + inputRef?: (el: HTMLInputElement) => void; 7 + loading: boolean; 8 + placeholder: string; 9 + query: string; 10 + onClear: () => void; 11 + onKeyDown?: (event: KeyboardEvent) => void; 12 + onQueryChange: (value: string) => void; 13 + }; 14 + 15 + export function SearchQueryInput(props: SearchQueryInputProps) { 16 + return ( 17 + <div class="grid gap-2"> 18 + <div class="relative"> 19 + <div class="absolute left-4 top-1/2 -translate-y-1/2 text-on-surface-variant"> 20 + <Icon kind="search" class="text-lg" /> 21 + </div> 22 + 23 + <input 24 + ref={props.inputRef} 25 + type="text" 26 + value={props.query} 27 + placeholder={props.placeholder} 28 + class="w-full rounded-3xl border-0 bg-black/40 py-3.5 pl-12 pr-20 text-base text-on-surface placeholder:text-on-surface-variant/50 outline-none ring-1 ring-white/5 transition-all focus:ring-primary/50" 29 + onInput={(event) => props.onQueryChange(event.currentTarget.value)} 30 + onKeyDown={(event) => props.onKeyDown?.(event)} /> 31 + 32 + <div class="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-2"> 33 + <LoadingIndicator loading={props.loading} /> 34 + <ClearButton query={props.query} loading={props.loading} onClear={props.onClear} /> 35 + </div> 36 + </div> 37 + 38 + <Show when={props.error}> 39 + {(message) => ( 40 + <div class="rounded-2xl bg-red-500/10 px-3 py-2 text-sm text-red-200 shadow-[inset_0_0_0_1px_rgba(239,68,68,0.15)]"> 41 + {message()} 42 + </div> 43 + )} 44 + </Show> 45 + </div> 46 + ); 47 + } 48 + 49 + function LoadingIndicator(props: { loading: boolean }) { 50 + return ( 51 + <Show when={props.loading}> 52 + <span class="flex items-center text-on-surface-variant"> 53 + <Icon iconClass="i-ri-loader-4-line animate-spin" class="text-base" /> 54 + </span> 55 + </Show> 56 + ); 57 + } 58 + 59 + function ClearButton(props: { query: string; loading: boolean; onClear: () => void }) { 60 + return ( 61 + <Show when={props.query && !props.loading}> 62 + <button 63 + type="button" 64 + onClick={() => props.onClear()} 65 + class="inline-flex items-center gap-1.5 rounded-lg border-0 bg-white/10 px-2 py-1 text-xs text-on-surface-variant transition hover:bg-white/20 hover:text-on-surface"> 66 + <kbd class="rounded bg-white/10 px-1">ESC</kbd> 67 + clear 68 + </button> 69 + </Show> 70 + ); 71 + }
+5 -1
src/components/shared/Icon.tsx
··· 57 57 | "list" 58 58 | "rss" 59 59 | "messages" 60 - | "unpin"; 60 + | "unpin" 61 + | "bookmark"; 61 62 62 63 type IconProps = JSX.HTMLAttributes<HTMLSpanElement> & { 63 64 class?: string; ··· 176 177 </Match> 177 178 <Match when={local.kind === "unpin"}> 178 179 <i class="i-ri-unpin-line" /> 180 + </Match> 181 + <Match when={local.kind === "bookmark"}> 182 + <i class="i-ri-bookmark-line" /> 179 183 </Match> 180 184 </Switch> 181 185 </span>
+15 -4
src/lib/api/search.ts
··· 20 20 21 21 export type StarterPackSearchResult = { cursor?: string | null; starterPacks: Array<TStarterPack> }; 22 22 23 - type TPostSource = "like" | "bookmark"; 23 + export type SavedPostSource = "like" | "bookmark"; 24 24 25 25 export type LocalPostResult = { 26 26 uri: string; ··· 29 29 authorHandle?: string | null; 30 30 text?: string | null; 31 31 createdAt?: string | null; 32 - source: TPostSource; 32 + source: SavedPostSource; 33 33 score: number; 34 34 keywordMatch: boolean; 35 35 semanticMatch: boolean; 36 36 }; 37 37 38 + export type SavedPostsPage = { posts: LocalPostResult[]; total: number; nextOffset?: number | null }; 39 + 38 40 export type SyncStatus = { 39 41 did: string; 40 - source: TPostSource; 42 + source: SavedPostSource; 41 43 cursor?: string | null; 42 44 lastSyncedAt?: string | null; 43 45 postCount?: number; ··· 71 73 return invoke("search_posts", { query, mode, limit }); 72 74 } 73 75 76 + export function listSavedPosts( 77 + source: SavedPostSource, 78 + limit: number, 79 + offset = 0, 80 + query?: string, 81 + ): Promise<SavedPostsPage> { 82 + return invoke("list_saved_posts", { source, limit, offset, query: query?.trim() ? query.trim() : null }); 83 + } 84 + 74 85 export function searchActors(query: string, limit?: number, cursor?: string | null): Promise<ActorSearchResult> { 75 86 return invoke("search_actors", { query, limit: limit ?? null, cursor: cursor ?? null }); 76 87 } ··· 83 94 return invoke("search_starter_packs", { query, limit: limit ?? null, cursor: cursor ?? null }); 84 95 } 85 96 86 - export function syncPosts(did: string, source: "like" | "bookmark"): Promise<SyncStatus> { 97 + export function syncPosts(did: string, source: SavedPostSource): Promise<SyncStatus> { 87 98 return invoke("sync_posts", { did, source }); 88 99 } 89 100
+13
src/router.test.tsx
··· 10 10 const listenMock = vi.hoisted(() => vi.fn()); 11 11 12 12 vi.mock("@tauri-apps/api/event", () => ({ listen: listenMock })); 13 + vi.mock( 14 + "$/components/saved/SavedPostsPanel", 15 + () => ({ SavedPostsPanel: () => <div data-testid="saved-posts-view">saved</div> }), 16 + ); 13 17 14 18 const Shell: Component<ParentProps<{ fullWidth?: boolean }>> = (props) => ( 15 19 <div data-testid="shell" data-full-width={props.fullWidth ? "true" : "false"}>{props.children}</div> ··· 99 103 100 104 expect(renderNotifications).toHaveBeenCalledOnce(); 101 105 expect(screen.getByText("notifications")).toBeInTheDocument(); 106 + expect(screen.getByTestId("shell")).toHaveAttribute("data-full-width", "false"); 107 + }); 108 + 109 + it("renders the saved posts route inside the protected shell", async () => { 110 + renderRouter("#/saved"); 111 + 112 + await screen.findByTestId("saved-posts-view"); 113 + 114 + expect(screen.getByText("saved")).toBeInTheDocument(); 102 115 expect(screen.getByTestId("shell")).toHaveAttribute("data-full-width", "false"); 103 116 }); 104 117
+8
src/router.tsx
··· 6 6 import { Dynamic } from "solid-js/web"; 7 7 import { DeckWorkspace } from "./components/deck/DeckWorkspace"; 8 8 import { ExplorerPanel } from "./components/explorer/ExplorerPanel"; 9 + import { SavedPostsPanel } from "./components/saved/SavedPostsPanel"; 9 10 import { SearchPanel } from "./components/search/SearchPanel"; 10 11 import { SettingsPanel } from "./components/settings/SettingsPanel"; 11 12 import { decodeMessagesRouteMemberDid } from "./lib/conversations"; ··· 137 138 </ProtectedRouteView> 138 139 ); 139 140 141 + const SavedPostsRoute = () => ( 142 + <ProtectedRouteView> 143 + <SavedPostsPanel /> 144 + </ProtectedRouteView> 145 + ); 146 + 140 147 const NotFoundRoute = () => ( 141 148 <Show when={session.bootstrapping} fallback={<Navigate href={session.hasSession ? TIMELINE_ROUTE : "/auth"} />}> 142 149 <RouteLoadingState /> ··· 153 160 <Route path="/profile/:actor" component={ActorProfileRoute} /> 154 161 <Route path="/composer" component={ComposerRoute} /> 155 162 <Route path="/search" component={SearchRoute} /> 163 + <Route path="/saved" component={SavedPostsRoute} /> 156 164 <Route path="/notifications" component={NotificationsRoute} /> 157 165 <Route path="/messages" component={MessagesRoute} /> 158 166 <Route path="/messages/:memberDid" component={MemberMessagesRoute} />
+1
tsconfig.json
··· 19 19 "noFallthroughCasesInSwitch": true, 20 20 "types": ["vite/client", "@testing-library/jest-dom"], 21 21 "baseUrl": "src", 22 + "ignoreDeprecations": "6.0", 22 23 "paths": { "$/*": ["./*"] } 23 24 }, 24 25 "include": ["src"],