remove "display name" from adding new friends

This commit is contained in:
2026-08-28 14:21:36 +08:00
parent a042945f5d
commit eb689210d4
17 changed files with 749 additions and 317 deletions
@@ -0,0 +1,13 @@
CREATE TABLE friends_with_optional_profiles (
id TEXT PRIMARY KEY NOT NULL,
display_name TEXT
);
-- Existing names were entered locally and cannot be distinguished from names
-- learned from a signed remote profile. Keep the relationship, then learn the
-- authoritative display name again from the remote.
INSERT INTO friends_with_optional_profiles (id)
SELECT id FROM friends;
DROP TABLE friends;
ALTER TABLE friends_with_optional_profiles RENAME TO friends;
+36 -15
View File
@@ -1,19 +1,30 @@
use crate::db::{self, AppDatabase};
use crate::user::User;
use serde::{Deserialize, Serialize};
use specta::Type;
use tauri::{AppHandle, State};
use tauri_specta::Event;
use crate::keypair::AppKeypair;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type, sqlx::FromRow)]
#[serde(rename_all = "camelCase")]
/// A configured public-key relationship with optional cached remote metadata.
/// The display name remains absent until learned from a signed remote profile.
pub struct Friend {
pub id: String,
pub display_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[serde(rename_all = "camelCase")]
pub struct FriendsChanged {
pub friends: Vec<User>,
pub friends: Vec<Friend>,
}
pub(crate) async fn all(database: &AppDatabase) -> Result<Vec<User>, sqlx::Error> {
sqlx::query_as::<_, User>(
"SELECT id, display_name FROM friends ORDER BY display_name COLLATE NOCASE, id",
pub(crate) async fn all(database: &AppDatabase) -> Result<Vec<Friend>, sqlx::Error> {
sqlx::query_as::<_, Friend>(
"SELECT id, display_name FROM friends \
ORDER BY display_name IS NULL, display_name COLLATE NOCASE, id",
)
.fetch_all(database.pool())
.await
@@ -35,7 +46,7 @@ async fn update_display_names(
let mut changed = false;
for profile in profiles {
let result = sqlx::query(
"UPDATE friends SET display_name = ?1 WHERE id = ?2 AND display_name != ?1",
"UPDATE friends SET display_name = ?1 WHERE id = ?2 AND display_name IS NOT ?1",
)
.bind(&profile.display_name)
.bind(&profile.id)
@@ -74,11 +85,21 @@ pub(crate) async fn apply_profile_sync(
pub async fn create_friend(
handle: AppHandle,
database: State<'_, AppDatabase>,
friend: User,
) -> Result<User, String> {
sqlx::query("INSERT INTO friends (id, display_name) VALUES (?1, ?2)")
keypair: State<'_, AppKeypair>,
id: String,
) -> Result<Friend, String> {
let id = id.trim().to_owned();
crate::user::validate_id(&id)?;
if id == keypair.public_key() {
return Err("You cannot add your own identification key.".to_owned());
}
let friend = Friend {
id,
display_name: None,
};
sqlx::query("INSERT INTO friends (id, display_name) VALUES (?1, NULL)")
.bind(&friend.id)
.bind(&friend.display_name)
.execute(database.pool())
.await
.map_err(db::command_error)?;
@@ -93,7 +114,7 @@ pub async fn create_friend(
pub async fn list_friends(
handle: AppHandle,
database: State<'_, AppDatabase>,
) -> Result<Vec<User>, String> {
) -> Result<Vec<Friend>, String> {
let friends = all(&database).await.map_err(db::command_error)?;
FriendsChanged {
@@ -110,8 +131,8 @@ pub async fn list_friends(
pub async fn get_friend(
database: State<'_, AppDatabase>,
id: String,
) -> Result<Option<User>, String> {
sqlx::query_as::<_, User>("SELECT id, display_name FROM friends WHERE id = ?1")
) -> Result<Option<Friend>, String> {
sqlx::query_as::<_, Friend>("SELECT id, display_name FROM friends WHERE id = ?1")
.bind(id)
.fetch_optional(database.pool())
.await
@@ -199,9 +220,9 @@ mod tests {
);
assert_eq!(
all(&database).await.unwrap(),
[User {
[Friend {
id: "friend-id".to_owned(),
display_name: "New".to_owned(),
display_name: Some("New".to_owned()),
}]
);
}
+1
View File
@@ -72,6 +72,7 @@ fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
keypair::get_public_key,
network::list_statuses,
network::list_friend_statuses,
network::resolve_friend_display_name,
images::pick_and_send_image,
images::send_image_bytes,
interactions::send_interaction,
+120 -30
View File
@@ -62,6 +62,7 @@ struct Connection {
remote: Remote,
sender: mpsc::Sender<String>,
interaction_sender: mpsc::Sender<InteractionRequest>,
profile_lookup_sender: mpsc::Sender<ProfileLookupRequest>,
task: tauri::async_runtime::JoinHandle<()>,
generation: u64,
}
@@ -73,6 +74,21 @@ struct InteractionRequest {
response: oneshot::Sender<InteractionDeliveryStatus>,
}
struct ProfileLookupRequest {
request_id: String,
user_id: String,
response: oneshot::Sender<Option<String>>,
}
struct ConnectionInputs {
profiles: watch::Receiver<crate::user::User>,
friends: watch::Receiver<Vec<String>>,
keypair: AppKeypair,
live_data: mpsc::Receiver<String>,
interactions: mpsc::Receiver<InteractionRequest>,
profile_lookups: mpsc::Receiver<ProfileLookupRequest>,
}
pub struct Network {
connections: Mutex<HashMap<String, Connection>>,
statuses: Statuses,
@@ -167,6 +183,45 @@ impl Network {
Err("Friend is no longer available".to_owned())
}
pub async fn resolve_profile(&self, user_id: String) -> Result<Option<String>, String> {
crate::user::validate_id(&user_id)?;
if user_id == self.keypair.public_key() {
return Err("You cannot add your own identification key.".to_owned());
}
let senders: Vec<_> = self
.connections
.lock()
.map_err(|error| error.to_string())?
.values()
.map(|connection| connection.profile_lookup_sender.clone())
.collect();
let request_id = uuid::Uuid::new_v4().to_string();
let mut responses = Vec::new();
for sender in senders {
let (response, receiver) = oneshot::channel();
let request = ProfileLookupRequest {
request_id: request_id.clone(),
user_id: user_id.clone(),
response,
};
if sender.try_send(request).is_ok() {
responses.push(receiver);
}
}
let mut pending: FuturesUnordered<_> = responses
.into_iter()
.map(|response| tokio::time::timeout(Duration::from_secs(3), response))
.collect();
while let Some(result) = pending.next().await {
if let Ok(Ok(Some(display_name))) = result {
return Ok(Some(display_name));
}
}
Ok(None)
}
fn sync_remotes(&self, handle: &AppHandle, remotes: Vec<Remote>) -> Result<(), String> {
let desired: HashMap<_, _> = remotes
.into_iter()
@@ -196,6 +251,7 @@ impl Network {
let generation = self.next_generation.fetch_add(1, Ordering::Relaxed);
let (sender, receiver) = mpsc::channel(32);
let (interaction_sender, interaction_receiver) = mpsc::channel(16);
let (profile_lookup_sender, profile_lookup_receiver) = mpsc::channel(16);
set_initial(
&self.statuses,
&remote,
@@ -208,11 +264,14 @@ impl Network {
self.friend_presence.clone(),
remote.clone(),
generation,
self.profile.subscribe(),
self.friends.subscribe(),
self.keypair.clone(),
receiver,
interaction_receiver,
ConnectionInputs {
profiles: self.profile.subscribe(),
friends: self.friends.subscribe(),
keypair: self.keypair.clone(),
live_data: receiver,
interactions: interaction_receiver,
profile_lookups: profile_lookup_receiver,
},
));
connections.insert(
remote.id.clone(),
@@ -220,6 +279,7 @@ impl Network {
remote,
sender,
interaction_sender,
profile_lookup_sender,
task,
generation,
},
@@ -280,11 +340,7 @@ async fn run(
friend_presence: Arc<FriendPresence>,
remote: Remote,
generation: u64,
mut profiles: watch::Receiver<crate::user::User>,
mut friends: watch::Receiver<Vec<String>>,
keypair: AppKeypair,
mut outgoing: mpsc::Receiver<String>,
mut active_outgoing: mpsc::Receiver<InteractionRequest>,
mut inputs: ConnectionInputs,
) {
loop {
changed(
@@ -300,11 +356,7 @@ async fn run(
&friend_presence,
&remote,
generation,
&mut profiles,
&mut friends,
&keypair,
&mut outgoing,
&mut active_outgoing,
&mut inputs,
)
.await
{
@@ -328,12 +380,16 @@ async fn connect(
friend_presence: &FriendPresence,
remote: &Remote,
generation: u64,
profiles: &mut watch::Receiver<crate::user::User>,
friends: &mut watch::Receiver<Vec<String>>,
keypair: &AppKeypair,
outgoing: &mut mpsc::Receiver<String>,
active_outgoing: &mut mpsc::Receiver<InteractionRequest>,
inputs: &mut ConnectionInputs,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let ConnectionInputs {
profiles,
friends,
keypair,
live_data,
interactions: active_outgoing,
profile_lookups,
} = inputs;
let (socket, _) = tokio_tungstenite::connect_async(url(remote)).await?;
let (mut writer, mut reader) = socket.split();
@@ -366,7 +422,7 @@ async fn connect(
if !matches!(recv(&mut reader).await?, ServerMessage::Registered) {
return Err("server rejected registration".into());
}
while outgoing.try_recv().is_ok() {}
while live_data.try_recv().is_ok() {}
while let Ok(request) = active_outgoing.try_recv() {
let _ = request
.response
@@ -383,9 +439,11 @@ async fn connect(
);
let mut pending_interactions = HashMap::new();
let mut pending_profile_lookups: HashMap<String, (String, oneshot::Sender<Option<String>>)> =
HashMap::new();
loop {
tokio::select! {
payload = outgoing.recv() => {
payload = live_data.recv() => {
let payload = payload.ok_or("network sender closed")?;
send(&mut writer, &ClientMessage::Signed {
signature: keypair.sign(&message_bytes(&payload)),
@@ -407,6 +465,21 @@ async fn connect(
}).await?;
pending_interactions.insert(request.interaction_id, request.response);
}
request = profile_lookups.recv() => {
let request = request.ok_or("profile lookup sender closed")?;
if request.response.is_closed() {
continue;
}
pending_profile_lookups.retain(|_, (_, response)| !response.is_closed());
send(&mut writer, &ClientMessage::ResolveProfile {
request_id: request.request_id.clone(),
user_id: request.user_id.clone(),
}).await?;
pending_profile_lookups.insert(
request.request_id,
(request.user_id, request.response),
);
}
changed = profiles.changed() => {
changed.map_err(|_| "profile sender closed")?;
let current = profiles.borrow_and_update().clone();
@@ -481,6 +554,14 @@ async fn connect(
let _ = response.send(status);
}
}
ServerMessage::ProfileResolved { request_id, profile } => {
if let Some((user_id, response)) = pending_profile_lookups.remove(&request_id) {
let display_name = profile
.filter(|profile| profile.id == user_id)
.map(|profile| profile.display_name);
let _ = response.send(display_name);
}
}
_ => {}
},
Message::Ping(data) => writer.send(Message::Pong(data)).await?,
@@ -512,6 +593,15 @@ pub fn list_friend_statuses(network: State<'_, Network>) -> Result<Vec<String>,
network.friend_presence.snapshot()
}
#[tauri::command]
#[specta::specta]
pub async fn resolve_friend_display_name(
user_id: String,
network: State<'_, Network>,
) -> Result<Option<String>, String> {
network.resolve_profile(user_id.trim().to_owned()).await
}
fn update_friend_presence(
handle: &AppHandle,
presence: &FriendPresence,
@@ -658,7 +748,7 @@ fn url(remote: &Remote) -> String {
format!("{scheme}://{address}{port}/v1/ws")
}
fn friend_ids(friends: Vec<crate::user::User>, own_id: &str) -> Vec<String> {
fn friend_ids(friends: Vec<crate::friends::Friend>, own_id: &str) -> Vec<String> {
let mut ids: Vec<_> = friends
.into_iter()
.map(|friend| friend.id)
@@ -672,22 +762,22 @@ fn friend_ids(friends: Vec<crate::user::User>, own_id: &str) -> Vec<String> {
#[cfg(test)]
mod tests {
use super::friend_ids;
use crate::user::User;
use crate::friends::Friend;
#[test]
fn friend_ids_discards_display_names_and_normalizes_ids() {
let friends = vec![
User {
Friend {
id: "friend-b".to_owned(),
display_name: "Old name".to_owned(),
display_name: Some("Old name".to_owned()),
},
User {
Friend {
id: "self".to_owned(),
display_name: "Me".to_owned(),
display_name: None,
},
User {
Friend {
id: "friend-a".to_owned(),
display_name: "Any name".to_owned(),
display_name: Some("Any name".to_owned()),
},
];
+29
View File
@@ -1,3 +1,6 @@
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use ed25519_dalek::VerifyingKey;
use serde::{Deserialize, Serialize};
use specta::Type;
@@ -9,3 +12,29 @@ pub struct User {
pub id: String,
pub display_name: String,
}
pub(crate) fn validate_id(id: &str) -> Result<(), String> {
let bytes = URL_SAFE_NO_PAD
.decode(id)
.map_err(|_| "Identification key is not valid base64url.".to_owned())?;
let bytes: [u8; 32] = bytes
.try_into()
.map_err(|_| "Identification key must encode a 32-byte public key.".to_owned())?;
VerifyingKey::from_bytes(&bytes)
.map(|_| ())
.map_err(|_| "Identification key is not a valid Ed25519 public key.".to_owned())
}
#[cfg(test)]
mod tests {
use ed25519_dalek::SigningKey;
use super::*;
#[test]
fn id_must_be_an_ed25519_public_key() {
let valid = URL_SAFE_NO_PAD.encode(SigningKey::from_bytes(&[7; 32]).verifying_key());
assert!(validate_id(&valid).is_ok());
assert!(validate_id("not a public key").is_err());
}
}