Search node aliases for mentions

Mentioning someone starts from their alias, but aliases are
self-declared and unrelated nodes often share one. Results say who is
followed or is you, so the composer can tell them apart.

Co-Authored-By: Rūdolfs Ošiņš <rudolfs@osins.org>
This commit is contained in:
Brandon Oxendine
2026-10-05 17:29:02 +02:00
committed by Rūdolfs Ošiņš
co-authored by Rūdolfs Ošiņš
parent b461f0bce2
commit bee84e9d40
6 changed files with 213 additions and 1 deletions
@@ -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<AppState>) -> Config {
@@ -13,3 +16,11 @@ pub fn config(ctx: tauri::State<AppState>) -> Config {
pub fn alias(ctx: tauri::State<AppState>, nid: NodeId) -> Option<radicle::node::Alias> {
ctx.alias(nid)
}
#[tauri::command]
pub async fn search_aliases(
ctx: tauri::State<'_, AppState>,
query: Option<String>,
) -> Result<Vec<AliasSuggestion>, Error> {
blocking(ctx, move |ctx| Ok(ctx.search_aliases(query))).await
}
+1
View File
@@ -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,
@@ -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;
};
+19
View File
@@ -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<String>", optional)]
pub alias: Option<Alias>,
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<A> {
+161 -1
View File
@@ -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<String>) -> Vec<AliasSuggestion> {
let p = self.profile();
let query = query.unwrap_or_default();
let query = query.trim();
let policies = p.policies().ok();
let mut candidates: BTreeMap<NodeId, Option<Alias>> = 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::<Vec<_>>();
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<NodeId, Option<Alias>>,
found: BTreeMap<Alias, BTreeSet<NodeId>>,
) {
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<u8> {
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::<Vec<_>>()
};
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());
}
}
+13
View File
@@ -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<String>,
}
async fn search_aliases_handler(
Ctx(ctx): Ctx,
Json(SearchAliasesBody { query }): Json<SearchAliasesBody>,
) -> impl IntoResponse {
Ok::<_, Error>(Json(ctx.search_aliases(query)))
}
#[derive(Serialize, Deserialize)]
pub struct Options {
show: Show,