diff --git a/android/src/app.rs b/android/src/app.rs index 46248b6..52ea620 100644 --- a/android/src/app.rs +++ b/android/src/app.rs @@ -273,6 +273,23 @@ impl SleekApp { } } + /// Debounced Bluesky handle typeahead while the connect form is visible. + fn poll_handle_typeahead(&mut self, ctx: &egui::Context) { + if self.state.connection.is_live() || self.state.connect_mode != crate::state::ConnectMode::Bluesky + { + return; + } + self.state + .handle_typeahead + .sync_from_input(&self.state.form_handle); + if let Some((request_id, query)) = self.state.handle_typeahead.take_ready_fetch() { + self.net.send(NetCmd::SearchHandles { request_id, query }); + } + if self.state.handle_typeahead.needs_repaint() { + ctx.request_repaint_after(std::time::Duration::from_millis(50)); + } + } + fn poll_net(&mut self, ctx: &egui::Context) { for ev in self.net.poll() { self.handle_net_event(ev); @@ -538,6 +555,20 @@ impl SleekApp { _ => {} } } + NetEvent::HandleSuggestions { + request_id, + query, + actors, + error, + } => { + if error.is_some() { + self.state.handle_typeahead.apply_failed(request_id); + } else { + self.state + .handle_typeahead + .apply_results(request_id, query, actors); + } + } NetEvent::Sdk(event) => self.handle_sdk_event(event), } } @@ -1469,6 +1500,7 @@ impl SleekApp { return; } self.state.error = None; + self.state.handle_typeahead.dismiss(); self.state.awaiting_oauth = true; self.state.connection = ConnectionState::Connecting; #[cfg(target_os = "android")] @@ -1957,6 +1989,7 @@ impl eframe::App for SleekApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.apply_fit_viewport(ctx); self.poll_auto_reconnect(ctx); + self.poll_handle_typeahead(ctx); self.poll_net(ctx); self.poll_file_pick(ctx); self.poll_oauth_deep_link(ctx); diff --git a/android/src/bsky.rs b/android/src/bsky.rs new file mode 100644 index 0000000..e6aa716 --- /dev/null +++ b/android/src/bsky.rs @@ -0,0 +1,416 @@ +//! Public Bluesky AppView helpers (no auth). +//! +//! Used for Bluesky login handle typeahead via +//! `app.bsky.actor.searchActorsTypeahead` — same endpoint as common AT Proto +//! login autocomplete examples (e.g. Atmosphere Conf `ActorAutocomplete`). + +use anyhow::{Context, Result, bail}; +use serde::Deserialize; +use std::time::Duration; + +/// Public AppView base (unauthenticated typeahead / search). +pub const PUBLIC_APPVIEW: &str = "https://public.api.bsky.app"; + +/// Debounce before firing a typeahead request while the user types. +pub const TYPEAHEAD_DEBOUNCE: Duration = Duration::from_millis(300); + +/// Minimum normalized query length before we hit the network. +pub const TYPEAHEAD_MIN_CHARS: usize = 2; + +/// Max suggestions requested from AppView. +pub const TYPEAHEAD_LIMIT: u32 = 8; + +/// One actor from `searchActorsTypeahead`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HandleSuggestion { + pub did: String, + pub handle: String, + pub display_name: Option, +} + +#[derive(Debug, Deserialize)] +struct TypeaheadResponse { + #[serde(default)] + actors: Vec, +} + +#[derive(Debug, Deserialize)] +struct TypeaheadActor { + did: String, + handle: String, + #[serde(default, rename = "displayName")] + display_name: Option, +} + +/// Trim whitespace and a leading `@` (users often type `@alice…`). +pub fn normalize_handle_query(raw: &str) -> String { + raw.trim().trim_start_matches('@').trim().to_string() +} + +/// Whether `raw` is long enough to justify a typeahead fetch. +pub fn should_typeahead(raw: &str) -> bool { + normalize_handle_query(raw).chars().count() >= TYPEAHEAD_MIN_CHARS +} + +/// Prefix search for Bluesky handles / display names. Does not require auth. +pub async fn search_actors_typeahead( + query: &str, + limit: u32, +) -> Result> { + let q = normalize_handle_query(query); + if q.chars().count() < TYPEAHEAD_MIN_CHARS { + return Ok(Vec::new()); + } + let limit = limit.clamp(1, 100); + let url = format!( + "{}/xrpc/app.bsky.actor.searchActorsTypeahead?q={}&limit={}", + PUBLIC_APPVIEW, + urlencoding::encode(&q), + limit + ); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(8)) + .build() + .context("reqwest client")?; + let resp = client.get(&url).send().await.context("typeahead request")?; + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + bail!("typeahead HTTP {status}: {body}"); + } + let parsed: TypeaheadResponse = resp.json().await.context("typeahead json")?; + Ok(parsed + .actors + .into_iter() + .filter(|a| !a.handle.is_empty() && !a.did.is_empty()) + .map(|a| HandleSuggestion { + did: a.did, + handle: a.handle, + display_name: a + .display_name + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()), + }) + .collect()) +} + +/// UI + debounce state for the Bluesky handle field on the connect screen. +#[derive(Debug, Clone)] +pub struct HandleTypeahead { + pub suggestions: Vec, + /// Dropdown visible (false after pick / Escape / empty query). + pub open: bool, + pub loading: bool, + /// Keyboard highlight into `suggestions` (`None` = none). + pub selected: Option, + /// Normalized query last observed from the text field. + pub last_query: String, + /// Normalized query waiting for debounce / in flight. + pub pending_query: String, + /// When to send the next fetch (`None` = nothing scheduled). + pub fetch_at: Option, + /// Monotonic id so stale responses are ignored. + pub request_id: u64, + /// Request id currently in flight (`None` if idle). + pub inflight_id: Option, +} + +impl Default for HandleTypeahead { + fn default() -> Self { + Self { + suggestions: Vec::new(), + open: false, + loading: false, + selected: None, + last_query: String::new(), + pending_query: String::new(), + fetch_at: None, + request_id: 0, + inflight_id: None, + } + } +} + +impl HandleTypeahead { + /// Sync debounce state from the current handle field value. + pub fn sync_from_input(&mut self, raw: &str) { + let q = normalize_handle_query(raw); + if q == self.last_query { + return; + } + self.last_query = q.clone(); + self.selected = None; + + if !should_typeahead(&q) { + self.pending_query.clear(); + self.fetch_at = None; + self.suggestions.clear(); + self.open = false; + self.loading = self.inflight_id.is_some(); + return; + } + + // Exact match already listed — keep suggestions but don't refetch noise. + if self + .suggestions + .iter() + .any(|s| s.handle.eq_ignore_ascii_case(&q)) + && self.pending_query.eq_ignore_ascii_case(&q) + { + self.open = true; + return; + } + + self.pending_query = q; + self.fetch_at = Some(std::time::Instant::now() + TYPEAHEAD_DEBOUNCE); + self.open = true; + } + + /// If debounce elapsed, return `(request_id, query)` to fetch. + pub fn take_ready_fetch(&mut self) -> Option<(u64, String)> { + let at = self.fetch_at?; + if std::time::Instant::now() < at { + return None; + } + self.fetch_at = None; + let q = self.pending_query.clone(); + if !should_typeahead(&q) { + return None; + } + self.request_id = self.request_id.wrapping_add(1); + let id = self.request_id; + self.inflight_id = Some(id); + self.loading = true; + Some((id, q)) + } + + /// Apply a completed typeahead response (ignore stale request ids). + pub fn apply_results( + &mut self, + request_id: u64, + query: String, + actors: Vec, + ) { + if self.inflight_id != Some(request_id) { + return; + } + self.inflight_id = None; + self.loading = self.fetch_at.is_some(); + // Drop results if the user already moved on past this query. + let current = normalize_handle_query(&self.last_query); + if !current.is_empty() + && !current.eq_ignore_ascii_case(&query) + && !self.pending_query.eq_ignore_ascii_case(&query) + { + return; + } + self.suggestions = actors; + self.selected = if self.suggestions.is_empty() { + None + } else { + Some(0) + }; + self.open = !self.suggestions.is_empty() && should_typeahead(&self.last_query); + } + + pub fn apply_failed(&mut self, request_id: u64) { + if self.inflight_id != Some(request_id) { + return; + } + self.inflight_id = None; + self.loading = self.fetch_at.is_some(); + // Keep prior suggestions; just stop the spinner. + } + + pub fn move_selection(&mut self, delta: i32) { + let n = self.suggestions.len(); + if n == 0 { + self.selected = None; + return; + } + let cur = self.selected.unwrap_or(0) as i32; + let next = (cur + delta).rem_euclid(n as i32) as usize; + self.selected = Some(next); + self.open = true; + } + + /// Fill `handle` from the highlighted (or first) suggestion and close. + pub fn accept_selected(&mut self, handle_out: &mut String) -> bool { + let idx = self.selected.or(if self.suggestions.is_empty() { + None + } else { + Some(0) + }); + let Some(i) = idx else { + return false; + }; + let Some(actor) = self.suggestions.get(i) else { + return false; + }; + *handle_out = actor.handle.clone(); + self.last_query = normalize_handle_query(handle_out); + self.pending_query = self.last_query.clone(); + self.fetch_at = None; + self.open = false; + self.selected = None; + self.suggestions.clear(); + true + } + + pub fn pick(&mut self, handle: &str, handle_out: &mut String) { + *handle_out = handle.to_string(); + self.last_query = normalize_handle_query(handle_out); + self.pending_query = self.last_query.clone(); + self.fetch_at = None; + self.open = false; + self.selected = None; + self.suggestions.clear(); + } + + pub fn dismiss(&mut self) { + self.open = false; + self.selected = None; + } + + pub fn needs_repaint(&self) -> bool { + self.fetch_at.is_some() || self.inflight_id.is_some() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_strips_at_and_whitespace() { + assert_eq!(normalize_handle_query(" @Alice.bsky.social "), "Alice.bsky.social"); + assert_eq!(normalize_handle_query("bob"), "bob"); + assert_eq!(normalize_handle_query(""), ""); + } + + #[test] + fn should_typeahead_requires_min_chars() { + assert!(!should_typeahead("")); + assert!(!should_typeahead("@a")); + assert!(!should_typeahead(" a ")); + assert!(should_typeahead("al")); + assert!(should_typeahead("@al")); + assert!(should_typeahead(" @alice ")); + } + + #[test] + fn sync_schedules_debounce_and_clears_short_query() { + let mut t = HandleTypeahead::default(); + t.sync_from_input("a"); + assert!(t.fetch_at.is_none()); + assert!(!t.open); + + t.sync_from_input("al"); + assert!(t.fetch_at.is_some()); + assert!(t.open); + assert_eq!(t.pending_query, "al"); + + t.sync_from_input("@"); + assert!(t.fetch_at.is_none()); + assert!(!t.open); + assert!(t.suggestions.is_empty()); + } + + #[test] + fn take_ready_fetch_increments_request_id() { + let mut t = HandleTypeahead::default(); + t.sync_from_input("alice"); + t.fetch_at = Some(std::time::Instant::now() - Duration::from_millis(1)); + let (id1, q) = t.take_ready_fetch().expect("ready"); + assert_eq!(q, "alice"); + assert_eq!(id1, 1); + assert_eq!(t.inflight_id, Some(1)); + assert!(t.loading); + assert!(t.take_ready_fetch().is_none()); + } + + #[test] + fn apply_results_ignores_stale_and_accepts_current() { + let mut t = HandleTypeahead::default(); + t.sync_from_input("alice"); + t.fetch_at = Some(std::time::Instant::now() - Duration::from_millis(1)); + let (id, _) = t.take_ready_fetch().unwrap(); + + t.apply_results( + id.wrapping_sub(1), + "alice".into(), + vec![HandleSuggestion { + did: "did:plc:x".into(), + handle: "alice.bsky.social".into(), + display_name: Some("Alice".into()), + }], + ); + assert!(t.suggestions.is_empty()); + assert!(t.loading); + + t.apply_results( + id, + "alice".into(), + vec![HandleSuggestion { + did: "did:plc:x".into(), + handle: "alice.bsky.social".into(), + display_name: Some("Alice".into()), + }], + ); + assert_eq!(t.suggestions.len(), 1); + assert_eq!(t.selected, Some(0)); + assert!(t.open); + assert!(!t.loading); + } + + #[test] + fn accept_selected_fills_handle_and_closes() { + let mut t = HandleTypeahead { + suggestions: vec![ + HandleSuggestion { + did: "did:1".into(), + handle: "one.bsky.social".into(), + display_name: None, + }, + HandleSuggestion { + did: "did:2".into(), + handle: "two.bsky.social".into(), + display_name: Some("Two".into()), + }, + ], + open: true, + selected: Some(1), + ..Default::default() + }; + let mut handle = String::from("tw"); + assert!(t.accept_selected(&mut handle)); + assert_eq!(handle, "two.bsky.social"); + assert!(!t.open); + assert!(t.suggestions.is_empty()); + } + + #[test] + fn move_selection_wraps() { + let mut t = HandleTypeahead { + suggestions: vec![ + HandleSuggestion { + did: "did:1".into(), + handle: "a.bsky.social".into(), + display_name: None, + }, + HandleSuggestion { + did: "did:2".into(), + handle: "b.bsky.social".into(), + display_name: None, + }, + ], + selected: Some(0), + open: true, + ..Default::default() + }; + t.move_selection(-1); + assert_eq!(t.selected, Some(1)); + t.move_selection(1); + assert_eq!(t.selected, Some(0)); + } +} diff --git a/android/src/lib.rs b/android/src/lib.rs index f301d7b..2ff99d0 100644 --- a/android/src/lib.rs +++ b/android/src/lib.rs @@ -4,6 +4,7 @@ mod app; mod auth; mod av; mod av_media; +mod bsky; mod clipboard; mod net; mod preview; diff --git a/android/src/net.rs b/android/src/net.rs index 595f02a..d9de1ba 100644 --- a/android/src/net.rs +++ b/android/src/net.rs @@ -11,6 +11,7 @@ use freeq_sdk::event::Event; use tokio::sync::mpsc; use crate::auth::{self, AuthTokens}; +use crate::bsky::{self, HandleSuggestion}; use crate::state::{prefer_websocket, websocket_url_for}; use crate::av_media::{AvMediaConfig, AvMediaSession}; @@ -134,6 +135,12 @@ pub enum NetCmd { }, /// TAGMSG `+draft/delete` — soft-delete a message. DeleteMessage { target: String, msgid: String }, + /// Bluesky handle typeahead for the connect-screen login field. + SearchHandles { + /// Monotonic id so the UI can ignore stale responses. + request_id: u64, + query: String, + }, Quit, } @@ -194,6 +201,14 @@ pub enum NetEvent { /// True when a real mic is feeding outbound Opus (false = silence/listen-only). has_mic: bool, }, + /// Bluesky handle typeahead results (or empty on soft failure). + HandleSuggestions { + request_id: u64, + query: String, + actors: Vec, + /// `None` on success; set when the AppView request failed. + error: Option, + }, } /// Payload after a successful image upload + PRIVMSG. @@ -837,6 +852,30 @@ async fn apply_cmd( NetCmd::AvSpeakerDevice { id } => { media.set_speaker_device(id); } + NetCmd::SearchHandles { request_id, query } => { + let tx = event_tx.clone(); + tokio::spawn(async move { + match bsky::search_actors_typeahead(&query, bsky::TYPEAHEAD_LIMIT).await { + Ok(actors) => { + let _ = tx.send(NetEvent::HandleSuggestions { + request_id, + query, + actors, + error: None, + }); + } + Err(e) => { + log::debug!("handle typeahead {query}: {e}"); + let _ = tx.send(NetEvent::HandleSuggestions { + request_id, + query, + actors: Vec::new(), + error: Some(e.to_string()), + }); + } + } + }); + } NetCmd::Quit => { media.stop().await; if let Some(h) = handle.take() { diff --git a/android/src/state.rs b/android/src/state.rs index 22265d5..e69f47d 100644 --- a/android/src/state.rs +++ b/android/src/state.rs @@ -984,6 +984,8 @@ pub struct AppState { pub form_server: String, pub form_tls: bool, pub form_websocket: bool, + /// Bluesky handle typeahead (connect screen). + pub handle_typeahead: crate::bsky::HandleTypeahead, /// Guest session was loaded from disk — consumed once for launch auto-connect. pub auto_guest_connect: bool, @@ -1121,6 +1123,7 @@ impl AppState { form_server: server, form_tls: true, form_websocket: use_ws, + handle_typeahead: crate::bsky::HandleTypeahead::default(), auto_guest_connect: false, local_call: None, av_pref_muted: prefs.av_pref_muted, diff --git a/android/src/ui/connect.rs b/android/src/ui/connect.rs index 8a32a4e..a816b9d 100644 --- a/android/src/ui/connect.rs +++ b/android/src/ui/connect.rs @@ -1,13 +1,13 @@ //! Connect screen — Bluesky OAuth (auth broker) + guest, freeq-android inspired. -use eframe::egui::{self, RichText}; +use eframe::egui::{self, Key, RichText, Vec2}; use vidya::{ body, button, checkbox, destructive_button, dim_label, primary_button, text_field_singleline, title_2, Theme, }; use crate::state::{AppState, ConnectMode, ConnectionState}; -use crate::ui::widgets::card; +use crate::ui::widgets::{avatar_circle, card}; pub fn connect_screen(ui: &mut egui::Ui, th: &Theme, state: &mut AppState) -> ConnectAction { let mut action = ConnectAction::None; @@ -30,7 +30,7 @@ pub fn connect_screen(ui: &mut egui::Ui, th: &Theme, state: &mut AppState) -> Co body(ui, th, "Bluesky handle"); ui.add_space(sp.xs); - let _ = text_field_singleline(ui, th, &mut state.form_handle); + handle_field(ui, th, state, loading); ui.add_space(sp.md); if let Some(err) = &state.error { @@ -169,6 +169,7 @@ pub fn connect_screen(ui: &mut egui::Ui, th: &Theme, state: &mut AppState) -> Co if button(ui, th, "Continue as guest").clicked() { state.connect_mode = ConnectMode::Guest; state.error = None; + state.handle_typeahead.dismiss(); } } ConnectMode::Guest => { @@ -183,6 +184,149 @@ pub fn connect_screen(ui: &mut egui::Ui, th: &Theme, state: &mut AppState) -> Co action } +/// Handle text field + AppView typeahead suggestions (debounced in `App`). +fn handle_field(ui: &mut egui::Ui, th: &Theme, state: &mut AppState, login_loading: bool) { + let sp = &th.spacing; + let p = &th.palette; + let w = ui.available_width().max(1.0); + + let resp = ui.add( + egui::TextEdit::singleline(&mut state.form_handle) + .margin(th.text_edit_margin()) + .desired_width(w) + .min_size(Vec2::new(0.0, sp.control_height)) + .hint_text("you.bsky.social") + .interactive(!login_loading), + ); + + state.handle_typeahead.sync_from_input(&state.form_handle); + + let typeahead_open = state.handle_typeahead.open + && (!state.handle_typeahead.suggestions.is_empty() || state.handle_typeahead.loading); + + if resp.has_focus() && typeahead_open { + let mut accept = false; + let mut dismiss = false; + let mut down = false; + let mut up = false; + ui.input(|i| { + if i.key_pressed(Key::ArrowDown) { + down = true; + } + if i.key_pressed(Key::ArrowUp) { + up = true; + } + if i.key_pressed(Key::Tab) || i.key_pressed(Key::Enter) { + // Enter also submits login when no highlight — only accept if open+selected. + if state.handle_typeahead.selected.is_some() + || !state.handle_typeahead.suggestions.is_empty() + { + accept = true; + } + } + if i.key_pressed(Key::Escape) { + dismiss = true; + } + }); + if down { + state.handle_typeahead.move_selection(1); + ui.ctx() + .input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::ArrowDown)); + } + if up { + state.handle_typeahead.move_selection(-1); + ui.ctx() + .input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::ArrowUp)); + } + if dismiss { + state.handle_typeahead.dismiss(); + ui.ctx() + .input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape)); + } + if accept && state.handle_typeahead.accept_selected(&mut state.form_handle) { + ui.ctx() + .input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Tab)); + ui.ctx() + .input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Enter)); + } + } + + if !typeahead_open { + return; + } + + ui.add_space(sp.xs); + let frame = egui::Frame::new() + .fill(p.popover_bg) + .stroke(egui::Stroke::new(1.0_f32, p.border_soft)) + .corner_radius(sp.radius_md) + .inner_margin(egui::Margin::symmetric(sp.sm as i8, sp.xs as i8)); + frame.show(ui, |ui| { + ui.set_min_width(w); + if state.handle_typeahead.loading && state.handle_typeahead.suggestions.is_empty() { + dim_label(ui, th, "Searching handles…"); + return; + } + let suggestions = state.handle_typeahead.suggestions.clone(); + let selected = state.handle_typeahead.selected; + let mut picked: Option = None; + let mut hovered: Option = None; + for (i, actor) in suggestions.iter().enumerate() { + let is_sel = selected == Some(i); + let row_h = sp.control_height.max(40.0); + let (rect, row_resp) = + ui.allocate_exact_size(Vec2::new(ui.available_width(), row_h), egui::Sense::click()); + if is_sel || row_resp.hovered() { + ui.painter().rect_filled( + rect, + sp.radius_sm, + if is_sel { + p.accent.gamma_multiply(0.18) + } else { + p.button_hover + }, + ); + } + ui.scope_builder( + egui::UiBuilder::new() + .max_rect(rect.shrink2(Vec2::new(sp.sm, 4.0))) + .layout(egui::Layout::left_to_right(egui::Align::Center)), + |ui| { + ui.spacing_mut().item_spacing.x = sp.sm; + avatar_circle(ui, th, &actor.handle, 28.0); + ui.vertical(|ui| { + if let Some(name) = &actor.display_name { + ui.label( + RichText::new(name) + .size(th.type_scale.body) + .color(p.text) + .strong(), + ); + } + ui.label( + RichText::new(format!("@{}", actor.handle)) + .size(th.type_scale.caption) + .color(p.text_secondary), + ); + }); + }, + ); + if row_resp.clicked() { + picked = Some(actor.handle.clone()); + } + if row_resp.hovered() { + hovered = Some(i); + } + } + if let Some(i) = hovered { + state.handle_typeahead.selected = Some(i); + } + if let Some(handle) = picked { + state.handle_typeahead.pick(&handle, &mut state.form_handle); + } + }); +} + /// Banner + destructive clear when a previous Bluesky login is still cached. fn cached_account_row( ui: &mut egui::Ui,