remove "display name" from adding new friends
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
pub const VERSION: u8 = 1;
|
||||
pub const VERSION: u8 = 2;
|
||||
pub const MAX_INTERACTION_PAYLOAD_BYTES: usize = 160 * 1024;
|
||||
pub const MAX_IMAGE_B64_SIZE: usize = 150 * 1024;
|
||||
pub const MAX_IMAGE_DIMENSION: u32 = 480;
|
||||
@@ -32,6 +32,10 @@ pub enum ClientMessage {
|
||||
},
|
||||
SyncFriendProfiles,
|
||||
SyncFriendStatuses,
|
||||
ResolveProfile {
|
||||
request_id: String,
|
||||
user_id: String,
|
||||
},
|
||||
Signed {
|
||||
payload: String,
|
||||
signature: String,
|
||||
@@ -107,6 +111,10 @@ pub enum ServerMessage {
|
||||
FriendProfiles {
|
||||
profiles: Vec<Profile>,
|
||||
},
|
||||
ProfileResolved {
|
||||
request_id: String,
|
||||
profile: Option<Profile>,
|
||||
},
|
||||
FriendStatusChanged {
|
||||
friend_id: String,
|
||||
online: bool,
|
||||
|
||||
+29
-220
@@ -13,19 +13,17 @@ use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio::sync::{Mutex, mpsc};
|
||||
use uuid::Uuid;
|
||||
use wyd_common::{
|
||||
ClientMessage, Profile, ServerMessage, message_bytes, profile_bytes, register_bytes,
|
||||
};
|
||||
use wyd_common::{ClientMessage, Profile, ServerMessage, message_bytes, register_bytes};
|
||||
|
||||
mod interactions;
|
||||
mod presence;
|
||||
mod profiles;
|
||||
|
||||
type Clients = Arc<Mutex<HashMap<String, Client>>>;
|
||||
|
||||
struct Client {
|
||||
connection_id: Uuid,
|
||||
key: VerifyingKey,
|
||||
#[allow(dead_code)] // Used when presence and profile lookup are exposed.
|
||||
profile: Profile,
|
||||
friends: Vec<String>,
|
||||
sender: mpsc::Sender<Message>,
|
||||
@@ -102,6 +100,7 @@ async fn connected(mut socket: WebSocket, clients: Clients) {
|
||||
presence::disconnected(&clients, &public_key, connection_id).await;
|
||||
return;
|
||||
}
|
||||
profiles::broadcast(&clients, &public_key, connection_id).await;
|
||||
presence::connected(&clients, &public_key, previous_friends).await;
|
||||
|
||||
let (mut writer, mut reader) = socket.split();
|
||||
@@ -148,7 +147,7 @@ async fn connected(mut socket: WebSocket, clients: Clients) {
|
||||
}
|
||||
}
|
||||
Ok(ClientMessage::ProfileUpdated { profile, signature }) => {
|
||||
if !update_profile(&clients, &public_key, connection_id, profile, &signature).await {
|
||||
if !profiles::update(&clients, &public_key, connection_id, profile, &signature).await {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -166,9 +165,16 @@ async fn connected(mut socket: WebSocket, clients: Clients) {
|
||||
if writer.send(Message::Text(serde_json::to_string(&message).unwrap().into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
let Some(profiles) = profiles::snapshot(&clients, &public_key, connection_id).await else {
|
||||
break;
|
||||
};
|
||||
let message = ServerMessage::FriendProfiles { profiles };
|
||||
if writer.send(Message::Text(serde_json::to_string(&message).unwrap().into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(ClientMessage::SyncFriendProfiles) => {
|
||||
let Some(profiles) = friend_profiles(&clients, &public_key, connection_id).await else {
|
||||
let Some(profiles) = profiles::snapshot(&clients, &public_key, connection_id).await else {
|
||||
break;
|
||||
};
|
||||
let message = ServerMessage::FriendProfiles { profiles };
|
||||
@@ -185,6 +191,23 @@ async fn connected(mut socket: WebSocket, clients: Clients) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(ClientMessage::ResolveProfile { request_id, user_id }) => {
|
||||
let Some(profile) = profiles::resolve(
|
||||
&clients,
|
||||
&public_key,
|
||||
connection_id,
|
||||
&user_id,
|
||||
).await else {
|
||||
break;
|
||||
};
|
||||
let message = ServerMessage::ProfileResolved {
|
||||
request_id,
|
||||
profile,
|
||||
};
|
||||
if writer.send(Message::Text(serde_json::to_string(&message).unwrap().into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
Some(Ok(Message::Ping(data))) => {
|
||||
@@ -199,63 +222,6 @@ async fn connected(mut socket: WebSocket, clients: Clients) {
|
||||
presence::disconnected(&clients, &public_key, connection_id).await;
|
||||
}
|
||||
|
||||
async fn update_profile(
|
||||
clients: &Clients,
|
||||
public_key: &str,
|
||||
connection_id: Uuid,
|
||||
profile: Profile,
|
||||
signature: &str,
|
||||
) -> bool {
|
||||
let mut clients = clients.lock().await;
|
||||
{
|
||||
let Some(client) = clients
|
||||
.get_mut(public_key)
|
||||
.filter(|client| client.connection_id == connection_id)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if profile.id != public_key || !verify(&client.key, &profile_bytes(&profile), signature) {
|
||||
return false;
|
||||
}
|
||||
client.profile = profile.clone();
|
||||
}
|
||||
|
||||
let recipients: Vec<_> = clients
|
||||
.values()
|
||||
.filter(|client| client.friends.iter().any(|friend| friend == public_key))
|
||||
.map(|client| client.sender.clone())
|
||||
.collect();
|
||||
drop(clients);
|
||||
|
||||
let message = Message::Text(
|
||||
serde_json::to_string(&ServerMessage::FriendProfileUpdated { profile })
|
||||
.unwrap()
|
||||
.into(),
|
||||
);
|
||||
for recipient in recipients {
|
||||
let _ = recipient.send(message.clone()).await;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
async fn friend_profiles(
|
||||
clients: &Clients,
|
||||
public_key: &str,
|
||||
connection_id: Uuid,
|
||||
) -> Option<Vec<Profile>> {
|
||||
let clients = clients.lock().await;
|
||||
let client = clients
|
||||
.get(public_key)
|
||||
.filter(|client| client.connection_id == connection_id)?;
|
||||
let mut profiles: Vec<_> = client
|
||||
.friends
|
||||
.iter()
|
||||
.filter_map(|friend| clients.get(friend).map(|client| client.profile.clone()))
|
||||
.collect();
|
||||
profiles.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
Some(profiles)
|
||||
}
|
||||
|
||||
async fn relay_live_data(
|
||||
clients: &Clients,
|
||||
public_key: &str,
|
||||
@@ -366,105 +332,6 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authenticated_profile_update_changes_the_registered_client() {
|
||||
let signing_key = SigningKey::from_bytes(&[7; 32]);
|
||||
let public_key = URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes());
|
||||
let connection_id = Uuid::new_v4();
|
||||
let (sender, _receiver) = mpsc::channel(1);
|
||||
let (friend_sender, mut friend_receiver) = mpsc::channel(1);
|
||||
let clients = Clients::default();
|
||||
{
|
||||
let mut clients = clients.lock().await;
|
||||
clients.insert(
|
||||
public_key.clone(),
|
||||
Client {
|
||||
connection_id,
|
||||
key: signing_key.verifying_key(),
|
||||
profile: Profile {
|
||||
id: public_key.clone(),
|
||||
display_name: "Old".to_owned(),
|
||||
},
|
||||
friends: Vec::new(),
|
||||
sender,
|
||||
},
|
||||
);
|
||||
clients.insert(
|
||||
"friend".to_owned(),
|
||||
Client {
|
||||
connection_id: Uuid::new_v4(),
|
||||
key: signing_key.verifying_key(),
|
||||
profile: Profile {
|
||||
id: "friend".to_owned(),
|
||||
display_name: "Friend".to_owned(),
|
||||
},
|
||||
friends: vec![public_key.clone()],
|
||||
sender: friend_sender,
|
||||
},
|
||||
);
|
||||
}
|
||||
let profile = Profile {
|
||||
id: public_key.clone(),
|
||||
display_name: "New".to_owned(),
|
||||
};
|
||||
let signature =
|
||||
URL_SAFE_NO_PAD.encode(signing_key.sign(&profile_bytes(&profile)).to_bytes());
|
||||
|
||||
assert!(update_profile(&clients, &public_key, connection_id, profile, &signature,).await);
|
||||
assert_eq!(
|
||||
clients
|
||||
.lock()
|
||||
.await
|
||||
.get(&public_key)
|
||||
.unwrap()
|
||||
.profile
|
||||
.display_name,
|
||||
"New"
|
||||
);
|
||||
let announcement = friend_receiver
|
||||
.recv()
|
||||
.await
|
||||
.expect("friend receives update");
|
||||
let Message::Text(announcement) = announcement else {
|
||||
panic!("expected text announcement");
|
||||
};
|
||||
let ServerMessage::FriendProfileUpdated { profile } =
|
||||
serde_json::from_str(&announcement).expect("decode announcement")
|
||||
else {
|
||||
panic!("expected friend profile update");
|
||||
};
|
||||
assert_eq!(profile.id, public_key);
|
||||
assert_eq!(profile.display_name, "New");
|
||||
|
||||
let stale_profile = Profile {
|
||||
id: public_key.clone(),
|
||||
display_name: "Stale".to_owned(),
|
||||
};
|
||||
let stale_signature =
|
||||
URL_SAFE_NO_PAD.encode(signing_key.sign(&profile_bytes(&stale_profile)).to_bytes());
|
||||
assert!(
|
||||
!update_profile(
|
||||
&clients,
|
||||
&public_key,
|
||||
Uuid::new_v4(),
|
||||
stale_profile,
|
||||
&stale_signature,
|
||||
)
|
||||
.await
|
||||
);
|
||||
assert!(friend_receiver.try_recv().is_err());
|
||||
assert_eq!(
|
||||
clients
|
||||
.lock()
|
||||
.await
|
||||
.get(&public_key)
|
||||
.unwrap()
|
||||
.profile
|
||||
.display_name,
|
||||
"New"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authenticated_friend_update_changes_only_ids() {
|
||||
let signing_key = SigningKey::from_bytes(&[7; 32]);
|
||||
@@ -506,64 +373,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_sync_returns_only_connected_friends_for_the_current_session() {
|
||||
let signing_key = SigningKey::from_bytes(&[7; 32]);
|
||||
let connection_id = Uuid::new_v4();
|
||||
let (sender, _receiver) = mpsc::channel(1);
|
||||
let clients = Clients::default();
|
||||
let mut registry = clients.lock().await;
|
||||
registry.insert(
|
||||
"requester".to_owned(),
|
||||
Client {
|
||||
connection_id,
|
||||
key: signing_key.verifying_key(),
|
||||
profile: Profile {
|
||||
id: "requester".to_owned(),
|
||||
display_name: "Requester".to_owned(),
|
||||
},
|
||||
friends: vec![
|
||||
"friend-b".to_owned(),
|
||||
"offline".to_owned(),
|
||||
"friend-a".to_owned(),
|
||||
],
|
||||
sender: sender.clone(),
|
||||
},
|
||||
);
|
||||
for (id, display_name) in [("friend-a", "Alice"), ("friend-b", "Bob")] {
|
||||
registry.insert(
|
||||
id.to_owned(),
|
||||
Client {
|
||||
connection_id: Uuid::new_v4(),
|
||||
key: signing_key.verifying_key(),
|
||||
profile: Profile {
|
||||
id: id.to_owned(),
|
||||
display_name: display_name.to_owned(),
|
||||
},
|
||||
friends: Vec::new(),
|
||||
sender: sender.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
drop(registry);
|
||||
|
||||
let profiles = friend_profiles(&clients, "requester", connection_id)
|
||||
.await
|
||||
.expect("current session");
|
||||
assert_eq!(
|
||||
profiles
|
||||
.iter()
|
||||
.map(|profile| (profile.id.as_str(), profile.display_name.as_str()))
|
||||
.collect::<Vec<_>>(),
|
||||
[("friend-a", "Alice"), ("friend-b", "Bob")]
|
||||
);
|
||||
assert!(
|
||||
friend_profiles(&clients, "requester", Uuid::new_v4())
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn presence_sync_and_disconnect_broadcast_require_mutual_friendship() {
|
||||
let signing_key = SigningKey::from_bytes(&[7; 32]);
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
use axum::extract::ws::Message;
|
||||
use tokio::sync::mpsc;
|
||||
use uuid::Uuid;
|
||||
use wyd_common::{Profile, ServerMessage, profile_bytes};
|
||||
|
||||
use super::{Client, Clients, verify};
|
||||
|
||||
pub(super) async fn update(
|
||||
clients: &Clients,
|
||||
public_key: &str,
|
||||
connection_id: Uuid,
|
||||
profile: Profile,
|
||||
signature: &str,
|
||||
) -> bool {
|
||||
let mut clients = clients.lock().await;
|
||||
let Some(client) = clients
|
||||
.get_mut(public_key)
|
||||
.filter(|client| client.connection_id == connection_id)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if profile.id != public_key || !verify(&client.key, &profile_bytes(&profile), signature) {
|
||||
return false;
|
||||
}
|
||||
client.profile = profile.clone();
|
||||
let recipients = subscribers(&clients, public_key);
|
||||
drop(clients);
|
||||
|
||||
send_update(profile, recipients).await;
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) async fn broadcast(clients: &Clients, public_key: &str, connection_id: Uuid) {
|
||||
let clients = clients.lock().await;
|
||||
let Some(profile) = clients
|
||||
.get(public_key)
|
||||
.filter(|client| client.connection_id == connection_id)
|
||||
.map(|client| client.profile.clone())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let recipients = subscribers(&clients, public_key);
|
||||
drop(clients);
|
||||
|
||||
send_update(profile, recipients).await;
|
||||
}
|
||||
|
||||
pub(super) async fn snapshot(
|
||||
clients: &Clients,
|
||||
public_key: &str,
|
||||
connection_id: Uuid,
|
||||
) -> Option<Vec<Profile>> {
|
||||
let clients = clients.lock().await;
|
||||
let client = clients
|
||||
.get(public_key)
|
||||
.filter(|client| client.connection_id == connection_id)?;
|
||||
let mut profiles: Vec<_> = client
|
||||
.friends
|
||||
.iter()
|
||||
.filter_map(|friend| clients.get(friend).map(|client| client.profile.clone()))
|
||||
.collect();
|
||||
profiles.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
Some(profiles)
|
||||
}
|
||||
|
||||
pub(super) async fn resolve(
|
||||
clients: &Clients,
|
||||
requester_id: &str,
|
||||
connection_id: Uuid,
|
||||
user_id: &str,
|
||||
) -> Option<Option<Profile>> {
|
||||
let clients = clients.lock().await;
|
||||
clients
|
||||
.get(requester_id)
|
||||
.filter(|client| client.connection_id == connection_id)?;
|
||||
Some(clients.get(user_id).map(|client| client.profile.clone()))
|
||||
}
|
||||
|
||||
fn subscribers(
|
||||
clients: &std::collections::HashMap<String, Client>,
|
||||
user_id: &str,
|
||||
) -> Vec<mpsc::Sender<Message>> {
|
||||
clients
|
||||
.iter()
|
||||
.filter(|(id, client)| {
|
||||
id.as_str() != user_id && client.friends.iter().any(|friend| friend == user_id)
|
||||
})
|
||||
.map(|(_, client)| client.sender.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn send_update(profile: Profile, recipients: Vec<mpsc::Sender<Message>>) {
|
||||
let message = Message::Text(
|
||||
serde_json::to_string(&ServerMessage::FriendProfileUpdated { profile })
|
||||
.expect("friend profile update serializes")
|
||||
.into(),
|
||||
);
|
||||
for recipient in recipients {
|
||||
let _ = recipient.send(message.clone()).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn client(
|
||||
signing_key: &SigningKey,
|
||||
id: &str,
|
||||
display_name: &str,
|
||||
connection_id: Uuid,
|
||||
friends: Vec<String>,
|
||||
sender: mpsc::Sender<Message>,
|
||||
) -> Client {
|
||||
Client {
|
||||
connection_id,
|
||||
key: signing_key.verifying_key(),
|
||||
profile: Profile {
|
||||
id: id.to_owned(),
|
||||
display_name: display_name.to_owned(),
|
||||
},
|
||||
friends,
|
||||
sender,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authenticated_update_changes_the_registered_client() {
|
||||
let signing_key = SigningKey::from_bytes(&[7; 32]);
|
||||
let public_key = URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes());
|
||||
let connection_id = Uuid::new_v4();
|
||||
let (sender, _receiver) = mpsc::channel(1);
|
||||
let (friend_sender, mut friend_receiver) = mpsc::channel(1);
|
||||
let clients = Clients::default();
|
||||
{
|
||||
let mut clients = clients.lock().await;
|
||||
clients.insert(
|
||||
public_key.clone(),
|
||||
client(
|
||||
&signing_key,
|
||||
&public_key,
|
||||
"Old",
|
||||
connection_id,
|
||||
Vec::new(),
|
||||
sender,
|
||||
),
|
||||
);
|
||||
clients.insert(
|
||||
"friend".to_owned(),
|
||||
client(
|
||||
&signing_key,
|
||||
"friend",
|
||||
"Friend",
|
||||
Uuid::new_v4(),
|
||||
vec![public_key.clone()],
|
||||
friend_sender,
|
||||
),
|
||||
);
|
||||
}
|
||||
let profile = Profile {
|
||||
id: public_key.clone(),
|
||||
display_name: "New".to_owned(),
|
||||
};
|
||||
let signature =
|
||||
URL_SAFE_NO_PAD.encode(signing_key.sign(&profile_bytes(&profile)).to_bytes());
|
||||
|
||||
assert!(update(&clients, &public_key, connection_id, profile, &signature).await);
|
||||
assert_eq!(
|
||||
clients
|
||||
.lock()
|
||||
.await
|
||||
.get(&public_key)
|
||||
.unwrap()
|
||||
.profile
|
||||
.display_name,
|
||||
"New"
|
||||
);
|
||||
let Message::Text(announcement) = friend_receiver
|
||||
.recv()
|
||||
.await
|
||||
.expect("friend receives update")
|
||||
else {
|
||||
panic!("expected text announcement");
|
||||
};
|
||||
let ServerMessage::FriendProfileUpdated { profile } =
|
||||
serde_json::from_str(&announcement).expect("decode announcement")
|
||||
else {
|
||||
panic!("expected friend profile update");
|
||||
};
|
||||
assert_eq!(profile.id, public_key);
|
||||
assert_eq!(profile.display_name, "New");
|
||||
|
||||
let stale_profile = Profile {
|
||||
id: public_key.clone(),
|
||||
display_name: "Stale".to_owned(),
|
||||
};
|
||||
let stale_signature =
|
||||
URL_SAFE_NO_PAD.encode(signing_key.sign(&profile_bytes(&stale_profile)).to_bytes());
|
||||
assert!(
|
||||
!update(
|
||||
&clients,
|
||||
&public_key,
|
||||
Uuid::new_v4(),
|
||||
stale_profile,
|
||||
&stale_signature,
|
||||
)
|
||||
.await
|
||||
);
|
||||
assert!(friend_receiver.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn first_connection_announces_to_existing_subscribers() {
|
||||
let signing_key = SigningKey::from_bytes(&[7; 32]);
|
||||
let public_key = URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes());
|
||||
let connection_id = Uuid::new_v4();
|
||||
let (source_sender, _source_receiver) = mpsc::channel(1);
|
||||
let (friend_sender, mut friend_receiver) = mpsc::channel(1);
|
||||
let (unrelated_sender, mut unrelated_receiver) = mpsc::channel(1);
|
||||
let clients = Clients::default();
|
||||
{
|
||||
let mut clients = clients.lock().await;
|
||||
clients.insert(
|
||||
public_key.clone(),
|
||||
client(
|
||||
&signing_key,
|
||||
&public_key,
|
||||
"Newly connected",
|
||||
connection_id,
|
||||
Vec::new(),
|
||||
source_sender,
|
||||
),
|
||||
);
|
||||
clients.insert(
|
||||
"friend".to_owned(),
|
||||
client(
|
||||
&signing_key,
|
||||
"friend",
|
||||
"Friend",
|
||||
Uuid::new_v4(),
|
||||
vec![public_key.clone()],
|
||||
friend_sender,
|
||||
),
|
||||
);
|
||||
clients.insert(
|
||||
"unrelated".to_owned(),
|
||||
client(
|
||||
&signing_key,
|
||||
"unrelated",
|
||||
"Unrelated",
|
||||
Uuid::new_v4(),
|
||||
Vec::new(),
|
||||
unrelated_sender,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
broadcast(&clients, &public_key, connection_id).await;
|
||||
|
||||
let Message::Text(announcement) = friend_receiver
|
||||
.recv()
|
||||
.await
|
||||
.expect("subscriber receives connected profile")
|
||||
else {
|
||||
panic!("expected text announcement");
|
||||
};
|
||||
let ServerMessage::FriendProfileUpdated { profile } =
|
||||
serde_json::from_str(&announcement).expect("decode announcement")
|
||||
else {
|
||||
panic!("expected friend profile update");
|
||||
};
|
||||
assert_eq!(profile.id, public_key);
|
||||
assert_eq!(profile.display_name, "Newly connected");
|
||||
assert!(unrelated_receiver.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snapshot_returns_only_connected_friends_for_the_current_session() {
|
||||
let signing_key = SigningKey::from_bytes(&[7; 32]);
|
||||
let connection_id = Uuid::new_v4();
|
||||
let (sender, _receiver) = mpsc::channel(1);
|
||||
let clients = Clients::default();
|
||||
let mut registry = clients.lock().await;
|
||||
registry.insert(
|
||||
"requester".to_owned(),
|
||||
client(
|
||||
&signing_key,
|
||||
"requester",
|
||||
"Requester",
|
||||
connection_id,
|
||||
vec![
|
||||
"friend-b".to_owned(),
|
||||
"offline".to_owned(),
|
||||
"friend-a".to_owned(),
|
||||
],
|
||||
sender.clone(),
|
||||
),
|
||||
);
|
||||
for (id, display_name) in [("friend-a", "Alice"), ("friend-b", "Bob")] {
|
||||
registry.insert(
|
||||
id.to_owned(),
|
||||
client(
|
||||
&signing_key,
|
||||
id,
|
||||
display_name,
|
||||
Uuid::new_v4(),
|
||||
Vec::new(),
|
||||
sender.clone(),
|
||||
),
|
||||
);
|
||||
}
|
||||
drop(registry);
|
||||
|
||||
let profiles = snapshot(&clients, "requester", connection_id)
|
||||
.await
|
||||
.expect("current session");
|
||||
assert_eq!(
|
||||
profiles
|
||||
.iter()
|
||||
.map(|profile| (profile.id.as_str(), profile.display_name.as_str()))
|
||||
.collect::<Vec<_>>(),
|
||||
[("friend-a", "Alice"), ("friend-b", "Bob")]
|
||||
);
|
||||
assert!(
|
||||
snapshot(&clients, "requester", Uuid::new_v4())
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lookup_resolves_connected_users_for_only_the_current_session() {
|
||||
let signing_key = SigningKey::from_bytes(&[7; 32]);
|
||||
let connection_id = Uuid::new_v4();
|
||||
let (sender, _receiver) = mpsc::channel(1);
|
||||
let clients = Clients::default();
|
||||
{
|
||||
let mut clients = clients.lock().await;
|
||||
for (id, name, session) in [
|
||||
("requester", "Requester", connection_id),
|
||||
("target", "Resolved name", Uuid::new_v4()),
|
||||
] {
|
||||
clients.insert(
|
||||
id.to_owned(),
|
||||
client(&signing_key, id, name, session, Vec::new(), sender.clone()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
resolve(&clients, "requester", connection_id, "target")
|
||||
.await
|
||||
.expect("current requester session")
|
||||
.expect("connected target")
|
||||
.display_name,
|
||||
"Resolved name"
|
||||
);
|
||||
assert!(
|
||||
resolve(&clients, "requester", connection_id, "offline")
|
||||
.await
|
||||
.expect("current requester session")
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
resolve(&clients, "requester", Uuid::new_v4(), "target")
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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()),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+13
-5
@@ -5,13 +5,13 @@
|
||||
|
||||
|
||||
export const commands = {
|
||||
async createFriend(friend: User) : Promise<User> {
|
||||
return await TAURI_INVOKE("create_friend", { friend });
|
||||
async createFriend(id: string) : Promise<Friend> {
|
||||
return await TAURI_INVOKE("create_friend", { id });
|
||||
},
|
||||
async listFriends() : Promise<User[]> {
|
||||
async listFriends() : Promise<Friend[]> {
|
||||
return await TAURI_INVOKE("list_friends");
|
||||
},
|
||||
async getFriend(id: string) : Promise<User | null> {
|
||||
async getFriend(id: string) : Promise<Friend | null> {
|
||||
return await TAURI_INVOKE("get_friend", { id });
|
||||
},
|
||||
async deleteFriend(id: string) : Promise<boolean> {
|
||||
@@ -47,6 +47,9 @@ async listStatuses() : Promise<ConnectionStatus[]> {
|
||||
async listFriendStatuses() : Promise<string[]> {
|
||||
return await TAURI_INVOKE("list_friend_statuses");
|
||||
},
|
||||
async resolveFriendDisplayName(userId: string) : Promise<string | null> {
|
||||
return await TAURI_INVOKE("resolve_friend_display_name", { userId });
|
||||
},
|
||||
async pickAndSendImage(recipientId: string) : Promise<boolean> {
|
||||
return await TAURI_INVOKE("pick_and_send_image", { recipientId });
|
||||
},
|
||||
@@ -113,10 +116,15 @@ raw: CursorPosition;
|
||||
*/
|
||||
mapped: CursorPosition }
|
||||
export type ForegroundAppChanged = { meta: AppMeta }
|
||||
/**
|
||||
* A configured public-key relationship with optional cached remote metadata.
|
||||
* The display name remains absent until learned from a signed remote profile.
|
||||
*/
|
||||
export type Friend = { id: string; displayName: string | null }
|
||||
export type FriendForegroundAppChanged = { friendId: string; meta: AppMeta }
|
||||
export type FriendInteractionReceived = { interactionId: string; friendId: string; content: InteractionContent }
|
||||
export type FriendStatusesChanged = { friendIds: string[] }
|
||||
export type FriendsChanged = { friends: User[] }
|
||||
export type FriendsChanged = { friends: Friend[] }
|
||||
export type InteractionContent = { type: "text"; text: string } | { type: "wave" } | { type: "image"; mediaType: string; data: string }
|
||||
export type NetworkStatusChanged = { statuses: ConnectionStatus[] }
|
||||
export type ProfileChanged = { profile: User }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { friends } from "$lib/listeners/friends";
|
||||
import { friendName, friends } from "$lib/listeners/friends";
|
||||
import {
|
||||
liveMetadata,
|
||||
liveMetadataListenerError,
|
||||
@@ -25,9 +25,9 @@
|
||||
|
||||
function nameFor(id: string) {
|
||||
if (id === $liveMetadata.localId) return "You";
|
||||
return (
|
||||
$friends.find((friend) => friend.id === id)?.displayName ??
|
||||
"Unknown friend"
|
||||
return friendName(
|
||||
$friends.find((friend) => friend.id === id),
|
||||
"Unknown friend",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,15 @@
|
||||
friendStatusesListenerError,
|
||||
onlineFriendIds,
|
||||
} from "$lib/listeners/friend-statuses";
|
||||
import { friends, friendsListenerError } from "$lib/listeners/friends";
|
||||
import {
|
||||
friendName,
|
||||
friends,
|
||||
friendsListenerError,
|
||||
} from "$lib/listeners/friends";
|
||||
import { onMount } from "svelte";
|
||||
import PanelMessage from "./panel-message.svelte";
|
||||
import type { RegisterPanel } from "./types";
|
||||
import Info from "$lib/icons/info.svelte";
|
||||
|
||||
let { register }: { register: RegisterPanel } = $props();
|
||||
|
||||
@@ -85,8 +90,9 @@
|
||||
|
||||
{#if mode === "remove" && selected}
|
||||
<PanelMessage kind="warning">
|
||||
Applying will remove <strong>{selected.displayName}</strong>. They will
|
||||
immediately go offline and stop receiving your activity.
|
||||
Applying will remove <strong
|
||||
>{friendName(selected, "Unknown friend")}</strong
|
||||
>. They will immediately go offline and stop receiving your activity.
|
||||
</PanelMessage>
|
||||
<button class="btn w-fit" type="button" onclick={() => (mode = "browse")}
|
||||
>Keep friend</button
|
||||
@@ -126,7 +132,23 @@
|
||||
ondblclick={() => (selectedId = friend.id)}
|
||||
>
|
||||
<span class="min-w-0">
|
||||
<strong class="block truncate">{friend.displayName}</strong>
|
||||
<div class="flex flex-row gap-1.5">
|
||||
<strong
|
||||
class:italic={!friend.displayName}
|
||||
class="block truncate"
|
||||
>{friendName(friend, "Unknown friend")}</strong
|
||||
>
|
||||
{#if !friend.displayName}
|
||||
<div
|
||||
class="tooltip tooltip-primary"
|
||||
data-tip={"Will be resolved when they're online"}
|
||||
>
|
||||
<div class="*:size-3 opacity-50">
|
||||
<Info />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="block truncate font-mono text-[9px] opacity-65"
|
||||
>{friend.id}</span
|
||||
>
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
friendStatusesListenerError,
|
||||
onlineFriendIds,
|
||||
} from "$lib/listeners/friend-statuses";
|
||||
import { friends, friendsListenerError } from "$lib/listeners/friends";
|
||||
import {
|
||||
friendName,
|
||||
friends,
|
||||
friendsListenerError,
|
||||
} from "$lib/listeners/friends";
|
||||
</script>
|
||||
|
||||
<section>
|
||||
@@ -20,7 +24,7 @@
|
||||
{#each $friends as friend (friend.id)}
|
||||
{@const online = $onlineFriendIds.has(friend.id)}
|
||||
<li>
|
||||
<strong>{friend.displayName}</strong>: {online ? "Online" : "Offline"}
|
||||
<strong>{friendName(friend, "Unknown friend")}</strong>: {online ? "Online" : "Offline"}
|
||||
<small>({friend.id})</small>
|
||||
</li>
|
||||
{/each}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { commands } from "$lib/bindings";
|
||||
import { friends, friendsListenerError } from "$lib/listeners/friends";
|
||||
import {
|
||||
friendName,
|
||||
friends,
|
||||
friendsListenerError,
|
||||
} from "$lib/listeners/friends";
|
||||
|
||||
let error = "";
|
||||
|
||||
@@ -11,10 +15,7 @@
|
||||
error = "";
|
||||
|
||||
try {
|
||||
await commands.createFriend({
|
||||
id: String(data.get("id") ?? ""),
|
||||
displayName: String(data.get("displayName") ?? ""),
|
||||
});
|
||||
await commands.createFriend(String(data.get("id") ?? ""));
|
||||
form.reset();
|
||||
} catch (err) {
|
||||
error = String(err);
|
||||
@@ -43,13 +44,6 @@
|
||||
</label>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<label>
|
||||
Display name
|
||||
<input name="displayName" required />
|
||||
</label>
|
||||
</p>
|
||||
|
||||
<button type="submit">Create friend</button>
|
||||
</form>
|
||||
|
||||
@@ -63,7 +57,7 @@
|
||||
<ul>
|
||||
{#each $friends as friend (friend.id)}
|
||||
<li>
|
||||
<span>{friend.displayName} ({friend.id})</span>
|
||||
<span>{friendName(friend, "Unknown friend")} ({friend.id})</span>
|
||||
<button type="button" onclick={() => deleteFriend(friend.id)}>Delete</button>
|
||||
</li>
|
||||
{/each}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { writable } from "svelte/store";
|
||||
import { commands, events, type User } from "$lib/bindings";
|
||||
import { commands, events, type Friend } from "$lib/bindings";
|
||||
|
||||
export const friends = writable<User[]>([]);
|
||||
export const UNRESOLVED_FRIEND_NAME = "Name pending resolution";
|
||||
|
||||
export function friendName(friend: Friend | undefined, fallback: string) {
|
||||
return friend?.displayName ?? (friend ? UNRESOLVED_FRIEND_NAME : fallback);
|
||||
}
|
||||
|
||||
export const friends = writable<Friend[]>([]);
|
||||
export const friendsListenerError = writable("");
|
||||
|
||||
export async function initFriendsListener() {
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { onDestroy } from "svelte";
|
||||
import { commands } from "$lib/bindings";
|
||||
import PanelMessage from "$lib/components/control-panel/panel-message.svelte";
|
||||
import Info from "$lib/icons/info.svelte";
|
||||
|
||||
let displayName = $state("");
|
||||
let friendId = $state("");
|
||||
let busy = $state(false);
|
||||
let error = $state("");
|
||||
let preview = $state<{
|
||||
status: "idle" | "loading" | "resolved" | "unresolved" | "unavailable";
|
||||
text: string;
|
||||
}>({ status: "idle", text: "" });
|
||||
let lookupTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let lookupGeneration = 0;
|
||||
|
||||
onDestroy(() => {
|
||||
lookupGeneration += 1;
|
||||
if (lookupTimer) clearTimeout(lookupTimer);
|
||||
});
|
||||
|
||||
async function closeWindow() {
|
||||
await getCurrentWindow().close();
|
||||
@@ -15,23 +26,52 @@
|
||||
|
||||
async function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const name = displayName.trim();
|
||||
const id = friendId.trim();
|
||||
if (!name || !id) {
|
||||
error = "Both display name and public key are required.";
|
||||
if (!id) {
|
||||
error = "Identification key is required.";
|
||||
return;
|
||||
}
|
||||
|
||||
busy = true;
|
||||
error = "";
|
||||
try {
|
||||
await commands.createFriend({ id, displayName: name });
|
||||
await commands.createFriend(id);
|
||||
await closeWindow();
|
||||
} catch (cause) {
|
||||
error = String(cause);
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleProfilePreview(event: Event) {
|
||||
const id = (event.currentTarget as HTMLTextAreaElement).value.trim();
|
||||
const generation = ++lookupGeneration;
|
||||
if (lookupTimer) clearTimeout(lookupTimer);
|
||||
if (!id) {
|
||||
preview = { status: "idle", text: "" };
|
||||
return;
|
||||
}
|
||||
|
||||
preview = { status: "loading", text: "Resolving…" };
|
||||
lookupTimer = setTimeout(
|
||||
() => void resolveProfilePreview(id, generation),
|
||||
250,
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveProfilePreview(id: string, generation: number) {
|
||||
try {
|
||||
const displayName = await commands.resolveFriendDisplayName(id);
|
||||
if (generation !== lookupGeneration) return;
|
||||
preview = displayName
|
||||
? { status: "resolved", text: displayName }
|
||||
: { status: "unresolved", text: "Unresolved user" };
|
||||
} catch {
|
||||
if (generation === lookupGeneration) {
|
||||
preview = { status: "unavailable", text: "Unresolved" };
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Add Friend</title></svelte:head>
|
||||
@@ -46,18 +86,14 @@
|
||||
<div class="min-h-0 flex-1 space-y-2">
|
||||
{#if error}<PanelMessage kind="error">{error}</PanelMessage>{/if}
|
||||
|
||||
<PanelMessage>
|
||||
Their display name will appear after Wyd learns their profile from a
|
||||
shared server.
|
||||
</PanelMessage>
|
||||
|
||||
<fieldset class="fieldset border border-base-300 bg-base-100 p-3">
|
||||
<legend class="fieldset-legend px-1">Friend identity</legend>
|
||||
<label class="fieldset-label" for="friend-name">Display name</label>
|
||||
<input
|
||||
id="friend-name"
|
||||
class="input w-full"
|
||||
bind:value={displayName}
|
||||
maxlength="64"
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
<label class="fieldset-label mt-1" for="friend-key"
|
||||
<label class="fieldset-label" for="friend-key"
|
||||
>Identification Key <div
|
||||
class="tooltip tooltip-primary"
|
||||
data-tip="Verify with your friend before adding."
|
||||
@@ -71,7 +107,20 @@
|
||||
id="friend-key"
|
||||
class="textarea h-20 w-full resize-none font-mono text-[10px]"
|
||||
bind:value={friendId}
|
||||
autocomplete="off"
|
||||
oninput={scheduleProfilePreview}
|
||||
required></textarea>
|
||||
<label class="fieldset-label mt-1" for="friend-display-name"
|
||||
>Display name</label
|
||||
>
|
||||
<input
|
||||
id="friend-display-name"
|
||||
class:italic={preview.status !== "resolved"}
|
||||
class="input w-full"
|
||||
value={preview.text}
|
||||
aria-busy={preview.status === "loading"}
|
||||
readonly
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import Popover from "$lib/components/popover.svelte";
|
||||
import { friends } from "$lib/listeners/friends";
|
||||
import { friendName, friends } from "$lib/listeners/friends";
|
||||
import { onlineFriendIds } from "$lib/listeners/friend-statuses";
|
||||
import { incomingInteraction } from "$lib/listeners/interactions";
|
||||
import { liveMetadata } from "$lib/listeners/live-metadata";
|
||||
@@ -44,8 +44,10 @@
|
||||
function displayName(userId: string) {
|
||||
return $profile?.id === userId
|
||||
? $profile.displayName
|
||||
: ($friends.find((friend) => friend.id === userId)?.displayName ??
|
||||
"Unknown user");
|
||||
: friendName(
|
||||
$friends.find((friend) => friend.id === userId),
|
||||
"Unknown user",
|
||||
);
|
||||
}
|
||||
|
||||
function dismissInteraction(interactionId: string) {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
type AppMeta,
|
||||
type InteractionContent,
|
||||
} from "$lib/bindings";
|
||||
import { friends } from "$lib/listeners/friends";
|
||||
import { friendName, friends } from "$lib/listeners/friends";
|
||||
import { profile } from "$lib/listeners/profile";
|
||||
|
||||
type Props = {
|
||||
@@ -33,8 +33,10 @@
|
||||
let username = $derived(
|
||||
$profile?.id === userId
|
||||
? $profile.displayName
|
||||
: ($friends.find((friend) => friend.id === userId)?.displayName ??
|
||||
"Unknown user"),
|
||||
: friendName(
|
||||
$friends.find((friend) => friend.id === userId),
|
||||
"Unknown user",
|
||||
),
|
||||
);
|
||||
|
||||
function selectMode(nextMode: "message" | "image") {
|
||||
|
||||
Reference in New Issue
Block a user