mirror of
https://seed.radicle.dev/rad:z4D5UCArafTzTQpDZNQRuqswh3ury.git
synced 2026-10-18 19:10:45 +08:00
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:
co-authored by
Rūdolfs Ošiņš
parent
b461f0bce2
commit
bee84e9d40
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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> {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user