tie friends profile together

This commit is contained in:
2026-08-08 03:27:25 +08:00
parent 056c9304f7
commit 8bc1d074d4
4 changed files with 498 additions and 28 deletions
+110 -1
View File
@@ -11,7 +11,7 @@ pub struct FriendsChanged {
pub friends: Vec<User>,
}
async fn all(database: &AppDatabase) -> Result<Vec<User>, sqlx::Error> {
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",
)
@@ -27,6 +27,48 @@ async fn emit_changed(handle: &AppHandle, database: &AppDatabase) -> Result<(),
.map_err(db::command_error)
}
async fn update_display_names(
database: &AppDatabase,
profiles: &[wyd_common::Profile],
) -> Result<bool, sqlx::Error> {
let mut transaction = database.pool().begin().await?;
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",
)
.bind(&profile.display_name)
.bind(&profile.id)
.execute(&mut *transaction)
.await?;
changed |= result.rows_affected() > 0;
}
transaction.commit().await?;
Ok(changed)
}
pub(crate) async fn apply_profile_update(
handle: &AppHandle,
database: &AppDatabase,
profile: wyd_common::Profile,
) -> Result<bool, String> {
apply_profile_sync(handle, database, vec![profile]).await
}
pub(crate) async fn apply_profile_sync(
handle: &AppHandle,
database: &AppDatabase,
profiles: Vec<wyd_common::Profile>,
) -> Result<bool, String> {
let changed = update_display_names(database, &profiles)
.await
.map_err(db::command_error)?;
if changed {
emit_changed(handle, database).await?;
}
Ok(changed)
}
#[tauri::command]
#[specta::specta]
pub async fn create_friend(
@@ -97,3 +139,70 @@ pub async fn delete_friend(
Ok(changed)
}
#[cfg(test)]
mod tests {
use sqlx::sqlite::SqlitePoolOptions;
use super::*;
async fn database() -> AppDatabase {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("connect to in-memory SQLite");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("run database migrations");
AppDatabase::new(pool)
}
#[tokio::test]
async fn profile_update_changes_only_an_existing_friend() {
let database = database().await;
sqlx::query("INSERT INTO friends (id, display_name) VALUES (?1, ?2)")
.bind("friend-id")
.bind("Old")
.execute(database.pool())
.await
.unwrap();
assert!(
update_display_names(
&database,
&[
wyd_common::Profile {
id: "friend-id".to_owned(),
display_name: "New".to_owned(),
},
wyd_common::Profile {
id: "missing".to_owned(),
display_name: "Name".to_owned(),
},
],
)
.await
.unwrap()
);
assert!(
!update_display_names(
&database,
&[wyd_common::Profile {
id: "friend-id".to_owned(),
display_name: "New".to_owned(),
}],
)
.await
.unwrap()
);
assert_eq!(
all(&database).await.unwrap(),
[User {
id: "friend-id".to_owned(),
display_name: "New".to_owned(),
}]
);
}
}
+92 -2
View File
@@ -11,10 +11,12 @@ use tauri_specta::Event;
use tokio::sync::{mpsc, watch};
use tokio_tungstenite::tungstenite::Message;
use wyd_common::{
ClientMessage, Profile, ServerMessage, message_bytes, profile_bytes, register_bytes,
ClientMessage, Profile, ServerMessage, friends_bytes, message_bytes, profile_bytes,
register_bytes,
};
use crate::db::AppDatabase;
use crate::friends::{self, FriendsChanged};
use crate::keypair::AppKeypair;
use crate::remotes::{self, Remote, RemotesChanged};
@@ -54,6 +56,7 @@ pub struct Network {
connections: Mutex<HashMap<String, Connection>>,
statuses: Statuses,
profile: watch::Sender<crate::user::User>,
friends: watch::Sender<Vec<String>>,
keypair: AppKeypair,
next_generation: AtomicU64,
}
@@ -114,6 +117,7 @@ impl Network {
remote.clone(),
generation,
self.profile.subscribe(),
self.friends.subscribe(),
self.keypair.clone(),
receiver,
));
@@ -138,10 +142,13 @@ pub async fn init(handle: &AppHandle) -> Result<(), Box<dyn std::error::Error>>
let keypair = handle.state::<AppKeypair>().inner().clone();
let profile = crate::profile::get(&database, keypair.public_key()).await?;
let (profile, _) = watch::channel(profile);
let friends = friend_ids(friends::all(&database).await?, keypair.public_key());
let (friends, _) = watch::channel(friends);
let network = Network {
connections: Mutex::new(HashMap::new()),
statuses: Statuses::default(),
profile,
friends,
keypair,
next_generation: AtomicU64::new(1),
};
@@ -155,6 +162,19 @@ pub async fn init(handle: &AppHandle) -> Result<(), Box<dyn std::error::Error>>
eprintln!("failed to synchronize remote connections: {error}");
}
});
let listener_handle = handle.clone();
FriendsChanged::listen(handle, move |event| {
let network = listener_handle.state::<Network>();
let ids = friend_ids(event.payload.friends, network.keypair.public_key());
network.friends.send_if_modified(|current| {
if *current == ids {
return false;
}
*current = ids;
true
});
});
Ok(())
}
@@ -164,6 +184,7 @@ async fn run(
remote: Remote,
generation: u64,
mut profiles: watch::Receiver<crate::user::User>,
mut friends: watch::Receiver<Vec<String>>,
keypair: AppKeypair,
mut outgoing: mpsc::Receiver<String>,
) {
@@ -181,6 +202,7 @@ async fn run(
&remote,
generation,
&mut profiles,
&mut friends,
&keypair,
&mut outgoing,
)
@@ -205,6 +227,7 @@ async fn connect(
remote: &Remote,
generation: u64,
profiles: &mut watch::Receiver<crate::user::User>,
friends: &mut watch::Receiver<Vec<String>>,
keypair: &AppKeypair,
outgoing: &mut mpsc::Receiver<String>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
@@ -222,11 +245,17 @@ async fn connect(
id: current.id,
display_name: current.display_name,
};
let registration_friends = friends.borrow_and_update().clone();
send(
&mut writer,
&ClientMessage::Register {
signature: keypair.sign(&register_bytes(&challenge, &registration_profile)),
signature: keypair.sign(&register_bytes(
&challenge,
&registration_profile,
&registration_friends,
)),
profile: registration_profile,
friends: registration_friends,
},
)
.await?;
@@ -234,6 +263,7 @@ async fn connect(
if !matches!(recv(&mut reader).await?, ServerMessage::Registered) {
return Err("server rejected registration".into());
}
send(&mut writer, &ClientMessage::SyncFriendProfiles).await?;
changed(
handle,
statuses,
@@ -263,7 +293,30 @@ async fn connect(
profile,
}).await?;
}
changed = friends.changed() => {
changed.map_err(|_| "friends sender closed")?;
let friends = friends.borrow_and_update().clone();
send(&mut writer, &ClientMessage::FriendsUpdated {
signature: keypair.sign(&friends_bytes(&friends)),
friends,
}).await?;
}
message = reader.next() => match message.ok_or("server closed the socket")?? {
Message::Text(text) => match serde_json::from_str(&text)? {
ServerMessage::FriendProfileUpdated { profile } => {
let database = handle.state::<AppDatabase>();
if let Err(error) = friends::apply_profile_update(handle, &database, profile).await {
eprintln!("failed to update friend profile: {error}");
}
}
ServerMessage::FriendProfiles { profiles } => {
let database = handle.state::<AppDatabase>();
if let Err(error) = friends::apply_profile_sync(handle, &database, profiles).await {
eprintln!("failed to synchronize friend profiles: {error}");
}
}
_ => {}
},
Message::Ping(data) => writer.send(Message::Pong(data)).await?,
Message::Close(_) => return Ok(()),
_ => {}
@@ -402,3 +455,40 @@ fn url(remote: &Remote) -> String {
.unwrap_or_default();
format!("{scheme}://{address}{port}/v1/ws")
}
fn friend_ids(friends: Vec<crate::user::User>, own_id: &str) -> Vec<String> {
let mut ids: Vec<_> = friends
.into_iter()
.map(|friend| friend.id)
.filter(|id| id != own_id)
.collect();
ids.sort_unstable();
ids.dedup();
ids
}
#[cfg(test)]
mod tests {
use super::friend_ids;
use crate::user::User;
#[test]
fn friend_ids_discards_display_names_and_normalizes_ids() {
let friends = vec![
User {
id: "friend-b".to_owned(),
display_name: "Old name".to_owned(),
},
User {
id: "self".to_owned(),
display_name: "Me".to_owned(),
},
User {
id: "friend-a".to_owned(),
display_name: "Any name".to_owned(),
},
];
assert_eq!(friend_ids(friends, "self"), ["friend-a", "friend-b"]);
}
}