remove "display name" from adding new friends
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
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_INTERACTION_PAYLOAD_BYTES: usize = 160 * 1024;
|
||||||
pub const MAX_IMAGE_B64_SIZE: usize = 150 * 1024;
|
pub const MAX_IMAGE_B64_SIZE: usize = 150 * 1024;
|
||||||
pub const MAX_IMAGE_DIMENSION: u32 = 480;
|
pub const MAX_IMAGE_DIMENSION: u32 = 480;
|
||||||
@@ -32,6 +32,10 @@ pub enum ClientMessage {
|
|||||||
},
|
},
|
||||||
SyncFriendProfiles,
|
SyncFriendProfiles,
|
||||||
SyncFriendStatuses,
|
SyncFriendStatuses,
|
||||||
|
ResolveProfile {
|
||||||
|
request_id: String,
|
||||||
|
user_id: String,
|
||||||
|
},
|
||||||
Signed {
|
Signed {
|
||||||
payload: String,
|
payload: String,
|
||||||
signature: String,
|
signature: String,
|
||||||
@@ -107,6 +111,10 @@ pub enum ServerMessage {
|
|||||||
FriendProfiles {
|
FriendProfiles {
|
||||||
profiles: Vec<Profile>,
|
profiles: Vec<Profile>,
|
||||||
},
|
},
|
||||||
|
ProfileResolved {
|
||||||
|
request_id: String,
|
||||||
|
profile: Option<Profile>,
|
||||||
|
},
|
||||||
FriendStatusChanged {
|
FriendStatusChanged {
|
||||||
friend_id: String,
|
friend_id: String,
|
||||||
online: bool,
|
online: bool,
|
||||||
|
|||||||
+29
-220
@@ -13,19 +13,17 @@ use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
|||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use tokio::sync::{Mutex, mpsc};
|
use tokio::sync::{Mutex, mpsc};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use wyd_common::{
|
use wyd_common::{ClientMessage, Profile, ServerMessage, message_bytes, register_bytes};
|
||||||
ClientMessage, Profile, ServerMessage, message_bytes, profile_bytes, register_bytes,
|
|
||||||
};
|
|
||||||
|
|
||||||
mod interactions;
|
mod interactions;
|
||||||
mod presence;
|
mod presence;
|
||||||
|
mod profiles;
|
||||||
|
|
||||||
type Clients = Arc<Mutex<HashMap<String, Client>>>;
|
type Clients = Arc<Mutex<HashMap<String, Client>>>;
|
||||||
|
|
||||||
struct Client {
|
struct Client {
|
||||||
connection_id: Uuid,
|
connection_id: Uuid,
|
||||||
key: VerifyingKey,
|
key: VerifyingKey,
|
||||||
#[allow(dead_code)] // Used when presence and profile lookup are exposed.
|
|
||||||
profile: Profile,
|
profile: Profile,
|
||||||
friends: Vec<String>,
|
friends: Vec<String>,
|
||||||
sender: mpsc::Sender<Message>,
|
sender: mpsc::Sender<Message>,
|
||||||
@@ -102,6 +100,7 @@ async fn connected(mut socket: WebSocket, clients: Clients) {
|
|||||||
presence::disconnected(&clients, &public_key, connection_id).await;
|
presence::disconnected(&clients, &public_key, connection_id).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
profiles::broadcast(&clients, &public_key, connection_id).await;
|
||||||
presence::connected(&clients, &public_key, previous_friends).await;
|
presence::connected(&clients, &public_key, previous_friends).await;
|
||||||
|
|
||||||
let (mut writer, mut reader) = socket.split();
|
let (mut writer, mut reader) = socket.split();
|
||||||
@@ -148,7 +147,7 @@ async fn connected(mut socket: WebSocket, clients: Clients) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(ClientMessage::ProfileUpdated { profile, signature }) => {
|
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;
|
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() {
|
if writer.send(Message::Text(serde_json::to_string(&message).unwrap().into())).await.is_err() {
|
||||||
break;
|
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) => {
|
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;
|
break;
|
||||||
};
|
};
|
||||||
let message = ServerMessage::FriendProfiles { profiles };
|
let message = ServerMessage::FriendProfiles { profiles };
|
||||||
@@ -185,6 +191,23 @@ async fn connected(mut socket: WebSocket, clients: Clients) {
|
|||||||
break;
|
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,
|
_ => break,
|
||||||
}
|
}
|
||||||
Some(Ok(Message::Ping(data))) => {
|
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;
|
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(
|
async fn relay_live_data(
|
||||||
clients: &Clients,
|
clients: &Clients,
|
||||||
public_key: &str,
|
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]
|
#[tokio::test]
|
||||||
async fn authenticated_friend_update_changes_only_ids() {
|
async fn authenticated_friend_update_changes_only_ids() {
|
||||||
let signing_key = SigningKey::from_bytes(&[7; 32]);
|
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]
|
#[tokio::test]
|
||||||
async fn presence_sync_and_disconnect_broadcast_require_mutual_friendship() {
|
async fn presence_sync_and_disconnect_broadcast_require_mutual_friendship() {
|
||||||
let signing_key = SigningKey::from_bytes(&[7; 32]);
|
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::db::{self, AppDatabase};
|
||||||
use crate::user::User;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
use tauri::{AppHandle, State};
|
use tauri::{AppHandle, State};
|
||||||
use tauri_specta::Event;
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct FriendsChanged {
|
pub struct FriendsChanged {
|
||||||
pub friends: Vec<User>,
|
pub friends: Vec<Friend>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn all(database: &AppDatabase) -> Result<Vec<User>, sqlx::Error> {
|
pub(crate) async fn all(database: &AppDatabase) -> Result<Vec<Friend>, sqlx::Error> {
|
||||||
sqlx::query_as::<_, User>(
|
sqlx::query_as::<_, Friend>(
|
||||||
"SELECT id, display_name FROM friends ORDER BY display_name COLLATE NOCASE, id",
|
"SELECT id, display_name FROM friends \
|
||||||
|
ORDER BY display_name IS NULL, display_name COLLATE NOCASE, id",
|
||||||
)
|
)
|
||||||
.fetch_all(database.pool())
|
.fetch_all(database.pool())
|
||||||
.await
|
.await
|
||||||
@@ -35,7 +46,7 @@ async fn update_display_names(
|
|||||||
let mut changed = false;
|
let mut changed = false;
|
||||||
for profile in profiles {
|
for profile in profiles {
|
||||||
let result = sqlx::query(
|
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.display_name)
|
||||||
.bind(&profile.id)
|
.bind(&profile.id)
|
||||||
@@ -74,11 +85,21 @@ pub(crate) async fn apply_profile_sync(
|
|||||||
pub async fn create_friend(
|
pub async fn create_friend(
|
||||||
handle: AppHandle,
|
handle: AppHandle,
|
||||||
database: State<'_, AppDatabase>,
|
database: State<'_, AppDatabase>,
|
||||||
friend: User,
|
keypair: State<'_, AppKeypair>,
|
||||||
) -> Result<User, String> {
|
id: String,
|
||||||
sqlx::query("INSERT INTO friends (id, display_name) VALUES (?1, ?2)")
|
) -> 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.id)
|
||||||
.bind(&friend.display_name)
|
|
||||||
.execute(database.pool())
|
.execute(database.pool())
|
||||||
.await
|
.await
|
||||||
.map_err(db::command_error)?;
|
.map_err(db::command_error)?;
|
||||||
@@ -93,7 +114,7 @@ pub async fn create_friend(
|
|||||||
pub async fn list_friends(
|
pub async fn list_friends(
|
||||||
handle: AppHandle,
|
handle: AppHandle,
|
||||||
database: State<'_, AppDatabase>,
|
database: State<'_, AppDatabase>,
|
||||||
) -> Result<Vec<User>, String> {
|
) -> Result<Vec<Friend>, String> {
|
||||||
let friends = all(&database).await.map_err(db::command_error)?;
|
let friends = all(&database).await.map_err(db::command_error)?;
|
||||||
|
|
||||||
FriendsChanged {
|
FriendsChanged {
|
||||||
@@ -110,8 +131,8 @@ pub async fn list_friends(
|
|||||||
pub async fn get_friend(
|
pub async fn get_friend(
|
||||||
database: State<'_, AppDatabase>,
|
database: State<'_, AppDatabase>,
|
||||||
id: String,
|
id: String,
|
||||||
) -> Result<Option<User>, String> {
|
) -> Result<Option<Friend>, String> {
|
||||||
sqlx::query_as::<_, User>("SELECT id, display_name FROM friends WHERE id = ?1")
|
sqlx::query_as::<_, Friend>("SELECT id, display_name FROM friends WHERE id = ?1")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_optional(database.pool())
|
.fetch_optional(database.pool())
|
||||||
.await
|
.await
|
||||||
@@ -199,9 +220,9 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
all(&database).await.unwrap(),
|
all(&database).await.unwrap(),
|
||||||
[User {
|
[Friend {
|
||||||
id: "friend-id".to_owned(),
|
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,
|
keypair::get_public_key,
|
||||||
network::list_statuses,
|
network::list_statuses,
|
||||||
network::list_friend_statuses,
|
network::list_friend_statuses,
|
||||||
|
network::resolve_friend_display_name,
|
||||||
images::pick_and_send_image,
|
images::pick_and_send_image,
|
||||||
images::send_image_bytes,
|
images::send_image_bytes,
|
||||||
interactions::send_interaction,
|
interactions::send_interaction,
|
||||||
|
|||||||
+120
-30
@@ -62,6 +62,7 @@ struct Connection {
|
|||||||
remote: Remote,
|
remote: Remote,
|
||||||
sender: mpsc::Sender<String>,
|
sender: mpsc::Sender<String>,
|
||||||
interaction_sender: mpsc::Sender<InteractionRequest>,
|
interaction_sender: mpsc::Sender<InteractionRequest>,
|
||||||
|
profile_lookup_sender: mpsc::Sender<ProfileLookupRequest>,
|
||||||
task: tauri::async_runtime::JoinHandle<()>,
|
task: tauri::async_runtime::JoinHandle<()>,
|
||||||
generation: u64,
|
generation: u64,
|
||||||
}
|
}
|
||||||
@@ -73,6 +74,21 @@ struct InteractionRequest {
|
|||||||
response: oneshot::Sender<InteractionDeliveryStatus>,
|
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 {
|
pub struct Network {
|
||||||
connections: Mutex<HashMap<String, Connection>>,
|
connections: Mutex<HashMap<String, Connection>>,
|
||||||
statuses: Statuses,
|
statuses: Statuses,
|
||||||
@@ -167,6 +183,45 @@ impl Network {
|
|||||||
Err("Friend is no longer available".to_owned())
|
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> {
|
fn sync_remotes(&self, handle: &AppHandle, remotes: Vec<Remote>) -> Result<(), String> {
|
||||||
let desired: HashMap<_, _> = remotes
|
let desired: HashMap<_, _> = remotes
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -196,6 +251,7 @@ impl Network {
|
|||||||
let generation = self.next_generation.fetch_add(1, Ordering::Relaxed);
|
let generation = self.next_generation.fetch_add(1, Ordering::Relaxed);
|
||||||
let (sender, receiver) = mpsc::channel(32);
|
let (sender, receiver) = mpsc::channel(32);
|
||||||
let (interaction_sender, interaction_receiver) = mpsc::channel(16);
|
let (interaction_sender, interaction_receiver) = mpsc::channel(16);
|
||||||
|
let (profile_lookup_sender, profile_lookup_receiver) = mpsc::channel(16);
|
||||||
set_initial(
|
set_initial(
|
||||||
&self.statuses,
|
&self.statuses,
|
||||||
&remote,
|
&remote,
|
||||||
@@ -208,11 +264,14 @@ impl Network {
|
|||||||
self.friend_presence.clone(),
|
self.friend_presence.clone(),
|
||||||
remote.clone(),
|
remote.clone(),
|
||||||
generation,
|
generation,
|
||||||
self.profile.subscribe(),
|
ConnectionInputs {
|
||||||
self.friends.subscribe(),
|
profiles: self.profile.subscribe(),
|
||||||
self.keypair.clone(),
|
friends: self.friends.subscribe(),
|
||||||
receiver,
|
keypair: self.keypair.clone(),
|
||||||
interaction_receiver,
|
live_data: receiver,
|
||||||
|
interactions: interaction_receiver,
|
||||||
|
profile_lookups: profile_lookup_receiver,
|
||||||
|
},
|
||||||
));
|
));
|
||||||
connections.insert(
|
connections.insert(
|
||||||
remote.id.clone(),
|
remote.id.clone(),
|
||||||
@@ -220,6 +279,7 @@ impl Network {
|
|||||||
remote,
|
remote,
|
||||||
sender,
|
sender,
|
||||||
interaction_sender,
|
interaction_sender,
|
||||||
|
profile_lookup_sender,
|
||||||
task,
|
task,
|
||||||
generation,
|
generation,
|
||||||
},
|
},
|
||||||
@@ -280,11 +340,7 @@ async fn run(
|
|||||||
friend_presence: Arc<FriendPresence>,
|
friend_presence: Arc<FriendPresence>,
|
||||||
remote: Remote,
|
remote: Remote,
|
||||||
generation: u64,
|
generation: u64,
|
||||||
mut profiles: watch::Receiver<crate::user::User>,
|
mut inputs: ConnectionInputs,
|
||||||
mut friends: watch::Receiver<Vec<String>>,
|
|
||||||
keypair: AppKeypair,
|
|
||||||
mut outgoing: mpsc::Receiver<String>,
|
|
||||||
mut active_outgoing: mpsc::Receiver<InteractionRequest>,
|
|
||||||
) {
|
) {
|
||||||
loop {
|
loop {
|
||||||
changed(
|
changed(
|
||||||
@@ -300,11 +356,7 @@ async fn run(
|
|||||||
&friend_presence,
|
&friend_presence,
|
||||||
&remote,
|
&remote,
|
||||||
generation,
|
generation,
|
||||||
&mut profiles,
|
&mut inputs,
|
||||||
&mut friends,
|
|
||||||
&keypair,
|
|
||||||
&mut outgoing,
|
|
||||||
&mut active_outgoing,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -328,12 +380,16 @@ async fn connect(
|
|||||||
friend_presence: &FriendPresence,
|
friend_presence: &FriendPresence,
|
||||||
remote: &Remote,
|
remote: &Remote,
|
||||||
generation: u64,
|
generation: u64,
|
||||||
profiles: &mut watch::Receiver<crate::user::User>,
|
inputs: &mut ConnectionInputs,
|
||||||
friends: &mut watch::Receiver<Vec<String>>,
|
|
||||||
keypair: &AppKeypair,
|
|
||||||
outgoing: &mut mpsc::Receiver<String>,
|
|
||||||
active_outgoing: &mut mpsc::Receiver<InteractionRequest>,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
) -> 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 (socket, _) = tokio_tungstenite::connect_async(url(remote)).await?;
|
||||||
let (mut writer, mut reader) = socket.split();
|
let (mut writer, mut reader) = socket.split();
|
||||||
|
|
||||||
@@ -366,7 +422,7 @@ async fn connect(
|
|||||||
if !matches!(recv(&mut reader).await?, ServerMessage::Registered) {
|
if !matches!(recv(&mut reader).await?, ServerMessage::Registered) {
|
||||||
return Err("server rejected registration".into());
|
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() {
|
while let Ok(request) = active_outgoing.try_recv() {
|
||||||
let _ = request
|
let _ = request
|
||||||
.response
|
.response
|
||||||
@@ -383,9 +439,11 @@ async fn connect(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let mut pending_interactions = HashMap::new();
|
let mut pending_interactions = HashMap::new();
|
||||||
|
let mut pending_profile_lookups: HashMap<String, (String, oneshot::Sender<Option<String>>)> =
|
||||||
|
HashMap::new();
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
payload = outgoing.recv() => {
|
payload = live_data.recv() => {
|
||||||
let payload = payload.ok_or("network sender closed")?;
|
let payload = payload.ok_or("network sender closed")?;
|
||||||
send(&mut writer, &ClientMessage::Signed {
|
send(&mut writer, &ClientMessage::Signed {
|
||||||
signature: keypair.sign(&message_bytes(&payload)),
|
signature: keypair.sign(&message_bytes(&payload)),
|
||||||
@@ -407,6 +465,21 @@ async fn connect(
|
|||||||
}).await?;
|
}).await?;
|
||||||
pending_interactions.insert(request.interaction_id, request.response);
|
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 = profiles.changed() => {
|
||||||
changed.map_err(|_| "profile sender closed")?;
|
changed.map_err(|_| "profile sender closed")?;
|
||||||
let current = profiles.borrow_and_update().clone();
|
let current = profiles.borrow_and_update().clone();
|
||||||
@@ -481,6 +554,14 @@ async fn connect(
|
|||||||
let _ = response.send(status);
|
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?,
|
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()
|
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(
|
fn update_friend_presence(
|
||||||
handle: &AppHandle,
|
handle: &AppHandle,
|
||||||
presence: &FriendPresence,
|
presence: &FriendPresence,
|
||||||
@@ -658,7 +748,7 @@ fn url(remote: &Remote) -> String {
|
|||||||
format!("{scheme}://{address}{port}/v1/ws")
|
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
|
let mut ids: Vec<_> = friends
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|friend| friend.id)
|
.map(|friend| friend.id)
|
||||||
@@ -672,22 +762,22 @@ fn friend_ids(friends: Vec<crate::user::User>, own_id: &str) -> Vec<String> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::friend_ids;
|
use super::friend_ids;
|
||||||
use crate::user::User;
|
use crate::friends::Friend;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn friend_ids_discards_display_names_and_normalizes_ids() {
|
fn friend_ids_discards_display_names_and_normalizes_ids() {
|
||||||
let friends = vec![
|
let friends = vec![
|
||||||
User {
|
Friend {
|
||||||
id: "friend-b".to_owned(),
|
id: "friend-b".to_owned(),
|
||||||
display_name: "Old name".to_owned(),
|
display_name: Some("Old name".to_owned()),
|
||||||
},
|
},
|
||||||
User {
|
Friend {
|
||||||
id: "self".to_owned(),
|
id: "self".to_owned(),
|
||||||
display_name: "Me".to_owned(),
|
display_name: None,
|
||||||
},
|
},
|
||||||
User {
|
Friend {
|
||||||
id: "friend-a".to_owned(),
|
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 serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
|
|
||||||
@@ -9,3 +12,29 @@ pub struct User {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
pub display_name: 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 = {
|
export const commands = {
|
||||||
async createFriend(friend: User) : Promise<User> {
|
async createFriend(id: string) : Promise<Friend> {
|
||||||
return await TAURI_INVOKE("create_friend", { friend });
|
return await TAURI_INVOKE("create_friend", { id });
|
||||||
},
|
},
|
||||||
async listFriends() : Promise<User[]> {
|
async listFriends() : Promise<Friend[]> {
|
||||||
return await TAURI_INVOKE("list_friends");
|
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 });
|
return await TAURI_INVOKE("get_friend", { id });
|
||||||
},
|
},
|
||||||
async deleteFriend(id: string) : Promise<boolean> {
|
async deleteFriend(id: string) : Promise<boolean> {
|
||||||
@@ -47,6 +47,9 @@ async listStatuses() : Promise<ConnectionStatus[]> {
|
|||||||
async listFriendStatuses() : Promise<string[]> {
|
async listFriendStatuses() : Promise<string[]> {
|
||||||
return await TAURI_INVOKE("list_friend_statuses");
|
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> {
|
async pickAndSendImage(recipientId: string) : Promise<boolean> {
|
||||||
return await TAURI_INVOKE("pick_and_send_image", { recipientId });
|
return await TAURI_INVOKE("pick_and_send_image", { recipientId });
|
||||||
},
|
},
|
||||||
@@ -113,10 +116,15 @@ raw: CursorPosition;
|
|||||||
*/
|
*/
|
||||||
mapped: CursorPosition }
|
mapped: CursorPosition }
|
||||||
export type ForegroundAppChanged = { meta: AppMeta }
|
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 FriendForegroundAppChanged = { friendId: string; meta: AppMeta }
|
||||||
export type FriendInteractionReceived = { interactionId: string; friendId: string; content: InteractionContent }
|
export type FriendInteractionReceived = { interactionId: string; friendId: string; content: InteractionContent }
|
||||||
export type FriendStatusesChanged = { friendIds: string[] }
|
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 InteractionContent = { type: "text"; text: string } | { type: "wave" } | { type: "image"; mediaType: string; data: string }
|
||||||
export type NetworkStatusChanged = { statuses: ConnectionStatus[] }
|
export type NetworkStatusChanged = { statuses: ConnectionStatus[] }
|
||||||
export type ProfileChanged = { profile: User }
|
export type ProfileChanged = { profile: User }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { friends } from "$lib/listeners/friends";
|
import { friendName, friends } from "$lib/listeners/friends";
|
||||||
import {
|
import {
|
||||||
liveMetadata,
|
liveMetadata,
|
||||||
liveMetadataListenerError,
|
liveMetadataListenerError,
|
||||||
@@ -25,9 +25,9 @@
|
|||||||
|
|
||||||
function nameFor(id: string) {
|
function nameFor(id: string) {
|
||||||
if (id === $liveMetadata.localId) return "You";
|
if (id === $liveMetadata.localId) return "You";
|
||||||
return (
|
return friendName(
|
||||||
$friends.find((friend) => friend.id === id)?.displayName ??
|
$friends.find((friend) => friend.id === id),
|
||||||
"Unknown friend"
|
"Unknown friend",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,15 @@
|
|||||||
friendStatusesListenerError,
|
friendStatusesListenerError,
|
||||||
onlineFriendIds,
|
onlineFriendIds,
|
||||||
} from "$lib/listeners/friend-statuses";
|
} 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 { onMount } from "svelte";
|
||||||
import PanelMessage from "./panel-message.svelte";
|
import PanelMessage from "./panel-message.svelte";
|
||||||
import type { RegisterPanel } from "./types";
|
import type { RegisterPanel } from "./types";
|
||||||
|
import Info from "$lib/icons/info.svelte";
|
||||||
|
|
||||||
let { register }: { register: RegisterPanel } = $props();
|
let { register }: { register: RegisterPanel } = $props();
|
||||||
|
|
||||||
@@ -85,8 +90,9 @@
|
|||||||
|
|
||||||
{#if mode === "remove" && selected}
|
{#if mode === "remove" && selected}
|
||||||
<PanelMessage kind="warning">
|
<PanelMessage kind="warning">
|
||||||
Applying will remove <strong>{selected.displayName}</strong>. They will
|
Applying will remove <strong
|
||||||
immediately go offline and stop receiving your activity.
|
>{friendName(selected, "Unknown friend")}</strong
|
||||||
|
>. They will immediately go offline and stop receiving your activity.
|
||||||
</PanelMessage>
|
</PanelMessage>
|
||||||
<button class="btn w-fit" type="button" onclick={() => (mode = "browse")}
|
<button class="btn w-fit" type="button" onclick={() => (mode = "browse")}
|
||||||
>Keep friend</button
|
>Keep friend</button
|
||||||
@@ -126,7 +132,23 @@
|
|||||||
ondblclick={() => (selectedId = friend.id)}
|
ondblclick={() => (selectedId = friend.id)}
|
||||||
>
|
>
|
||||||
<span class="min-w-0">
|
<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"
|
<span class="block truncate font-mono text-[9px] opacity-65"
|
||||||
>{friend.id}</span
|
>{friend.id}</span
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,7 +3,11 @@
|
|||||||
friendStatusesListenerError,
|
friendStatusesListenerError,
|
||||||
onlineFriendIds,
|
onlineFriendIds,
|
||||||
} from "$lib/listeners/friend-statuses";
|
} from "$lib/listeners/friend-statuses";
|
||||||
import { friends, friendsListenerError } from "$lib/listeners/friends";
|
import {
|
||||||
|
friendName,
|
||||||
|
friends,
|
||||||
|
friendsListenerError,
|
||||||
|
} from "$lib/listeners/friends";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
@@ -20,7 +24,7 @@
|
|||||||
{#each $friends as friend (friend.id)}
|
{#each $friends as friend (friend.id)}
|
||||||
{@const online = $onlineFriendIds.has(friend.id)}
|
{@const online = $onlineFriendIds.has(friend.id)}
|
||||||
<li>
|
<li>
|
||||||
<strong>{friend.displayName}</strong>: {online ? "Online" : "Offline"}
|
<strong>{friendName(friend, "Unknown friend")}</strong>: {online ? "Online" : "Offline"}
|
||||||
<small>({friend.id})</small>
|
<small>({friend.id})</small>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { commands } from "$lib/bindings";
|
import { commands } from "$lib/bindings";
|
||||||
import { friends, friendsListenerError } from "$lib/listeners/friends";
|
import {
|
||||||
|
friendName,
|
||||||
|
friends,
|
||||||
|
friendsListenerError,
|
||||||
|
} from "$lib/listeners/friends";
|
||||||
|
|
||||||
let error = "";
|
let error = "";
|
||||||
|
|
||||||
@@ -11,10 +15,7 @@
|
|||||||
error = "";
|
error = "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await commands.createFriend({
|
await commands.createFriend(String(data.get("id") ?? ""));
|
||||||
id: String(data.get("id") ?? ""),
|
|
||||||
displayName: String(data.get("displayName") ?? ""),
|
|
||||||
});
|
|
||||||
form.reset();
|
form.reset();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = String(err);
|
error = String(err);
|
||||||
@@ -43,13 +44,6 @@
|
|||||||
</label>
|
</label>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
|
||||||
<label>
|
|
||||||
Display name
|
|
||||||
<input name="displayName" required />
|
|
||||||
</label>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<button type="submit">Create friend</button>
|
<button type="submit">Create friend</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -63,7 +57,7 @@
|
|||||||
<ul>
|
<ul>
|
||||||
{#each $friends as friend (friend.id)}
|
{#each $friends as friend (friend.id)}
|
||||||
<li>
|
<li>
|
||||||
<span>{friend.displayName} ({friend.id})</span>
|
<span>{friendName(friend, "Unknown friend")} ({friend.id})</span>
|
||||||
<button type="button" onclick={() => deleteFriend(friend.id)}>Delete</button>
|
<button type="button" onclick={() => deleteFriend(friend.id)}>Delete</button>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { writable } from "svelte/store";
|
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 const friendsListenerError = writable("");
|
||||||
|
|
||||||
export async function initFriendsListener() {
|
export async function initFriendsListener() {
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
import { onDestroy } from "svelte";
|
||||||
import { commands } from "$lib/bindings";
|
import { commands } from "$lib/bindings";
|
||||||
import PanelMessage from "$lib/components/control-panel/panel-message.svelte";
|
import PanelMessage from "$lib/components/control-panel/panel-message.svelte";
|
||||||
import Info from "$lib/icons/info.svelte";
|
import Info from "$lib/icons/info.svelte";
|
||||||
|
|
||||||
let displayName = $state("");
|
|
||||||
let friendId = $state("");
|
let friendId = $state("");
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let error = $state("");
|
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() {
|
async function closeWindow() {
|
||||||
await getCurrentWindow().close();
|
await getCurrentWindow().close();
|
||||||
@@ -15,23 +26,52 @@
|
|||||||
|
|
||||||
async function submit(event: SubmitEvent) {
|
async function submit(event: SubmitEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const name = displayName.trim();
|
|
||||||
const id = friendId.trim();
|
const id = friendId.trim();
|
||||||
if (!name || !id) {
|
if (!id) {
|
||||||
error = "Both display name and public key are required.";
|
error = "Identification key is required.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
busy = true;
|
busy = true;
|
||||||
error = "";
|
error = "";
|
||||||
try {
|
try {
|
||||||
await commands.createFriend({ id, displayName: name });
|
await commands.createFriend(id);
|
||||||
await closeWindow();
|
await closeWindow();
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
error = String(cause);
|
error = String(cause);
|
||||||
busy = false;
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>Add Friend</title></svelte:head>
|
<svelte:head><title>Add Friend</title></svelte:head>
|
||||||
@@ -46,18 +86,14 @@
|
|||||||
<div class="min-h-0 flex-1 space-y-2">
|
<div class="min-h-0 flex-1 space-y-2">
|
||||||
{#if error}<PanelMessage kind="error">{error}</PanelMessage>{/if}
|
{#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">
|
<fieldset class="fieldset border border-base-300 bg-base-100 p-3">
|
||||||
<legend class="fieldset-legend px-1">Friend identity</legend>
|
<legend class="fieldset-legend px-1">Friend identity</legend>
|
||||||
<label class="fieldset-label" for="friend-name">Display name</label>
|
<label class="fieldset-label" for="friend-key"
|
||||||
<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"
|
|
||||||
>Identification Key <div
|
>Identification Key <div
|
||||||
class="tooltip tooltip-primary"
|
class="tooltip tooltip-primary"
|
||||||
data-tip="Verify with your friend before adding."
|
data-tip="Verify with your friend before adding."
|
||||||
@@ -71,7 +107,20 @@
|
|||||||
id="friend-key"
|
id="friend-key"
|
||||||
class="textarea h-20 w-full resize-none font-mono text-[10px]"
|
class="textarea h-20 w-full resize-none font-mono text-[10px]"
|
||||||
bind:value={friendId}
|
bind:value={friendId}
|
||||||
|
autocomplete="off"
|
||||||
|
oninput={scheduleProfilePreview}
|
||||||
required></textarea>
|
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>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import Popover from "$lib/components/popover.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 { onlineFriendIds } from "$lib/listeners/friend-statuses";
|
||||||
import { incomingInteraction } from "$lib/listeners/interactions";
|
import { incomingInteraction } from "$lib/listeners/interactions";
|
||||||
import { liveMetadata } from "$lib/listeners/live-metadata";
|
import { liveMetadata } from "$lib/listeners/live-metadata";
|
||||||
@@ -44,8 +44,10 @@
|
|||||||
function displayName(userId: string) {
|
function displayName(userId: string) {
|
||||||
return $profile?.id === userId
|
return $profile?.id === userId
|
||||||
? $profile.displayName
|
? $profile.displayName
|
||||||
: ($friends.find((friend) => friend.id === userId)?.displayName ??
|
: friendName(
|
||||||
"Unknown user");
|
$friends.find((friend) => friend.id === userId),
|
||||||
|
"Unknown user",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function dismissInteraction(interactionId: string) {
|
function dismissInteraction(interactionId: string) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
type AppMeta,
|
type AppMeta,
|
||||||
type InteractionContent,
|
type InteractionContent,
|
||||||
} from "$lib/bindings";
|
} from "$lib/bindings";
|
||||||
import { friends } from "$lib/listeners/friends";
|
import { friendName, friends } from "$lib/listeners/friends";
|
||||||
import { profile } from "$lib/listeners/profile";
|
import { profile } from "$lib/listeners/profile";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -33,8 +33,10 @@
|
|||||||
let username = $derived(
|
let username = $derived(
|
||||||
$profile?.id === userId
|
$profile?.id === userId
|
||||||
? $profile.displayName
|
? $profile.displayName
|
||||||
: ($friends.find((friend) => friend.id === userId)?.displayName ??
|
: friendName(
|
||||||
"Unknown user"),
|
$friends.find((friend) => friend.id === userId),
|
||||||
|
"Unknown user",
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
function selectMode(nextMode: "message" | "image") {
|
function selectMode(nextMode: "message" | "image") {
|
||||||
|
|||||||
Reference in New Issue
Block a user