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: use lazurite.stormlightlabs.org client-metadata

* add embedding model size to conf payload

* fix feed filters

* refactor component dir structure

+535 -81
+205 -13
src-tauri/src/auth.rs
··· 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; 6 + use jacquard::common::deps::fluent_uri::Uri; 6 7 use jacquard::common::session::SessionStoreError; 7 8 use jacquard::oauth::atproto::AtprotoClientMetadata; 8 9 use jacquard::oauth::authstore::ClientAuthStore; 9 10 use jacquard::oauth::client::{OAuthClient, OAuthSession}; 10 - use jacquard::oauth::loopback::{handle_localhost_callback, one_shot_server, try_open_in_browser}; 11 - use jacquard::oauth::loopback::{CallbackHandle, LoopbackConfig, LoopbackPort}; 11 + use jacquard::oauth::loopback::{try_open_in_browser, LoopbackConfig, LoopbackPort}; 12 + use jacquard::oauth::scopes::Scope; 12 13 use jacquard::oauth::session::{AuthRequestData, ClientData, ClientSessionData}; 13 - use jacquard::oauth::types::AuthorizeOptions; 14 + use jacquard::oauth::types::{AuthorizeOptions, CallbackParams}; 14 15 use jacquard::types::did::Did; 15 16 use jacquard::xrpc::XrpcClient; 16 17 use jacquard::IntoStatic; 17 18 use rusqlite::{params, OptionalExtension}; 18 19 use serde::{Deserialize, Serialize}; 19 20 use std::collections::HashMap; 20 - use std::net::SocketAddr; 21 + use std::io::{Read, Write}; 22 + use std::net::{SocketAddr, TcpListener, TcpStream}; 23 + use std::sync::mpsc as std_mpsc; 21 24 use std::sync::{MutexGuard, RwLock}; 25 + use std::thread; 22 26 use std::time::Duration; 23 27 use tauri::{AppHandle, Emitter}; 28 + use tokio::sync::oneshot; 24 29 25 30 pub const ACCOUNT_SWITCHED_EVENT: &str = "auth:account-switched"; 26 31 const CLIENT_NAME: &str = "Lazurite"; 32 + const CLIENT_METADATA_URL: &str = "https://lazurite.stormlightlabs.org/client-metadata.json"; 33 + const CLIENT_SITE_URL: &str = "https://lazurite.stormlightlabs.org"; 34 + const LOOPBACK_CALLBACK_PATH: &str = "/callback"; 35 + const LOOPBACK_SCOPE: &str = "atproto transition:generic"; 27 36 const LOGIN_TYPEAHEAD_LIMIT: usize = 6; 28 37 const LOGIN_TYPEAHEAD_CLIENT: &str = "lazurite-desktop"; 29 38 const LOGIN_TYPEAHEAD_PRIMARY_URL: &str = "https://typeahead.waow.tech"; ··· 408 417 } 409 418 410 419 pub fn default_client_metadata() -> AtprotoClientMetadata<'static> { 411 - AtprotoClientMetadata::default_localhost().with_prod_info(CLIENT_NAME, None, None, None) 420 + build_client_metadata("http://127.0.0.1/callback") 412 421 } 413 422 414 423 pub async fn login_with_loopback( ··· 417 426 let config = LoopbackConfig::default(); 418 427 let options = AuthorizeOptions::default(); 419 428 let bind_addr = loopback_bind_addr(&config)?; 420 - let (local_addr, callback_handle) = one_shot_server(bind_addr); 429 + let (local_addr, callback_handle) = start_loopback_callback_server(bind_addr)?; 421 430 let flow_client = build_loopback_client(oauth_client, &config, &options, local_addr); 422 431 423 - let auth_url = flow_client.start_auth(identifier, options).await?; 432 + let auth_url = match flow_client.start_auth(identifier, options).await { 433 + Ok(auth_url) => auth_url, 434 + Err(error) => { 435 + let _ = callback_handle.stop_tx.send(()); 436 + let _ = callback_handle.server_handle.join(); 437 + return Err(error.into()); 438 + } 439 + }; 424 440 let _ = try_open_in_browser(&auth_url); 425 441 426 442 complete_loopback_login(flow_client, callback_handle, config).await ··· 498 514 fn build_loopback_client( 499 515 oauth_client: &LazuriteOAuthClient, config: &LoopbackConfig, options: &AuthorizeOptions<'_>, local_addr: SocketAddr, 500 516 ) -> LazuriteOAuthClient { 501 - let mut client_data = oauth_client.build_localhost_client_data(config, options, local_addr); 502 - client_data.config = client_data.config.with_prod_info(CLIENT_NAME, None, None, None); 517 + let scopes = if options.scopes.is_empty() { None } else { Some(options.scopes.clone().into_static()) }; 518 + let redirect_uri = format!("http://{}:{}{}", config.host, local_addr.port(), LOOPBACK_CALLBACK_PATH); 519 + let client_data = ClientData::new_public(build_client_metadata_with_scopes(&redirect_uri, scopes)); 503 520 504 521 OAuthClient::new_with_shared( 505 522 oauth_client.registry.store.clone(), ··· 508 525 ) 509 526 } 510 527 528 + struct LocalCallbackServerHandle { 529 + callback_rx: oneshot::Receiver<CallbackParams<'static>>, 530 + server_handle: thread::JoinHandle<()>, 531 + stop_tx: std_mpsc::Sender<()>, 532 + } 533 + 511 534 async fn complete_loopback_login( 512 - flow_client: LazuriteOAuthClient, callback_handle: CallbackHandle, config: LoopbackConfig, 535 + flow_client: LazuriteOAuthClient, callback_handle: LocalCallbackServerHandle, config: LoopbackConfig, 513 536 ) -> Result<LazuriteOAuthSession, AppError> { 514 - Ok(handle_localhost_callback(callback_handle, &flow_client, &config).await?) 537 + let callback = tokio::time::timeout(Duration::from_millis(config.timeout_ms), callback_handle.callback_rx) 538 + .await 539 + .map_err(|_| AppError::validation("oauth loopback callback timed out"))? 540 + .map_err(|_| AppError::validation("oauth loopback callback channel closed"))?; 541 + 542 + let _ = callback_handle.stop_tx.send(()); 543 + let _ = callback_handle.server_handle.join(); 544 + 545 + Ok(flow_client.callback(callback).await?) 515 546 } 516 547 517 548 fn loopback_bind_addr(config: &LoopbackConfig) -> Result<SocketAddr, AppError> { ··· 525 556 .map_err(|error| AppError::Validation(format!("invalid loopback bind address: {error}"))) 526 557 } 527 558 559 + fn start_loopback_callback_server(bind_addr: SocketAddr) -> Result<(SocketAddr, LocalCallbackServerHandle), AppError> { 560 + let listener = TcpListener::bind(bind_addr)?; 561 + listener.set_nonblocking(true)?; 562 + let local_addr = listener.local_addr()?; 563 + 564 + let (callback_tx, callback_rx) = oneshot::channel(); 565 + let (stop_tx, stop_rx) = std_mpsc::channel(); 566 + 567 + let server_handle = thread::spawn(move || { 568 + run_loopback_callback_server(&listener, callback_tx, &stop_rx); 569 + }); 570 + 571 + Ok(( 572 + local_addr, 573 + LocalCallbackServerHandle { callback_rx, server_handle, stop_tx }, 574 + )) 575 + } 576 + 577 + fn run_loopback_callback_server( 578 + listener: &TcpListener, callback_tx: oneshot::Sender<CallbackParams<'static>>, stop_rx: &std_mpsc::Receiver<()>, 579 + ) { 580 + let mut callback_tx = Some(callback_tx); 581 + 582 + loop { 583 + if stop_rx.try_recv().is_ok() { 584 + break; 585 + } 586 + 587 + match listener.accept() { 588 + Ok((mut stream, _)) => { 589 + let handled = handle_loopback_stream(&mut stream, &mut callback_tx); 590 + if handled { 591 + break; 592 + } 593 + } 594 + Err(error) => match error.kind() { 595 + std::io::ErrorKind::WouldBlock => thread::sleep(Duration::from_millis(25)), 596 + _ => break, 597 + }, 598 + } 599 + } 600 + } 601 + 602 + fn handle_loopback_stream( 603 + stream: &mut TcpStream, callback_tx: &mut Option<oneshot::Sender<CallbackParams<'static>>>, 604 + ) -> bool { 605 + let mut buffer = [0_u8; 8192]; 606 + let bytes_read = match stream.read(&mut buffer) { 607 + Ok(bytes_read) => bytes_read, 608 + Err(_) => return false, 609 + }; 610 + 611 + let request = String::from_utf8_lossy(&buffer[..bytes_read]); 612 + let request_line = request.lines().next().unwrap_or_default(); 613 + let request_target = request_line.split_whitespace().nth(1).unwrap_or_default(); 614 + 615 + match parse_loopback_callback(request_target) { 616 + Ok(params) => { 617 + let _ = write_http_response(stream, 200, "Logged in!"); 618 + if let Some(callback_tx) = callback_tx.take() { 619 + let _ = callback_tx.send(params); 620 + } 621 + true 622 + } 623 + Err(_) => { 624 + let _ = write_http_response(stream, 404, "Not found"); 625 + false 626 + } 627 + } 628 + } 629 + 630 + fn parse_loopback_callback(request_target: &str) -> Result<CallbackParams<'static>, AppError> { 631 + let url = reqwest::Url::parse(&format!("http://127.0.0.1{request_target}")) 632 + .map_err(|error| AppError::validation(format!("invalid loopback callback URL: {error}")))?; 633 + 634 + if url.path() != LOOPBACK_CALLBACK_PATH { 635 + return Err(AppError::validation("unexpected loopback callback path")); 636 + } 637 + 638 + let mut code = None; 639 + let mut iss = None; 640 + let mut state = None; 641 + 642 + for (key, value) in url.query_pairs() { 643 + match key.as_ref() { 644 + "code" => code = Some(value.into_owned()), 645 + "iss" => iss = Some(value.into_owned()), 646 + "state" => state = Some(value.into_owned()), 647 + _ => {} 648 + } 649 + } 650 + 651 + let code = code.ok_or_else(|| AppError::validation("loopback callback missing code"))?; 652 + 653 + Ok(CallbackParams { code: code.into(), iss: iss.map(Into::into), state: state.map(Into::into) }) 654 + } 655 + 656 + fn write_http_response(stream: &mut TcpStream, status_code: u16, body: &str) -> std::io::Result<()> { 657 + let status_text = match status_code { 658 + 200 => "OK", 659 + 404 => "Not Found", 660 + _ => "OK", 661 + }; 662 + let response = format!( 663 + "HTTP/1.1 {status_code} {status_text}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", 664 + body.len() 665 + ); 666 + stream.write_all(response.as_bytes())?; 667 + stream.flush() 668 + } 669 + 670 + fn build_client_metadata(redirect_uri: &str) -> AtprotoClientMetadata<'static> { 671 + build_client_metadata_with_scopes(redirect_uri, None) 672 + } 673 + 674 + fn build_client_metadata_with_scopes( 675 + redirect_uri: &str, scopes: Option<Vec<Scope<'static>>>, 676 + ) -> AtprotoClientMetadata<'static> { 677 + AtprotoClientMetadata { 678 + client_id: Uri::parse(CLIENT_METADATA_URL.to_string()).expect("client metadata URL should be valid"), 679 + client_uri: Some(Uri::parse(CLIENT_SITE_URL.to_string()).expect("client site URL should be valid")), 680 + redirect_uris: vec![Uri::parse(redirect_uri.to_string()).expect("loopback redirect URI should be valid")], 681 + grant_types: vec![ 682 + jacquard::oauth::atproto::GrantType::AuthorizationCode, 683 + jacquard::oauth::atproto::GrantType::RefreshToken, 684 + ], 685 + scopes: scopes.unwrap_or_else(|| Scope::parse_multiple(LOOPBACK_SCOPE).expect("loopback scopes should parse")), 686 + jwks_uri: None, 687 + client_name: Some(CLIENT_NAME.into()), 688 + logo_uri: None, 689 + tos_uri: None, 690 + privacy_policy_uri: None, 691 + } 692 + } 693 + 528 694 fn sqlite_to_store_error(error: rusqlite::Error) -> SessionStoreError { 529 695 SessionStoreError::Other(Box::new(error)) 530 696 } ··· 613 779 #[cfg(test)] 614 780 mod tests { 615 781 use super::{ 616 - should_fallback_to_public_typeahead, LoginSuggestion, PersistentAuthStore, TypeaheadFetchError, 617 - TypeaheadFetchErrorKind, 782 + default_client_metadata, parse_loopback_callback, should_fallback_to_public_typeahead, LoginSuggestion, 783 + PersistentAuthStore, TypeaheadFetchError, TypeaheadFetchErrorKind, 618 784 }; 619 785 use crate::db::DbPool; 786 + use jacquard::common::deps::fluent_uri::Uri; 620 787 use reqwest::StatusCode; 621 788 use rusqlite::{params, Connection}; 622 789 use std::sync::{Arc, Mutex}; ··· 723 890 assert_eq!(payload["handle"], "alice.bsky.social"); 724 891 assert_eq!(payload["displayName"], "Alice"); 725 892 assert_eq!(payload["avatar"], "https://cdn.example/alice.jpg"); 893 + } 894 + 895 + #[test] 896 + fn default_client_metadata_uses_hosted_document_and_callback_path() { 897 + let metadata = default_client_metadata(); 898 + 899 + assert_eq!( 900 + metadata.client_id.as_str(), 901 + "https://lazurite.stormlightlabs.org/client-metadata.json" 902 + ); 903 + assert_eq!( 904 + metadata.client_uri.as_ref().map(Uri::as_str), 905 + Some("https://lazurite.stormlightlabs.org") 906 + ); 907 + assert_eq!(metadata.redirect_uris[0].as_str(), "http://127.0.0.1/callback"); 908 + } 909 + 910 + #[test] 911 + fn parse_loopback_callback_extracts_query_params_from_callback_path() { 912 + let params = parse_loopback_callback("/callback?code=abc123&state=state-1&iss=https%3A%2F%2Fauth.example") 913 + .expect("loopback callback should parse"); 914 + 915 + assert_eq!(params.code.as_ref(), "abc123"); 916 + assert_eq!(params.state.as_deref(), Some("state-1")); 917 + assert_eq!(params.iss.as_deref(), Some("https://auth.example")); 726 918 } 727 919 728 920 #[test]
+26 -8
src-tauri/src/feed.rs
··· 176 176 Ok(output.preferences.into_iter().map(IntoStatic::into_static).collect()) 177 177 } 178 178 179 + fn accepts_empty_put_preferences_response(status: reqwest::StatusCode, body: &[u8]) -> bool { 180 + status.is_success() && body.is_empty() 181 + } 182 + 179 183 async fn store_preference_items(session: &Arc<LazuriteOAuthSession>, items: StoredPreferences) -> Result<()> { 180 - session 184 + let response = session 181 185 .send(PutPreferences::new().preferences(items).build()) 182 186 .await 183 187 .map_err(|error| { 184 188 log::error!("putPreferences error: {error}"); 185 189 AppError::validation("putPreferences error") 186 - })? 187 - .into_output() 188 - .map_err(|error| { 189 - log::error!("putPreferences output error: {error}"); 190 - AppError::validation("putPreferences output error") 191 190 })?; 191 + 192 + // Bluesky may return a 200 with no body for putPreferences. jacquard's default 193 + // unit decoder still tries to parse JSON, which raises an EOF on successful writes. 194 + if accepts_empty_put_preferences_response(response.status(), response.buffer()) { 195 + return Ok(()); 196 + } 197 + 198 + response.into_output().map_err(|error| { 199 + log::error!("putPreferences output error: {error}"); 200 + AppError::validation("putPreferences output error") 201 + })?; 192 202 193 203 Ok(()) 194 204 } ··· 673 683 #[cfg(test)] 674 684 mod tests { 675 685 use super::{ 676 - merge_feed_view_preferences, merge_saved_feeds_preferences, user_preferences_from_items, FeedViewPrefItem, 677 - SavedFeedItem, 686 + accepts_empty_put_preferences_response, merge_feed_view_preferences, merge_saved_feeds_preferences, 687 + user_preferences_from_items, FeedViewPrefItem, SavedFeedItem, 678 688 }; 679 689 use jacquard::api::app_bsky::actor::{AdultContentPref, FeedViewPref, PreferencesItem}; 690 + use reqwest::StatusCode; 680 691 681 692 fn adult_content_pref_item() -> PreferencesItem<'static> { 682 693 PreferencesItem::AdultContentPref(Box::new(AdultContentPref::new().enabled(true).build())) ··· 758 769 .expect("custom pref should exist"); 759 770 assert!(!custom.hide_quote_posts); 760 771 assert!(!custom.hide_replies); 772 + } 773 + 774 + #[test] 775 + fn empty_success_put_preferences_response_is_treated_as_valid() { 776 + assert!(accepts_empty_put_preferences_response(StatusCode::OK, b"")); 777 + assert!(!accepts_empty_put_preferences_response(StatusCode::OK, b"null")); 778 + assert!(!accepts_empty_put_preferences_response(StatusCode::BAD_REQUEST, b"")); 761 779 } 762 780 }
+22
src-tauri/src/search.rs
··· 15 15 use rusqlite::{params, Connection, OptionalExtension}; 16 16 use serde::Serialize; 17 17 use std::collections::HashMap; 18 + use std::fs; 18 19 use std::path::{Path, PathBuf}; 19 20 use std::sync::{Arc, LazyLock, Mutex}; 20 21 use std::time::{Duration, Instant}; ··· 606 607 cached_embedding_files(models_dir) == required_embedding_files().len() 607 608 } 608 609 610 + fn directory_size(path: &Path) -> Result<u64> { 611 + if !path.exists() { 612 + return Ok(0); 613 + } 614 + 615 + if path.is_file() { 616 + return Ok(path.metadata()?.len()); 617 + } 618 + 619 + let mut total = 0_u64; 620 + for entry in fs::read_dir(path)? { 621 + let entry = entry?; 622 + total = total.saturating_add(directory_size(&entry.path())?); 623 + } 624 + 625 + Ok(total) 626 + } 627 + 609 628 fn set_download_idle_state(downloaded_files: usize, total_files: usize) { 610 629 if let Ok(mut state) = EMBEDDINGS_DOWNLOAD_STATE.lock() { 611 630 state.active = false; ··· 1040 1059 pub enabled: bool, 1041 1060 pub model_name: String, 1042 1061 pub dimensions: i64, 1062 + pub model_size_bytes: Option<u64>, 1043 1063 pub downloaded: bool, 1044 1064 pub download_active: bool, 1045 1065 pub download_progress: Option<f64>, ··· 1056 1076 let enabled = db_get_embeddings_enabled(&conn)?; 1057 1077 let models_dir = resolve_models_dir(app)?; 1058 1078 let downloaded = embeddings_downloaded(&models_dir); 1079 + let model_size_bytes = directory_size(&models_dir).ok().filter(|bytes| *bytes > 0); 1059 1080 let state = EMBEDDINGS_DOWNLOAD_STATE 1060 1081 .lock() 1061 1082 .map_err(|_| AppError::StatePoisoned("embeddings_download_state"))?; ··· 1087 1108 enabled, 1088 1109 model_name: EMBEDDING_MODEL_NAME.to_string(), 1089 1110 dimensions: EMBEDDING_DIMENSIONS, 1111 + model_size_bytes, 1090 1112 downloaded, 1091 1113 download_active: state.active, 1092 1114 download_progress,
+18 -6
src-tauri/src/settings.rs
··· 475 475 if is_valid_level { 476 476 let timestamp = Some(timestamp_token.to_string()); 477 477 if let Some(colon_pos) = rest.find(": ") { 478 - return LogEntry { 479 - timestamp, 480 - level: level_token, 481 - target: Some(rest[..colon_pos].to_string()), 482 - message: rest[colon_pos + 2..].to_string(), 483 - }; 478 + let candidate_target = &rest[..colon_pos]; 479 + if !candidate_target.contains(char::is_whitespace) { 480 + return LogEntry { 481 + timestamp, 482 + level: level_token, 483 + target: Some(candidate_target.to_string()), 484 + message: rest[colon_pos + 2..].to_string(), 485 + }; 486 + } 484 487 } 485 488 return LogEntry { timestamp, level: level_token, target: None, message: rest }; 486 489 } ··· 1049 1052 assert_eq!(entry.level, "ERROR"); 1050 1053 assert!(entry.target.is_none()); 1051 1054 assert_eq!(entry.message, "something went wrong"); 1055 + } 1056 + 1057 + #[test] 1058 + fn parse_log_line_keeps_colon_messages_when_no_target_is_present() { 1059 + let line = "2024-01-15T10:30:00Z WARN failed to parse payload: invalid json"; 1060 + let entry = parse_log_line(line); 1061 + assert_eq!(entry.level, "WARN"); 1062 + assert!(entry.target.is_none()); 1063 + assert_eq!(entry.message, "failed to parse payload: invalid json"); 1052 1064 } 1053 1065 1054 1066 #[test]
+1 -1
src/App.tsx
··· 5 5 import { createEffect, onCleanup, Show } from "solid-js"; 6 6 import "./App.css"; 7 7 import { AccountLedger } from "./components/account/AccountLedger"; 8 - import { AppRail } from "./components/AppRail"; 9 8 import { ComposerWindow } from "./components/feeds/ComposerWindow"; 10 9 import { FeedWorkspace } from "./components/feeds/FeedWorkspace"; 11 10 import { LoginPanel } from "./components/LoginPanel"; 12 11 import { NotificationsPanel } from "./components/notifications/NotificationsPanel"; 13 12 import { HeaderPanel } from "./components/panels/Header"; 13 + import { AppRail } from "./components/rail/AppRail"; 14 14 import { SessionSpotlight } from "./components/Session"; 15 15 import { ErrorToast } from "./components/shared/ErrorToast"; 16 16 import { AppPreferencesProvider } from "./contexts/app-preferences";
+4 -4
src/components/AppRail.tsx src/components/rail/AppRail.tsx
··· 1 1 import { useAppSession } from "$/contexts/app-session"; 2 2 import { useAppShellUi } from "$/contexts/app-shell-ui"; 3 3 import { Show } from "solid-js"; 4 - import { AccountSwitcher } from "./account/AccountSwitcher"; 5 - import { RailButton } from "./RailButton"; 6 - import { ArrowIcon } from "./shared/Icon"; 7 - import { Wordmark } from "./Wordmark"; 4 + import { AccountSwitcher } from "../account/AccountSwitcher"; 5 + import { ArrowIcon } from "../shared/Icon"; 6 + import { Wordmark } from "../Wordmark"; 7 + import { RailButton } from "./AppRailButton"; 8 8 9 9 function RailHeader(props: { collapsed: boolean; onToggleCollapse: () => void }) { 10 10 return (
+1 -1
src/components/RailButton.test.tsx src/components/rail/AppRailButton.test.tsx
··· 1 1 import { MemoryRouter, Route } from "@solidjs/router"; 2 2 import { render, screen } from "@solidjs/testing-library"; 3 3 import { describe, expect, it } from "vitest"; 4 - import { RailButton } from "./RailButton"; 4 + import { RailButton } from "./AppRailButton"; 5 5 6 6 function renderInRouter(ui: () => ReturnType<typeof RailButton>) { 7 7 return render(() => (
+1 -1
src/components/RailButton.tsx src/components/rail/AppRailButton.tsx
··· 1 1 import { A } from "@solidjs/router"; 2 2 import { Show } from "solid-js"; 3 3 import { Motion, Presence } from "solid-motionone"; 4 - import { Icon, type IconKind } from "./shared/Icon"; 4 + import { Icon, type IconKind } from "../shared/Icon"; 5 5 6 6 type RailButtonProps = { 7 7 badge?: number;
+52
src/components/feeds/FeedWorkspace.test.tsx
··· 36 36 }; 37 37 } 38 38 39 + function createReplyItem(id: string, likeCount: number, text = `Reply ${id}`) { 40 + const base = createFeedItem(id, text); 41 + return { 42 + ...base, 43 + post: { 44 + ...base.post, 45 + author: { ...base.post.author, viewer: { following: "at://did:plc:alice/app.bsky.graph.follow/1" } }, 46 + likeCount, 47 + }, 48 + reply: { 49 + parent: { $type: "app.bsky.feed.defs#postView", ...createFeedItem("root").post }, 50 + root: { $type: "app.bsky.feed.defs#postView", ...createFeedItem("root").post }, 51 + }, 52 + }; 53 + } 54 + 39 55 function createDeferred<T>() { 40 56 let resolve!: (value: T) => void; 41 57 const promise = new Promise<T>((innerResolve) => { ··· 133 149 await flushMicrotasks(); 134 150 135 151 expect(scroller!.scrollTop).toBe(260); 152 + }); 153 + 154 + it("filters replies when the minimum like threshold changes", async () => { 155 + invokeMock.mockImplementation((command: string) => { 156 + if (command === "get_preferences") { 157 + return Promise.resolve({ 158 + savedFeeds: [{ id: "following", pinned: true, type: "timeline", value: "following" }], 159 + feedViewPrefs: [], 160 + }); 161 + } 162 + 163 + if (command === "get_timeline") { 164 + return Promise.resolve({ cursor: null, feed: [createReplyItem("1", 2, "Low-like reply")] }); 165 + } 166 + 167 + if (command === "update_feed_view_pref") { 168 + return Promise.resolve(null); 169 + } 170 + 171 + throw new Error(`unexpected invoke: ${command}`); 172 + }); 173 + 174 + render(() => ( 175 + <AppTestProviders 176 + session={{ activeDid: ACTIVE_SESSION.did, activeHandle: ACTIVE_SESSION.handle, activeSession: ACTIVE_SESSION }}> 177 + <FeedWorkspace onThreadRouteChange={vi.fn()} threadUri={null} /> 178 + </AppTestProviders> 179 + )); 180 + 181 + await screen.findByText("Low-like reply"); 182 + 183 + const thresholdInput = screen.getByRole("spinbutton", { name: /Minimum likes for replies/i }); 184 + fireEvent.input(thresholdInput, { target: { value: "5" } }); 185 + 186 + expect(await screen.findByDisplayValue("5")).toBeInTheDocument(); 187 + expect(screen.queryByText("Low-like reply")).not.toBeInTheDocument(); 136 188 }); 137 189 });
+5
src/components/feeds/FeedWorkspaceSidebar.tsx
··· 96 96 return ( 97 97 <label class="grid gap-2 text-[0.8rem] text-on-surface-variant"> 98 98 <span>Minimum likes for replies</span> 99 + <p class="m-0 text-[0.72rem] leading-normal text-on-surface-variant/80">Only reply posts are affected.</p> 99 100 <input 100 101 class="rounded-full border-0 bg-white/6 px-4 py-2 text-on-surface shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)] focus:outline focus:outline-primary/50" 101 102 min="0" 102 103 type="number" 104 + placeholder='e.g. "10"' 103 105 value={props.value ?? ""} 104 106 onInput={(event) => { 105 107 const value = event.currentTarget.value.trim(); 108 + if (value !== "" && (Number.isNaN(Number(value)) || Number(value) < 0)) { 109 + return; 110 + } 106 111 props.onChange(value ? Number(value) : null); 107 112 }} /> 108 113 </label>
+16
src/components/feeds/PostCard.test.tsx
··· 36 36 37 37 expect(onOpenThread).toHaveBeenCalledTimes(1); 38 38 }); 39 + 40 + it("shows reply context when the feed item is a reply", () => { 41 + render(() => ( 42 + <PostCard 43 + item={{ 44 + post: createPost(), 45 + reply: { 46 + parent: { $type: "app.bsky.feed.defs#postView", ...createPost(), author: { ...createPost().author, handle: "bob.test" } }, 47 + root: { $type: "app.bsky.feed.defs#postView", ...createPost() }, 48 + }, 49 + }} 50 + post={createPost()} /> 51 + )); 52 + 53 + expect(screen.getByText("Replying to @bob.test")).toBeInTheDocument(); 54 + }); 39 55 });
+20
src/components/feeds/PostCard.tsx
··· 7 7 getPostText, 8 8 getQuotedAuthor, 9 9 getQuotedText, 10 + isReplyItem, 10 11 } from "$/lib/feeds"; 11 12 import type { FeedViewPost, ImagesEmbedView, PostView, ProfileViewBasic } from "$/lib/types"; 12 13 import { formatCount } from "$/lib/utils/text"; ··· 43 44 44 45 return `${getDisplayName(reason.by)} reposted`; 45 46 }); 47 + const replyLabel = createMemo(() => { 48 + const item = props.item; 49 + if (!item || !isReplyItem(item)) { 50 + return null; 51 + } 52 + 53 + const parent = item.reply?.parent; 54 + if (parent?.$type === "app.bsky.feed.defs#postView") { 55 + return `Replying to @${parent.author.handle.replace(/^@/, "")}`; 56 + } 57 + 58 + return "Reply in thread"; 59 + }); 46 60 47 61 const likeCount = createMemo(() => formatCount(props.post.likeCount)); 48 62 const replyCount = createMemo(() => formatCount(props.post.replyCount)); ··· 69 83 <div class="mb-3 flex items-center gap-2 text-xs font-medium tracking-[0.04em] text-primary"> 70 84 <Icon aria-hidden="true" iconClass="i-ri-repeat-2-line" /> 71 85 <span>{reasonLabel()}</span> 86 + </div> 87 + </Show> 88 + <Show when={replyLabel()}> 89 + <div class="mb-3 flex items-center gap-2 text-xs font-medium tracking-[0.04em] text-on-surface-variant"> 90 + <Icon aria-hidden="true" iconClass="i-ri-corner-down-right-line" /> 91 + <span>{replyLabel()}</span> 72 92 </div> 73 93 </Show> 74 94
+3
src/components/search/EmbeddingsSettings.test.tsx
··· 55 55 enabled: true, 56 56 modelName: "nomic-embed-text-v1.5", 57 57 dimensions: 768, 58 + modelSizeBytes: 1024 * 1024 * 384, 58 59 downloaded: true, 59 60 downloadActive: false, 60 61 }); ··· 62 63 enabled: true, 63 64 modelName: "nomic-embed-text-v1.5", 64 65 dimensions: 768, 66 + modelSizeBytes: 1024 * 1024 * 384, 65 67 downloaded: true, 66 68 downloadActive: false, 67 69 }); ··· 74 76 expect(await screen.findByText("Semantic Search")).toBeInTheDocument(); 75 77 expect(await screen.findByText(/nomic-embed-text-v1\.5/)).toBeInTheDocument(); 76 78 expect(await screen.findByText(/768D/)).toBeInTheDocument(); 79 + expect(await screen.findByText(/384 MB on disk/i)).toBeInTheDocument(); 77 80 }); 78 81 79 82 it("shows toggle in enabled state when embeddings are enabled", async () => {
+9 -1
src/components/search/EmbeddingsSettings.tsx
··· 2 2 import { Icon } from "$/components/shared/Icon"; 3 3 import { useAppPreferences } from "$/contexts/app-preferences"; 4 4 import type { EmbeddingsConfig } from "$/lib/api/search"; 5 - import { formatEtaSeconds, formatProgress } from "$/lib/utils/text"; 5 + import { formatBytes, formatEtaSeconds, formatProgress } from "$/lib/utils/text"; 6 6 import { createEffect, createMemo, createSignal, Match, onCleanup, onMount, Show, Switch } from "solid-js"; 7 7 import { Motion, Presence } from "solid-motionone"; 8 8 ··· 12 12 <span>{props.config?.modelName ?? "nomic-embed-text-v1.5"}</span> 13 13 <span>·</span> 14 14 <span>{props.config?.dimensions ?? 768}D</span> 15 + <Show when={props.config?.modelSizeBytes}> 16 + {(bytes) => ( 17 + <> 18 + <span>·</span> 19 + <span>{formatBytes(bytes())} on disk</span> 20 + </> 21 + )} 22 + </Show> 15 23 </p> 16 24 ); 17 25 }
+1
src/components/search/SearchPanel.test.tsx
··· 53 53 expect(screen.getByText("Keyword")).toBeInTheDocument(); 54 54 expect(screen.getByText("Semantic")).toBeInTheDocument(); 55 55 expect(screen.getByText("Hybrid")).toBeInTheDocument(); 56 + expect(screen.getByRole("link", { name: /open settings/i })).toHaveAttribute("href", "#/settings"); 56 57 }); 57 58 58 59 it("switches search modes when clicking mode buttons", async () => {
+6
src/components/search/SearchPanel.tsx
··· 633 633 </div> 634 634 </div> 635 635 </div> 636 + <a 637 + class="inline-flex w-fit items-center gap-2 rounded-full bg-white/6 px-3 py-2 text-xs font-medium text-on-surface no-underline transition hover:bg-white/10 hover:text-primary" 638 + href="#/settings"> 639 + <Icon kind="settings" class="text-sm" /> 640 + <span>Open settings</span> 641 + </a> 636 642 </section> 637 643 ); 638 644 }
+61 -41
src/components/settings/SettingsLogs.tsx
··· 1 1 import type { LogEntry, LogLevelFilter } from "$/lib/types"; 2 - import { For, Show } from "solid-js"; 2 + import { formatLogCopyLine, formatLogTimestamp } from "$/lib/utils/text"; 3 + import { createMemo, For, Show } from "solid-js"; 3 4 import { Motion, Presence } from "solid-motionone"; 4 5 import { Icon } from "../shared/Icon"; 5 6 import { SegmentedControl } from "../shared/SegmentedControl"; ··· 21 22 expand: (expanded: boolean) => void; 22 23 }; 23 24 25 + function ExpandButton(props: { expanded: boolean; onClick: () => void }) { 26 + return ( 27 + <button 28 + type="button" 29 + onClick={() => props.onClick()} 30 + class="flex items-center justify-between rounded-lg bg-black/40 px-4 py-2 text-sm text-on-surface transition hover:bg-black/50"> 31 + <Show 32 + when={props.expanded} 33 + fallback={ 34 + <> 35 + <span>Expand Logs</span> 36 + <Icon kind="menu" class="text-xs" /> 37 + </> 38 + }> 39 + <> 40 + <span>Collapse Logs</span> 41 + <Icon kind="close" class="text-xs" /> 42 + </> 43 + </Show> 44 + </button> 45 + ); 46 + } 47 + 48 + function LogDisplay(props: { logs: LogEntry[] }) { 49 + return ( 50 + <Motion.div 51 + class="overflow-hidden" 52 + initial={{ height: 0 }} 53 + animate={{ height: "auto" }} 54 + exit={{ height: 0 }} 55 + transition={{ duration: 0.2 }}> 56 + <div class="max-h-64 overflow-y-auto rounded-xl bg-black/50 p-4 font-mono text-xs"> 57 + <For each={props.logs} fallback={<p class="text-on-surface-variant">No log entries found</p>}> 58 + {(log) => ( 59 + <div class="mb-2 grid gap-2 rounded-xl bg-white/3 px-3 py-2 md:grid-cols-[auto_auto_auto_minmax(0,1fr)] md:items-start"> 60 + <span class="text-on-surface-variant">{formatLogTimestamp(log.timestamp)}</span> 61 + <span 62 + class="font-semibold" 63 + classList={{ 64 + "text-on-surface-variant": log.level === "DEBUG" || log.level === "TRACE", 65 + "text-primary": log.level === "INFO", 66 + "text-yellow-400": log.level === "WARN", 67 + "text-red-400": log.level === "ERROR", 68 + }}> 69 + {log.level} 70 + </span> 71 + <span class="break-all text-on-surface-variant">{log.target ?? "app"}</span> 72 + <span class="whitespace-pre-wrap wrap-break-word text-on-secondary-container">{log.message}</span> 73 + </div> 74 + )} 75 + </For> 76 + </div> 77 + </Motion.div> 78 + ); 79 + } 80 + 24 81 export function SettingsLogs(props: SettingsLogsProps) { 25 82 const expanded = () => props.expanded; 26 83 const level = () => props.logLevel; 27 84 const logs = () => props.logs; 85 + const output = createMemo(() => logs().map((log) => formatLogCopyLine(log)).join("\n")); 28 86 return ( 29 87 <SettingsCard icon="computer" title="Logs"> 30 88 <div class="grid gap-3"> ··· 34 92 <button 35 93 type="button" 36 94 onClick={() => { 37 - void navigator.clipboard.writeText( 38 - logs().map((l) => `[${l.timestamp}] ${l.level}: ${l.message}`).join("\n"), 39 - ); 95 + void navigator.clipboard.writeText(output()); 40 96 }} 41 97 class="rounded-lg border border-white/20 px-3 py-1.5 text-xs font-medium text-on-surface transition hover:bg-white/5"> 42 98 Copy all ··· 49 105 </button> 50 106 </div> 51 107 </div> 52 - <button 53 - type="button" 54 - onClick={() => props.expand(!expanded())} 55 - class="flex items-center justify-between rounded-lg bg-black/40 px-4 py-2 text-sm text-on-surface transition hover:bg-black/50"> 56 - <span>{expanded() ? "Collapse" : "Expand"} log viewer</span> 57 - <Icon kind={expanded() ? "close" : "menu"} class="text-xs" /> 58 - </button> 108 + <ExpandButton expanded={expanded()} onClick={() => props.expand(!expanded())} /> 59 109 <Presence> 60 110 <Show when={expanded()}> 61 111 <LogDisplay logs={logs()} /> ··· 65 115 </SettingsCard> 66 116 ); 67 117 } 68 - 69 - function LogDisplay(props: { logs: LogEntry[] }) { 70 - return ( 71 - <Motion.div 72 - class="overflow-hidden" 73 - initial={{ height: 0 }} 74 - animate={{ height: "auto" }} 75 - exit={{ height: 0 }} 76 - transition={{ duration: 0.2 }}> 77 - <div class="max-h-48 overflow-y-auto rounded-xl bg-black/50 p-4 font-mono text-xs"> 78 - <For each={props.logs} fallback={<p class="text-on-surface-variant">No log entries found</p>}> 79 - {(log) => ( 80 - <div class="mb-1 flex gap-3"> 81 - <span class="text-on-surface-variant">{log.timestamp?.split("T")[1]?.slice(0, 8) ?? "--:--:--"}</span> 82 - <span 83 - classList={{ 84 - "text-primary": log.level === "INFO", 85 - "text-yellow-400": log.level === "WARN", 86 - "text-red-400": log.level === "ERROR", 87 - }}> 88 - {log.level} 89 - </span> 90 - <span class="text-on-secondary-container">{log.message}</span> 91 - </div> 92 - )} 93 - </For> 94 - </div> 95 - </Motion.div> 96 - ); 97 - }
+4 -2
src/components/settings/SettingsPanel.test.tsx
··· 17 17 enabled: true, 18 18 modelName: "nomic-embed-text-v1.5", 19 19 dimensions: 768, 20 + modelSizeBytes: 1024 * 1024 * 384, 20 21 downloaded: true, 21 22 downloadActive: false, 22 23 }; ··· 110 111 expect(await screen.findByText("Data")).toBeInTheDocument(); 111 112 expect(await screen.findByText("Logs")).toBeInTheDocument(); 112 113 expect(await screen.findByText("About")).toBeInTheDocument(); 114 + expect(await screen.findByText(/384 MB on disk/i)).toBeInTheDocument(); 113 115 }); 114 116 115 117 it("displays cache size information", async () => { ··· 225 227 renderSettingsPanel(); 226 228 227 229 await screen.findByText("Settings"); 228 - const expandButton = await screen.findByRole("button", { name: /expand log viewer/i }); 230 + const expandButton = await screen.findByRole("button", { name: /expand logs/i }); 229 231 230 232 fireEvent.click(expandButton); 231 - expect(await screen.findByRole("button", { name: /collapse log viewer/i })).toBeInTheDocument(); 233 + expect(await screen.findByRole("button", { name: /collapse logs/i })).toBeInTheDocument(); 232 234 }); 233 235 234 236 it("copies logs to clipboard", async () => {
+1
src/lib/api/search.ts
··· 47 47 enabled: boolean; 48 48 modelName: string; 49 49 dimensions: number; 50 + modelSizeBytes?: number | null; 50 51 downloaded: boolean; 51 52 downloadActive: boolean; 52 53 downloadProgress?: number | null;
+35
src/lib/feeds.test.ts
··· 70 70 expect(applyFeedPreferences([reply], createPref({ hideRepliesByLikeCount: 5 }))).toEqual([]); 71 71 }); 72 72 73 + it("treats zero as an active reply-like threshold", () => { 74 + const reply = createFeedItem({ 75 + post: { ...createFeedItem().post, likeCount: 0, uri: "at://did:plc:alice/app.bsky.feed.post/zero" }, 76 + reply: { 77 + parent: { $type: "app.bsky.feed.defs#postView", ...createFeedItem().post }, 78 + root: { $type: "app.bsky.feed.defs#postView", ...createFeedItem().post }, 79 + }, 80 + }); 81 + 82 + expect(applyFeedPreferences([reply], createPref({ hideRepliesByLikeCount: 0 }))).toEqual([reply]); 83 + expect(applyFeedPreferences([reply], createPref({ hideRepliesByLikeCount: 1 }))).toEqual([]); 84 + }); 85 + 86 + it("detects replies from the embedded record and respects the unfollowed reply filter", () => { 87 + const base = createFeedItem(); 88 + const unfollowedReply = createFeedItem({ 89 + post: { 90 + ...base.post, 91 + author: { did: "did:plc:bob", handle: "bob.test", viewer: { following: null } }, 92 + record: { 93 + createdAt: "2026-03-28T12:00:00.000Z", 94 + reply: { parent: { uri: "at://did:plc:alice/app.bsky.feed.post/1" } }, 95 + text: "reply from unfollowed author", 96 + }, 97 + uri: "at://did:plc:bob/app.bsky.feed.post/2", 98 + }, 99 + }); 100 + 101 + expect(applyFeedPreferences([unfollowedReply], createPref({ hideReplies: true }))).toEqual([]); 102 + expect(applyFeedPreferences([unfollowedReply], createPref({ hideRepliesByUnfollowed: true }))).toEqual([]); 103 + expect(applyFeedPreferences([unfollowedReply], createPref({ hideRepliesByUnfollowed: false }))).toEqual([ 104 + unfollowedReply, 105 + ]); 106 + }); 107 + 73 108 it("builds feed commands per saved feed type", () => { 74 109 const timeline: SavedFeedItem = { id: "following", pinned: true, type: "timeline", value: "following" }; 75 110 const feed: SavedFeedItem = {
+19 -2
src/lib/feeds.ts
··· 186 186 } 187 187 188 188 export function isReplyItem(item: FeedViewPost) { 189 - return !!item.reply; 189 + if (item.reply) { 190 + return true; 191 + } 192 + 193 + const record = asRecord(item.post.record); 194 + return !!asRecord(record?.reply); 195 + } 196 + 197 + export function isReplyByUnfollowed(item: FeedViewPost) { 198 + return isReplyItem(item) && !item.post.author.viewer?.following; 190 199 } 191 200 192 201 export function getRootRef(item: FeedViewPost) { ··· 263 272 return false; 264 273 } 265 274 275 + if (pref.hideRepliesByUnfollowed && isReplyByUnfollowed(item)) { 276 + return false; 277 + } 278 + 266 279 if (pref.hideQuotePosts && isQuoteEmbed(item.post.embed)) { 267 280 return false; 268 281 } 269 282 270 - if (pref.hideRepliesByLikeCount && isReplyItem(item) && (item.post.likeCount ?? 0) < pref.hideRepliesByLikeCount) { 283 + if ( 284 + pref.hideRepliesByLikeCount !== null 285 + && isReplyItem(item) 286 + && (item.post.likeCount ?? 0) < pref.hideRepliesByLikeCount 287 + ) { 271 288 return false; 272 289 } 273 290
+25 -1
src/lib/utils/text.ts
··· 1 - import type { Maybe } from "$/lib/types"; 1 + import type { LogEntry, Maybe } from "$/lib/types"; 2 2 3 3 export function escapeForRegex(value: string) { 4 4 return value.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); ··· 49 49 const i = Math.floor(Math.log(bytes) / Math.log(k)); 50 50 return `${Number.parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; 51 51 } 52 + 53 + export function formatLogTimestamp(timestamp: string | null) { 54 + if (!timestamp) { 55 + return "--"; 56 + } 57 + 58 + const parsed = new Date(timestamp); 59 + if (Number.isNaN(parsed.getTime())) { 60 + return timestamp; 61 + } 62 + 63 + return parsed.toLocaleString(undefined, { 64 + day: "2-digit", 65 + hour: "2-digit", 66 + minute: "2-digit", 67 + month: "short", 68 + second: "2-digit", 69 + }); 70 + } 71 + 72 + export function formatLogCopyLine(log: LogEntry) { 73 + const prefix = [formatLogTimestamp(log.timestamp), log.level, log.target ?? "app"].join(" "); 74 + return `${prefix} ${log.message}`; 75 + }