diff --git a/crates/radicle-tauri/src/commands/profile.rs b/crates/radicle-tauri/src/commands/profile.rs index 6a70de63a..3f988711b 100644 --- a/crates/radicle-tauri/src/commands/profile.rs +++ b/crates/radicle-tauri/src/commands/profile.rs @@ -1,8 +1,11 @@ use radicle::node::NodeId; +use radicle_types::cobs::AliasSuggestion; use radicle_types::config::Config; +use radicle_types::error::Error; use radicle_types::traits::Profile; use crate::AppState; +use crate::commands::blocking; #[tauri::command] pub fn config(ctx: tauri::State) -> Config { @@ -13,3 +16,11 @@ pub fn config(ctx: tauri::State) -> Config { pub fn alias(ctx: tauri::State, nid: NodeId) -> Option { ctx.alias(nid) } + +#[tauri::command] +pub async fn search_aliases( + ctx: tauri::State<'_, AppState>, + query: Option, +) -> Result, Error> { + blocking(ctx, move |ctx| Ok(ctx.search_aliases(query))).await +} diff --git a/crates/radicle-tauri/src/lib.rs b/crates/radicle-tauri/src/lib.rs index 2d9e9a44f..a8851357c 100644 --- a/crates/radicle-tauri/src/lib.rs +++ b/crates/radicle-tauri/src/lib.rs @@ -75,6 +75,7 @@ pub fn run() { inbox::list_notifications, profile::alias, profile::config, + profile::search_aliases, repo::clean, repo::diff_stats, repo::list_commits, diff --git a/crates/radicle-types/bindings/cob/AliasSuggestion.ts b/crates/radicle-types/bindings/cob/AliasSuggestion.ts new file mode 100644 index 000000000..f47ad0247 --- /dev/null +++ b/crates/radicle-types/bindings/cob/AliasSuggestion.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AliasSuggestion = { + did: string; + alias?: string; + followed: boolean; + isSelf: boolean; +}; diff --git a/crates/radicle-types/src/cobs.rs b/crates/radicle-types/src/cobs.rs index c94734fa5..04b4176e3 100644 --- a/crates/radicle-types/src/cobs.rs +++ b/crates/radicle-types/src/cobs.rs @@ -38,6 +38,21 @@ pub enum CacheEvent { }, } +#[derive(Debug, Clone, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +#[ts(export_to = "cob/")] +pub struct AliasSuggestion { + #[ts(as = "String")] + pub did: identity::Did, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(as = "Option", optional)] + pub alias: Option, + pub followed: bool, + #[serde(rename = "isSelf")] + pub is_self: bool, +} + #[derive(Debug, Clone, Serialize, TS, Deserialize, PartialEq, Eq, PartialOrd, Ord)] #[serde(rename_all = "camelCase")] #[ts(export)] @@ -61,6 +76,10 @@ impl Author { pub fn did(&self) -> &identity::Did { &self.did } + + pub fn alias(&self) -> Option<&Alias> { + self.alias.as_ref() + } } pub trait FromRadicleAction { diff --git a/crates/radicle-types/src/traits.rs b/crates/radicle-types/src/traits.rs index 59bce1e62..eff99ee4d 100644 --- a/crates/radicle-types/src/traits.rs +++ b/crates/radicle-types/src/traits.rs @@ -1,8 +1,15 @@ -use radicle::node::{AliasStore, NodeId}; +use std::collections::{BTreeMap, BTreeSet}; +use std::str::FromStr; +use radicle::node; +use radicle::node::{Alias, AliasStore, NodeId}; + +use crate::cobs::AliasSuggestion; use crate::config::Config; use crate::error::Error; +const ALIAS_SEARCH_LIMIT: usize = 50; + pub mod cobs; pub mod identity; pub mod inbox; @@ -49,6 +56,110 @@ pub trait Profile { aliases.alias(&nid) } + + fn search_aliases(&self, query: Option) -> Vec { + let p = self.profile(); + let query = query.unwrap_or_default(); + let query = query.trim(); + let policies = p.policies().ok(); + + let mut candidates: BTreeMap> = BTreeMap::new(); + let mut followed = BTreeSet::new(); + // The `following` table holds blocked nodes alongside followed ones, so + // they have to be filtered out of every source, not just this one. + let mut blocked = BTreeSet::new(); + if let Some(policies) = policies.as_ref() + && let Ok(follow_policies) = policies.follow_policies() + { + for policy in follow_policies.flatten() { + match policy.policy { + node::policy::Policy::Allow => { + followed.insert(policy.nid); + candidates.insert(policy.nid, policy.alias); + } + node::policy::Policy::Block => { + blocked.insert(policy.nid); + } + } + } + } + + candidates.insert(p.public_key, Some(p.config.node.alias.clone())); + + // `Aliases::reverse_lookup` merges its two stores with `BTreeMap::extend`, + // which drops node ids when both stores hold the same alias. Query the + // stores separately so every matching node id survives. + if let Ok(alias) = Alias::from_str(query) { + if let Some(policies) = policies.as_ref() { + extend_candidates(&mut candidates, policies.reverse_lookup(&alias)); + } + if let Ok(db) = p.database() { + extend_candidates(&mut candidates, db.reverse_lookup(&alias)); + } + } + + let needle = query.to_lowercase(); + let mut matches = candidates + .into_iter() + .filter(|(nid, _)| !blocked.contains(nid)) + .filter_map(|(nid, alias)| { + let rank = rank_alias(alias.as_ref(), &needle, followed.contains(&nid))?; + + Some((rank, alias.clone(), nid)) + }) + .collect::>(); + + matches.sort_by(|(a_rank, a_alias, a_nid), (b_rank, b_alias, b_nid)| { + a_rank + .cmp(b_rank) + .then_with(|| a_alias.cmp(b_alias)) + .then_with(|| a_nid.cmp(b_nid)) + }); + matches.truncate(ALIAS_SEARCH_LIMIT); + + matches + .into_iter() + .map(|(_, alias, nid)| AliasSuggestion { + did: nid.into(), + alias, + followed: followed.contains(&nid), + is_self: nid == p.public_key, + }) + .collect() + } +} + +fn extend_candidates( + candidates: &mut BTreeMap>, + found: BTreeMap>, +) { + for (alias, nids) in found { + for nid in nids { + candidates.insert(nid, Some(alias.clone())); + } + } +} + +fn rank_alias(alias: Option<&Alias>, needle: &str, followed: bool) -> Option { + let position = match alias { + None if needle.is_empty() => 3, + None => return None, + Some(alias) => { + let alias = alias.to_string().to_lowercase(); + + if alias == needle { + 0 + } else if alias.starts_with(needle) { + 1 + } else if alias.contains(needle) { + 2 + } else { + return None; + } + } + }; + + Some(position * 2 + u8::from(!followed)) } #[cfg(test)] @@ -62,6 +173,8 @@ mod test { use crate::config::Config; use crate::{AppState, Profile, test}; + use super::rank_alias; + #[test] fn config() { let tmp = tempfile::tempdir().unwrap(); @@ -80,4 +193,51 @@ mod test { } ) } + + fn node(seed: u8) -> radicle::node::NodeId { + *SigningKey::from_seed(Seed::new([seed; 32])).public_key() + } + + #[test] + fn search_aliases() { + let tmp = tempfile::tempdir().unwrap(); + let profile = test::profile(tmp.path(), [0xff; 32]); + let mut policies = profile.policies_mut().unwrap(); + let alice = Alias::from_str("alice").unwrap(); + policies.follow(&node(1), Some(&alice)).unwrap(); + policies + .follow(&node(2), Some(&Alias::from_str("alicia").unwrap())) + .unwrap(); + policies.follow(&node(3), Some(&alice)).unwrap(); + policies + .set_follow_policy(&node(3), radicle::node::policy::Policy::Block) + .unwrap(); + drop(policies); + let state = AppState { profile }; + let search = |query: &str| { + Profile::search_aliases(&state, Some(query.to_string())) + .into_iter() + .map(|suggestion| suggestion.did.as_key().to_owned()) + .collect::>() + }; + + assert_eq!(search("alice"), vec![node(1)]); + assert_eq!(search("ali"), vec![node(1), node(2)]); + assert_eq!(search(""), vec![node(1), node(2), state.profile.public_key]); + } + + #[test] + fn rank_alias_orders_matches() { + let alias = |name: &str| Alias::from_str(name).unwrap(); + let rank = |name: &str, needle: &str, followed: bool| { + rank_alias(Some(&alias(name)), needle, followed) + }; + + assert!(rank("alice", "alice", false) < rank("alice", "ali", false)); + assert!(rank("alice", "ali", false) < rank("malice", "ali", false)); + assert!(rank("alice", "ali", true) < rank("alice", "ali", false)); + assert_eq!(rank("bob", "ali", true), None); + assert_eq!(rank_alias(None, "ali", true), None); + assert!(rank_alias(None, "", true).is_some()); + } } diff --git a/crates/test-http-api/src/api.rs b/crates/test-http-api/src/api.rs index c46bedce2..c2aa60be5 100644 --- a/crates/test-http-api/src/api.rs +++ b/crates/test-http-api/src/api.rs @@ -145,6 +145,7 @@ pub fn router(shared: Shared) -> Router { .route("/startup", post(startup_handler)) .route("/config", post(config_handler)) .route("/alias", post(alias_handler)) + .route("/search_aliases", post(search_aliases_handler)) .route("/authenticate", post(auth_handler)) .route("/check_radicle_cli", post(check_radicle_cli_handler)) .route("/node_running", post(node_running_handler)) @@ -287,6 +288,18 @@ async fn node_running_handler(Ctx(ctx): Ctx) -> impl IntoResponse { Ok::<_, Error>(Json(ctx.node_running())) } +#[derive(Deserialize)] +struct SearchAliasesBody { + pub query: Option, +} + +async fn search_aliases_handler( + Ctx(ctx): Ctx, + Json(SearchAliasesBody { query }): Json, +) -> impl IntoResponse { + Ok::<_, Error>(Json(ctx.search_aliases(query))) +} + #[derive(Serialize, Deserialize)] pub struct Options { show: Show,