friends connection status system
This commit is contained in:
@@ -31,6 +31,7 @@ pub enum ClientMessage {
|
|||||||
signature: String,
|
signature: String,
|
||||||
},
|
},
|
||||||
SyncFriendProfiles,
|
SyncFriendProfiles,
|
||||||
|
SyncFriendStatuses,
|
||||||
Signed {
|
Signed {
|
||||||
payload: String,
|
payload: String,
|
||||||
signature: String,
|
signature: String,
|
||||||
@@ -106,6 +107,13 @@ pub enum ServerMessage {
|
|||||||
FriendProfiles {
|
FriendProfiles {
|
||||||
profiles: Vec<Profile>,
|
profiles: Vec<Profile>,
|
||||||
},
|
},
|
||||||
|
FriendStatusChanged {
|
||||||
|
friend_id: String,
|
||||||
|
online: bool,
|
||||||
|
},
|
||||||
|
FriendStatuses {
|
||||||
|
friend_ids: Vec<String>,
|
||||||
|
},
|
||||||
FriendLiveData {
|
FriendLiveData {
|
||||||
friend_id: String,
|
friend_id: String,
|
||||||
payload: String,
|
payload: String,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use wyd_common::{
|
|||||||
MAX_INTERACTION_PAYLOAD_BYTES, ServerMessage, interaction_bytes,
|
MAX_INTERACTION_PAYLOAD_BYTES, ServerMessage, interaction_bytes,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{Clients, verify};
|
use super::{Clients, are_mutual_friends, verify};
|
||||||
|
|
||||||
pub(super) async fn relay(
|
pub(super) async fn relay(
|
||||||
clients: &Clients,
|
clients: &Clients,
|
||||||
@@ -28,10 +28,7 @@ pub(super) async fn relay(
|
|||||||
.filter(|client| client.connection_id == connection_id)?;
|
.filter(|client| client.connection_id == connection_id)?;
|
||||||
let recipient = clients
|
let recipient = clients
|
||||||
.get(recipient_id)
|
.get(recipient_id)
|
||||||
.filter(|recipient| {
|
.filter(|recipient| are_mutual_friends(public_key, source, recipient_id, recipient))
|
||||||
source.friends.iter().any(|friend| friend == recipient_id)
|
|
||||||
&& recipient.friends.iter().any(|friend| friend == public_key)
|
|
||||||
})
|
|
||||||
.map(|recipient| recipient.sender.clone());
|
.map(|recipient| recipient.sender.clone());
|
||||||
(source.key, recipient)
|
(source.key, recipient)
|
||||||
};
|
};
|
||||||
|
|||||||
+155
-41
@@ -14,11 +14,11 @@ 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, friends_bytes, message_bytes, profile_bytes,
|
ClientMessage, Profile, ServerMessage, message_bytes, profile_bytes, register_bytes,
|
||||||
register_bytes,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
mod interactions;
|
mod interactions;
|
||||||
|
mod presence;
|
||||||
|
|
||||||
type Clients = Arc<Mutex<HashMap<String, Client>>>;
|
type Clients = Arc<Mutex<HashMap<String, Client>>>;
|
||||||
|
|
||||||
@@ -31,6 +31,11 @@ struct Client {
|
|||||||
sender: mpsc::Sender<Message>,
|
sender: mpsc::Sender<Message>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn are_mutual_friends(source_id: &str, source: &Client, friend_id: &str, friend: &Client) -> bool {
|
||||||
|
source.friends.iter().any(|id| id == friend_id)
|
||||||
|
&& friend.friends.iter().any(|id| id == source_id)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn routes() -> Router {
|
pub fn routes() -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/v1/ws", get(upgrade))
|
.route("/v1/ws", get(upgrade))
|
||||||
@@ -79,7 +84,10 @@ async fn connected(mut socket: WebSocket, clients: Clients) {
|
|||||||
let connection_id = Uuid::new_v4();
|
let connection_id = Uuid::new_v4();
|
||||||
let public_key = profile.id.clone();
|
let public_key = profile.id.clone();
|
||||||
let (sender, mut outgoing) = mpsc::channel(32);
|
let (sender, mut outgoing) = mpsc::channel(32);
|
||||||
clients.lock().await.insert(
|
let previous_friends = clients
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(
|
||||||
public_key.clone(),
|
public_key.clone(),
|
||||||
Client {
|
Client {
|
||||||
connection_id,
|
connection_id,
|
||||||
@@ -88,11 +96,13 @@ async fn connected(mut socket: WebSocket, clients: Clients) {
|
|||||||
friends,
|
friends,
|
||||||
sender,
|
sender,
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
|
.map(|client| client.friends);
|
||||||
if send(&mut socket, &ServerMessage::Registered).await.is_err() {
|
if send(&mut socket, &ServerMessage::Registered).await.is_err() {
|
||||||
remove(&clients, &public_key, connection_id).await;
|
presence::disconnected(&clients, &public_key, connection_id).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
presence::connected(&clients, &public_key, previous_friends).await;
|
||||||
|
|
||||||
let (mut writer, mut reader) = socket.split();
|
let (mut writer, mut reader) = socket.split();
|
||||||
let mut ping = tokio::time::interval(Duration::from_secs(20));
|
let mut ping = tokio::time::interval(Duration::from_secs(20));
|
||||||
@@ -143,7 +153,17 @@ async fn connected(mut socket: WebSocket, clients: Clients) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(ClientMessage::FriendsUpdated { friends, signature }) => {
|
Ok(ClientMessage::FriendsUpdated { friends, signature }) => {
|
||||||
if !update_friends(&clients, &public_key, connection_id, friends, &signature).await {
|
let Some(friend_ids) = presence::update_friends(
|
||||||
|
&clients,
|
||||||
|
&public_key,
|
||||||
|
connection_id,
|
||||||
|
friends,
|
||||||
|
&signature,
|
||||||
|
).await else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let message = ServerMessage::FriendStatuses { friend_ids };
|
||||||
|
if writer.send(Message::Text(serde_json::to_string(&message).unwrap().into())).await.is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,6 +176,15 @@ async fn connected(mut socket: WebSocket, clients: Clients) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(ClientMessage::SyncFriendStatuses) => {
|
||||||
|
let Some(friend_ids) = presence::snapshot(&clients, &public_key, connection_id).await else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let message = ServerMessage::FriendStatuses { friend_ids };
|
||||||
|
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))) => {
|
||||||
@@ -167,7 +196,7 @@ async fn connected(mut socket: WebSocket, clients: Clients) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
remove(&clients, &public_key, connection_id).await;
|
presence::disconnected(&clients, &public_key, connection_id).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_profile(
|
async fn update_profile(
|
||||||
@@ -209,27 +238,6 @@ async fn update_profile(
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_friends(
|
|
||||||
clients: &Clients,
|
|
||||||
public_key: &str,
|
|
||||||
connection_id: Uuid,
|
|
||||||
friends: Vec<String>,
|
|
||||||
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 !verify(&client.key, &friends_bytes(&friends), signature) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
client.friends = friends;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn friend_profiles(
|
async fn friend_profiles(
|
||||||
clients: &Clients,
|
clients: &Clients,
|
||||||
public_key: &str,
|
public_key: &str,
|
||||||
@@ -270,8 +278,7 @@ async fn relay_live_data(
|
|||||||
.iter()
|
.iter()
|
||||||
.filter(|(recipient_id, recipient)| {
|
.filter(|(recipient_id, recipient)| {
|
||||||
recipient_id.as_str() != public_key
|
recipient_id.as_str() != public_key
|
||||||
&& source.friends.iter().any(|friend| friend == *recipient_id)
|
&& are_mutual_friends(public_key, source, recipient_id, recipient)
|
||||||
&& recipient.friends.iter().any(|friend| friend == public_key)
|
|
||||||
})
|
})
|
||||||
.map(|(_, recipient)| recipient.sender.clone())
|
.map(|(_, recipient)| recipient.sender.clone())
|
||||||
.collect();
|
.collect();
|
||||||
@@ -291,16 +298,6 @@ async fn relay_live_data(
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn remove(clients: &Clients, public_key: &str, connection_id: Uuid) {
|
|
||||||
let mut clients = clients.lock().await;
|
|
||||||
if clients
|
|
||||||
.get(public_key)
|
|
||||||
.is_some_and(|client| client.connection_id == connection_id)
|
|
||||||
{
|
|
||||||
clients.remove(public_key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send(socket: &mut WebSocket, message: &ServerMessage) -> Result<(), axum::Error> {
|
async fn send(socket: &mut WebSocket, message: &ServerMessage) -> Result<(), axum::Error> {
|
||||||
socket
|
socket
|
||||||
.send(Message::Text(
|
.send(Message::Text(
|
||||||
@@ -328,6 +325,7 @@ fn verify(key: &VerifyingKey, bytes: &[u8], signature: &str) -> bool {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use ed25519_dalek::{Signer, SigningKey};
|
use ed25519_dalek::{Signer, SigningKey};
|
||||||
|
use wyd_common::friends_bytes;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
@@ -492,7 +490,7 @@ mod tests {
|
|||||||
URL_SAFE_NO_PAD.encode(signing_key.sign(&friends_bytes(&friends)).to_bytes());
|
URL_SAFE_NO_PAD.encode(signing_key.sign(&friends_bytes(&friends)).to_bytes());
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
update_friends(
|
presence::update_friends(
|
||||||
&clients,
|
&clients,
|
||||||
&public_key,
|
&public_key,
|
||||||
connection_id,
|
connection_id,
|
||||||
@@ -500,6 +498,7 @@ mod tests {
|
|||||||
&signature,
|
&signature,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
.is_some()
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
clients.lock().await.get(&public_key).unwrap().friends,
|
clients.lock().await.get(&public_key).unwrap().friends,
|
||||||
@@ -565,6 +564,108 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn presence_sync_and_disconnect_broadcast_require_mutual_friendship() {
|
||||||
|
let signing_key = SigningKey::from_bytes(&[7; 32]);
|
||||||
|
let connection_id = Uuid::new_v4();
|
||||||
|
let (source_sender, _source_receiver) = mpsc::channel(4);
|
||||||
|
let (mutual_sender, mut mutual_receiver) = mpsc::channel(4);
|
||||||
|
let (one_way_sender, mut one_way_receiver) = mpsc::channel(4);
|
||||||
|
let clients = Clients::default();
|
||||||
|
let mut registry = clients.lock().await;
|
||||||
|
registry.insert(
|
||||||
|
"source".to_owned(),
|
||||||
|
Client {
|
||||||
|
connection_id,
|
||||||
|
key: signing_key.verifying_key(),
|
||||||
|
profile: Profile {
|
||||||
|
id: "source".to_owned(),
|
||||||
|
display_name: "Source".to_owned(),
|
||||||
|
},
|
||||||
|
friends: vec!["mutual".to_owned()],
|
||||||
|
sender: source_sender,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
registry.insert(
|
||||||
|
"mutual".to_owned(),
|
||||||
|
Client {
|
||||||
|
connection_id: Uuid::new_v4(),
|
||||||
|
key: signing_key.verifying_key(),
|
||||||
|
profile: Profile {
|
||||||
|
id: "mutual".to_owned(),
|
||||||
|
display_name: "Mutual".to_owned(),
|
||||||
|
},
|
||||||
|
friends: vec!["source".to_owned()],
|
||||||
|
sender: mutual_sender,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
registry.insert(
|
||||||
|
"one-way".to_owned(),
|
||||||
|
Client {
|
||||||
|
connection_id: Uuid::new_v4(),
|
||||||
|
key: signing_key.verifying_key(),
|
||||||
|
profile: Profile {
|
||||||
|
id: "one-way".to_owned(),
|
||||||
|
display_name: "One way".to_owned(),
|
||||||
|
},
|
||||||
|
friends: vec!["source".to_owned()],
|
||||||
|
sender: one_way_sender,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
drop(registry);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
presence::snapshot(&clients, "source", connection_id)
|
||||||
|
.await
|
||||||
|
.expect("current session"),
|
||||||
|
["mutual"]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
presence::snapshot(&clients, "source", Uuid::new_v4())
|
||||||
|
.await
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
|
||||||
|
presence::broadcast_status(&clients, "source", true).await;
|
||||||
|
assert_friend_status(mutual_receiver.recv().await, "source", true);
|
||||||
|
assert!(one_way_receiver.try_recv().is_err());
|
||||||
|
|
||||||
|
let no_friends = Vec::new();
|
||||||
|
let signature =
|
||||||
|
URL_SAFE_NO_PAD.encode(signing_key.sign(&friends_bytes(&no_friends)).to_bytes());
|
||||||
|
assert_eq!(
|
||||||
|
presence::update_friends(&clients, "source", connection_id, no_friends, &signature,)
|
||||||
|
.await,
|
||||||
|
Some(Vec::new())
|
||||||
|
);
|
||||||
|
assert_friend_status(mutual_receiver.recv().await, "source", false);
|
||||||
|
|
||||||
|
let mutual_friends = vec!["mutual".to_owned()];
|
||||||
|
let signature =
|
||||||
|
URL_SAFE_NO_PAD.encode(signing_key.sign(&friends_bytes(&mutual_friends)).to_bytes());
|
||||||
|
assert_eq!(
|
||||||
|
presence::update_friends(
|
||||||
|
&clients,
|
||||||
|
"source",
|
||||||
|
connection_id,
|
||||||
|
mutual_friends,
|
||||||
|
&signature,
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
Some(vec!["mutual".to_owned()])
|
||||||
|
);
|
||||||
|
assert_friend_status(mutual_receiver.recv().await, "source", true);
|
||||||
|
|
||||||
|
presence::disconnected(&clients, "source", Uuid::new_v4()).await;
|
||||||
|
assert!(clients.lock().await.contains_key("source"));
|
||||||
|
assert!(mutual_receiver.try_recv().is_err());
|
||||||
|
|
||||||
|
presence::disconnected(&clients, "source", connection_id).await;
|
||||||
|
assert_friend_status(mutual_receiver.recv().await, "source", false);
|
||||||
|
assert!(one_way_receiver.try_recv().is_err());
|
||||||
|
assert!(!clients.lock().await.contains_key("source"));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn live_data_relay_requires_valid_session_signature_and_mutual_friendship() {
|
async fn live_data_relay_requires_valid_session_signature_and_mutual_friendship() {
|
||||||
let signing_key = SigningKey::from_bytes(&[7; 32]);
|
let signing_key = SigningKey::from_bytes(&[7; 32]);
|
||||||
@@ -669,4 +770,17 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(mutual_receiver.try_recv().is_err());
|
assert!(mutual_receiver.try_recv().is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn assert_friend_status(message: Option<Message>, expected_id: &str, expected_online: bool) {
|
||||||
|
let Message::Text(message) = message.expect("friend receives status") else {
|
||||||
|
panic!("expected text status");
|
||||||
|
};
|
||||||
|
let ServerMessage::FriendStatusChanged { friend_id, online } =
|
||||||
|
serde_json::from_str(&message).expect("decode friend status")
|
||||||
|
else {
|
||||||
|
panic!("expected friend status");
|
||||||
|
};
|
||||||
|
assert_eq!(friend_id, expected_id);
|
||||||
|
assert_eq!(online, expected_online);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
use axum::extract::ws::Message;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
use wyd_common::{ServerMessage, friends_bytes};
|
||||||
|
|
||||||
|
use super::{Clients, are_mutual_friends, verify};
|
||||||
|
|
||||||
|
pub(super) async fn connected(
|
||||||
|
clients: &Clients,
|
||||||
|
public_key: &str,
|
||||||
|
previous_friends: Option<Vec<String>>,
|
||||||
|
) {
|
||||||
|
if let Some(previous_friends) = previous_friends {
|
||||||
|
broadcast_friendship_changes(clients, public_key, &previous_friends).await;
|
||||||
|
} else {
|
||||||
|
broadcast_status(clients, public_key, true).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn update_friends(
|
||||||
|
clients: &Clients,
|
||||||
|
public_key: &str,
|
||||||
|
connection_id: Uuid,
|
||||||
|
friends: Vec<String>,
|
||||||
|
signature: &str,
|
||||||
|
) -> Option<Vec<String>> {
|
||||||
|
let mut clients = clients.lock().await;
|
||||||
|
let previous_friends = {
|
||||||
|
let client = clients
|
||||||
|
.get_mut(public_key)
|
||||||
|
.filter(|client| client.connection_id == connection_id)?;
|
||||||
|
if !verify(&client.key, &friends_bytes(&friends), signature) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
std::mem::replace(&mut client.friends, friends.clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut notifications = Vec::new();
|
||||||
|
let mut online_friend_ids = Vec::new();
|
||||||
|
for (friend_id, friend) in clients.iter().filter(|(id, _)| id.as_str() != public_key) {
|
||||||
|
let was_visible = mutual_friends(public_key, &previous_friends, friend_id, &friend.friends);
|
||||||
|
let is_visible = mutual_friends(public_key, &friends, friend_id, &friend.friends);
|
||||||
|
if is_visible {
|
||||||
|
online_friend_ids.push(friend_id.clone());
|
||||||
|
}
|
||||||
|
if was_visible != is_visible {
|
||||||
|
notifications.push((friend.sender.clone(), public_key.to_owned(), is_visible));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(clients);
|
||||||
|
|
||||||
|
send_notifications(notifications).await;
|
||||||
|
online_friend_ids.sort_unstable();
|
||||||
|
Some(online_friend_ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn snapshot(
|
||||||
|
clients: &Clients,
|
||||||
|
public_key: &str,
|
||||||
|
connection_id: Uuid,
|
||||||
|
) -> Option<Vec<String>> {
|
||||||
|
let clients = clients.lock().await;
|
||||||
|
let source = clients
|
||||||
|
.get(public_key)
|
||||||
|
.filter(|client| client.connection_id == connection_id)?;
|
||||||
|
let mut friend_ids = clients
|
||||||
|
.iter()
|
||||||
|
.filter(|(friend_id, friend)| {
|
||||||
|
friend_id.as_str() != public_key && visible_to(public_key, source, friend_id, friend)
|
||||||
|
})
|
||||||
|
.map(|(friend_id, _)| friend_id.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
friend_ids.sort_unstable();
|
||||||
|
Some(friend_ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn broadcast_status(clients: &Clients, public_key: &str, online: bool) {
|
||||||
|
let recipients = {
|
||||||
|
let clients = clients.lock().await;
|
||||||
|
let Some(source) = clients.get(public_key) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
clients
|
||||||
|
.iter()
|
||||||
|
.filter(|(friend_id, friend)| {
|
||||||
|
friend_id.as_str() != public_key
|
||||||
|
&& visible_to(public_key, source, friend_id, friend)
|
||||||
|
})
|
||||||
|
.map(|(_, friend)| friend.sender.clone())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
let message = status_message(public_key.to_owned(), online);
|
||||||
|
for recipient in recipients {
|
||||||
|
let _ = recipient.send(message.clone()).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn disconnected(clients: &Clients, public_key: &str, connection_id: Uuid) {
|
||||||
|
let recipients = {
|
||||||
|
let mut clients = clients.lock().await;
|
||||||
|
if !clients
|
||||||
|
.get(public_key)
|
||||||
|
.is_some_and(|client| client.connection_id == connection_id)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let source = clients
|
||||||
|
.remove(public_key)
|
||||||
|
.expect("checked registered client");
|
||||||
|
clients
|
||||||
|
.iter()
|
||||||
|
.filter(|(friend_id, friend)| visible_to(public_key, &source, friend_id, friend))
|
||||||
|
.map(|(_, friend)| friend.sender.clone())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
let message = status_message(public_key.to_owned(), false);
|
||||||
|
for recipient in recipients {
|
||||||
|
let _ = recipient.send(message.clone()).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn broadcast_friendship_changes(
|
||||||
|
clients: &Clients,
|
||||||
|
public_key: &str,
|
||||||
|
previous_friends: &[String],
|
||||||
|
) {
|
||||||
|
let notifications = {
|
||||||
|
let clients = clients.lock().await;
|
||||||
|
let Some(source) = clients.get(public_key) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
clients
|
||||||
|
.iter()
|
||||||
|
.filter(|(friend_id, _)| friend_id.as_str() != public_key)
|
||||||
|
.filter_map(|(friend_id, friend)| {
|
||||||
|
let was_visible =
|
||||||
|
mutual_friends(public_key, previous_friends, friend_id, &friend.friends);
|
||||||
|
let is_visible = visible_to(public_key, source, friend_id, friend);
|
||||||
|
(was_visible != is_visible)
|
||||||
|
.then(|| (friend.sender.clone(), public_key.to_owned(), is_visible))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
send_notifications(notifications).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_notifications(notifications: Vec<(mpsc::Sender<Message>, String, bool)>) {
|
||||||
|
for (recipient, friend_id, online) in notifications {
|
||||||
|
let _ = recipient.send(status_message(friend_id, online)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visible_to(
|
||||||
|
source_id: &str,
|
||||||
|
source: &super::Client,
|
||||||
|
viewer_id: &str,
|
||||||
|
viewer: &super::Client,
|
||||||
|
) -> bool {
|
||||||
|
// Apply the source profile's per-friend online-visibility preference here.
|
||||||
|
are_mutual_friends(source_id, source, viewer_id, viewer)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mutual_friends(
|
||||||
|
source_id: &str,
|
||||||
|
source_friends: &[String],
|
||||||
|
viewer_id: &str,
|
||||||
|
viewer_friends: &[String],
|
||||||
|
) -> bool {
|
||||||
|
source_friends.iter().any(|friend| friend == viewer_id)
|
||||||
|
&& viewer_friends.iter().any(|friend| friend == source_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_message(friend_id: String, online: bool) -> Message {
|
||||||
|
Message::Text(
|
||||||
|
serde_json::to_string(&ServerMessage::FriendStatusChanged { friend_id, online })
|
||||||
|
.expect("friend status serializes")
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -46,6 +46,16 @@ impl CursorState {
|
|||||||
positions_by_user.insert(user_id, positions);
|
positions_by_user.insert(user_id, positions);
|
||||||
Ok(positions_by_user.clone())
|
Ok(positions_by_user.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn remove(
|
||||||
|
&self,
|
||||||
|
user_ids: &[String],
|
||||||
|
) -> Result<Option<HashMap<String, CursorPositions>>, String> {
|
||||||
|
let mut positions_by_user = self.0.write().map_err(|error| error.to_string())?;
|
||||||
|
let previous_len = positions_by_user.len();
|
||||||
|
positions_by_user.retain(|user_id, _| !user_ids.contains(user_id));
|
||||||
|
Ok((positions_by_user.len() != previous_len).then(|| positions_by_user.clone()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Was private, but for some reason LSP
|
// Was private, but for some reason LSP
|
||||||
@@ -125,6 +135,23 @@ pub(crate) fn emit_position(handle: &AppHandle, user_id: String, positions: Curs
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn remove_positions(handle: &AppHandle, user_ids: &[String]) {
|
||||||
|
if user_ids.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let positions = match handle.state::<CursorState>().remove(user_ids) {
|
||||||
|
Ok(Some(positions)) => positions,
|
||||||
|
Ok(None) => return,
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("Failed to remove offline cursor positions: {error}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(error) = (CursorPositionChanged { positions }).emit(handle) {
|
||||||
|
eprintln!("Failed to emit cursor position removal: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert absolute to normalized coordinates (0.12, 0.78), or normalized to absolute (1234, 567)
|
/// Convert absolute to normalized coordinates (0.12, 0.78), or normalized to absolute (1234, 567)
|
||||||
pub fn transform_cursor_pos(
|
pub fn transform_cursor_pos(
|
||||||
pos: &CursorPosition,
|
pos: &CursorPosition,
|
||||||
@@ -181,6 +208,13 @@ mod tests {
|
|||||||
let snapshot = state.update("local".to_owned(), positions(3.0)).unwrap();
|
let snapshot = state.update("local".to_owned(), positions(3.0)).unwrap();
|
||||||
assert_eq!(snapshot.len(), 2);
|
assert_eq!(snapshot.len(), 2);
|
||||||
assert_eq!(snapshot["local"].raw.x, 3.0);
|
assert_eq!(snapshot["local"].raw.x, 3.0);
|
||||||
|
|
||||||
|
let snapshot = state
|
||||||
|
.remove(&["friend".to_owned()])
|
||||||
|
.unwrap()
|
||||||
|
.expect("friend cursor removed");
|
||||||
|
assert_eq!(snapshot.keys().collect::<Vec<_>>(), ["local"]);
|
||||||
|
assert!(state.remove(&["missing".to_owned()]).unwrap().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
|
|||||||
profile::update_profile,
|
profile::update_profile,
|
||||||
keypair::get_public_key,
|
keypair::get_public_key,
|
||||||
network::list_statuses,
|
network::list_statuses,
|
||||||
|
network::list_friend_statuses,
|
||||||
images::pick_and_send_image,
|
images::pick_and_send_image,
|
||||||
images::send_image_bytes,
|
images::send_image_bytes,
|
||||||
interactions::send_interaction,
|
interactions::send_interaction,
|
||||||
@@ -81,6 +82,7 @@ fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
|
|||||||
remotes::RemotesChanged,
|
remotes::RemotesChanged,
|
||||||
profile::ProfileChanged,
|
profile::ProfileChanged,
|
||||||
network::NetworkStatusChanged,
|
network::NetworkStatusChanged,
|
||||||
|
network::FriendStatusesChanged,
|
||||||
cursor::CursorPositionChanged,
|
cursor::CursorPositionChanged,
|
||||||
ufa::ForegroundAppChanged,
|
ufa::ForegroundAppChanged,
|
||||||
ufa::FriendForegroundAppChanged,
|
ufa::FriendForegroundAppChanged,
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ use crate::keypair::AppKeypair;
|
|||||||
use crate::live_data::LiveData;
|
use crate::live_data::LiveData;
|
||||||
use crate::remotes::{self, Remote, RemotesChanged};
|
use crate::remotes::{self, Remote, RemotesChanged};
|
||||||
|
|
||||||
|
mod presence;
|
||||||
|
|
||||||
|
use presence::{Change as FriendPresenceChange, FriendPresence};
|
||||||
|
|
||||||
type Statuses = Arc<Mutex<HashMap<String, (u64, ConnectionStatus)>>>;
|
type Statuses = Arc<Mutex<HashMap<String, (u64, ConnectionStatus)>>>;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||||
@@ -48,6 +52,12 @@ pub struct NetworkStatusChanged {
|
|||||||
pub statuses: Vec<ConnectionStatus>,
|
pub statuses: Vec<ConnectionStatus>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FriendStatusesChanged {
|
||||||
|
pub friend_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
struct Connection {
|
struct Connection {
|
||||||
remote: Remote,
|
remote: Remote,
|
||||||
sender: mpsc::Sender<String>,
|
sender: mpsc::Sender<String>,
|
||||||
@@ -66,6 +76,7 @@ struct InteractionRequest {
|
|||||||
pub struct Network {
|
pub struct Network {
|
||||||
connections: Mutex<HashMap<String, Connection>>,
|
connections: Mutex<HashMap<String, Connection>>,
|
||||||
statuses: Statuses,
|
statuses: Statuses,
|
||||||
|
friend_presence: Arc<FriendPresence>,
|
||||||
profile: watch::Sender<crate::user::User>,
|
profile: watch::Sender<crate::user::User>,
|
||||||
friends: watch::Sender<Vec<String>>,
|
friends: watch::Sender<Vec<String>>,
|
||||||
keypair: AppKeypair,
|
keypair: AppKeypair,
|
||||||
@@ -173,6 +184,7 @@ impl Network {
|
|||||||
if let Some(connection) = connections.remove(&id) {
|
if let Some(connection) = connections.remove(&id) {
|
||||||
connection.task.abort();
|
connection.task.abort();
|
||||||
remove_status(&self.statuses, &id, connection.generation);
|
remove_status(&self.statuses, &id, connection.generation);
|
||||||
|
apply_friend_presence_change(handle, self.friend_presence.remove(&id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +205,7 @@ impl Network {
|
|||||||
let task = tauri::async_runtime::spawn(run(
|
let task = tauri::async_runtime::spawn(run(
|
||||||
handle.clone(),
|
handle.clone(),
|
||||||
self.statuses.clone(),
|
self.statuses.clone(),
|
||||||
|
self.friend_presence.clone(),
|
||||||
remote.clone(),
|
remote.clone(),
|
||||||
generation,
|
generation,
|
||||||
self.profile.subscribe(),
|
self.profile.subscribe(),
|
||||||
@@ -228,6 +241,7 @@ pub async fn init(handle: &AppHandle) -> Result<(), Box<dyn std::error::Error>>
|
|||||||
let network = Network {
|
let network = Network {
|
||||||
connections: Mutex::new(HashMap::new()),
|
connections: Mutex::new(HashMap::new()),
|
||||||
statuses: Statuses::default(),
|
statuses: Statuses::default(),
|
||||||
|
friend_presence: Arc::default(),
|
||||||
profile,
|
profile,
|
||||||
friends,
|
friends,
|
||||||
keypair,
|
keypair,
|
||||||
@@ -252,9 +266,10 @@ pub async fn init(handle: &AppHandle) -> Result<(), Box<dyn std::error::Error>>
|
|||||||
if *current == ids {
|
if *current == ids {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
*current = ids;
|
*current = ids.clone();
|
||||||
true
|
true
|
||||||
});
|
});
|
||||||
|
apply_friend_presence_change(&listener_handle, network.friend_presence.retain(&ids));
|
||||||
});
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -262,6 +277,7 @@ pub async fn init(handle: &AppHandle) -> Result<(), Box<dyn std::error::Error>>
|
|||||||
async fn run(
|
async fn run(
|
||||||
handle: AppHandle,
|
handle: AppHandle,
|
||||||
statuses: Statuses,
|
statuses: Statuses,
|
||||||
|
friend_presence: Arc<FriendPresence>,
|
||||||
remote: Remote,
|
remote: Remote,
|
||||||
generation: u64,
|
generation: u64,
|
||||||
mut profiles: watch::Receiver<crate::user::User>,
|
mut profiles: watch::Receiver<crate::user::User>,
|
||||||
@@ -281,6 +297,7 @@ async fn run(
|
|||||||
if let Err(error) = connect(
|
if let Err(error) = connect(
|
||||||
&handle,
|
&handle,
|
||||||
&statuses,
|
&statuses,
|
||||||
|
&friend_presence,
|
||||||
&remote,
|
&remote,
|
||||||
generation,
|
generation,
|
||||||
&mut profiles,
|
&mut profiles,
|
||||||
@@ -293,6 +310,7 @@ async fn run(
|
|||||||
{
|
{
|
||||||
eprintln!("remote {} disconnected: {error}", remote.id);
|
eprintln!("remote {} disconnected: {error}", remote.id);
|
||||||
}
|
}
|
||||||
|
apply_friend_presence_change(&handle, friend_presence.remove(&remote.id));
|
||||||
changed(
|
changed(
|
||||||
&handle,
|
&handle,
|
||||||
&statuses,
|
&statuses,
|
||||||
@@ -307,6 +325,7 @@ async fn run(
|
|||||||
async fn connect(
|
async fn connect(
|
||||||
handle: &AppHandle,
|
handle: &AppHandle,
|
||||||
statuses: &Statuses,
|
statuses: &Statuses,
|
||||||
|
friend_presence: &FriendPresence,
|
||||||
remote: &Remote,
|
remote: &Remote,
|
||||||
generation: u64,
|
generation: u64,
|
||||||
profiles: &mut watch::Receiver<crate::user::User>,
|
profiles: &mut watch::Receiver<crate::user::User>,
|
||||||
@@ -354,6 +373,7 @@ async fn connect(
|
|||||||
.send(InteractionDeliveryStatus::Unavailable);
|
.send(InteractionDeliveryStatus::Unavailable);
|
||||||
}
|
}
|
||||||
send(&mut writer, &ClientMessage::SyncFriendProfiles).await?;
|
send(&mut writer, &ClientMessage::SyncFriendProfiles).await?;
|
||||||
|
send(&mut writer, &ClientMessage::SyncFriendStatuses).await?;
|
||||||
changed(
|
changed(
|
||||||
handle,
|
handle,
|
||||||
statuses,
|
statuses,
|
||||||
@@ -421,6 +441,21 @@ async fn connect(
|
|||||||
eprintln!("failed to synchronize friend profiles: {error}");
|
eprintln!("failed to synchronize friend profiles: {error}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ServerMessage::FriendStatusChanged { friend_id, online } => {
|
||||||
|
update_friend_presence(
|
||||||
|
handle,
|
||||||
|
friend_presence,
|
||||||
|
&remote.id,
|
||||||
|
friend_id,
|
||||||
|
online,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ServerMessage::FriendStatuses { friend_ids } => {
|
||||||
|
apply_friend_presence_change(
|
||||||
|
handle,
|
||||||
|
friend_presence.replace(&remote.id, friend_ids),
|
||||||
|
);
|
||||||
|
}
|
||||||
ServerMessage::FriendLiveData { friend_id, payload } => {
|
ServerMessage::FriendLiveData { friend_id, payload } => {
|
||||||
match serde_json::from_str(&payload) {
|
match serde_json::from_str(&payload) {
|
||||||
Ok(LiveData::Cursor { positions }) => {
|
Ok(LiveData::Cursor { positions }) => {
|
||||||
@@ -471,6 +506,42 @@ pub fn list_statuses(
|
|||||||
Ok(statuses)
|
Ok(statuses)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub fn list_friend_statuses(network: State<'_, Network>) -> Result<Vec<String>, String> {
|
||||||
|
network.friend_presence.snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_friend_presence(
|
||||||
|
handle: &AppHandle,
|
||||||
|
presence: &FriendPresence,
|
||||||
|
remote_id: &str,
|
||||||
|
friend_id: String,
|
||||||
|
online: bool,
|
||||||
|
) {
|
||||||
|
apply_friend_presence_change(handle, presence.update(remote_id, friend_id, online));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_friend_presence_change(
|
||||||
|
handle: &AppHandle,
|
||||||
|
result: Result<Option<FriendPresenceChange>, String>,
|
||||||
|
) {
|
||||||
|
match result {
|
||||||
|
Ok(Some(change)) => {
|
||||||
|
crate::cursor::remove_positions(handle, &change.went_offline);
|
||||||
|
if let Err(error) = (FriendStatusesChanged {
|
||||||
|
friend_ids: change.online,
|
||||||
|
})
|
||||||
|
.emit(handle)
|
||||||
|
{
|
||||||
|
eprintln!("failed to emit friend statuses: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(error) => eprintln!("failed to update friend presence: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn changed(
|
fn changed(
|
||||||
handle: &AppHandle,
|
handle: &AppHandle,
|
||||||
statuses: &Statuses,
|
statuses: &Statuses,
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub(super) struct Change {
|
||||||
|
pub(super) online: Vec<String>,
|
||||||
|
pub(super) went_offline: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(super) struct FriendPresence(Mutex<HashMap<String, HashSet<String>>>);
|
||||||
|
|
||||||
|
impl FriendPresence {
|
||||||
|
pub(super) fn replace(
|
||||||
|
&self,
|
||||||
|
remote_id: &str,
|
||||||
|
friend_ids: Vec<String>,
|
||||||
|
) -> Result<Option<Change>, String> {
|
||||||
|
let mut by_remote = self.0.lock().map_err(|error| error.to_string())?;
|
||||||
|
let before = aggregate(&by_remote);
|
||||||
|
let friend_ids = friend_ids.into_iter().collect::<HashSet<_>>();
|
||||||
|
if friend_ids.is_empty() {
|
||||||
|
by_remote.remove(remote_id);
|
||||||
|
} else {
|
||||||
|
by_remote.insert(remote_id.to_owned(), friend_ids);
|
||||||
|
}
|
||||||
|
Ok(diff(before, &by_remote))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn update(
|
||||||
|
&self,
|
||||||
|
remote_id: &str,
|
||||||
|
friend_id: String,
|
||||||
|
online: bool,
|
||||||
|
) -> Result<Option<Change>, String> {
|
||||||
|
let mut by_remote = self.0.lock().map_err(|error| error.to_string())?;
|
||||||
|
let before = aggregate(&by_remote);
|
||||||
|
if online {
|
||||||
|
by_remote
|
||||||
|
.entry(remote_id.to_owned())
|
||||||
|
.or_default()
|
||||||
|
.insert(friend_id);
|
||||||
|
} else if let Some(friend_ids) = by_remote.get_mut(remote_id) {
|
||||||
|
friend_ids.remove(&friend_id);
|
||||||
|
if friend_ids.is_empty() {
|
||||||
|
by_remote.remove(remote_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(diff(before, &by_remote))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn remove(&self, remote_id: &str) -> Result<Option<Change>, String> {
|
||||||
|
let mut by_remote = self.0.lock().map_err(|error| error.to_string())?;
|
||||||
|
let before = aggregate(&by_remote);
|
||||||
|
by_remote.remove(remote_id);
|
||||||
|
Ok(diff(before, &by_remote))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn retain(&self, known_friend_ids: &[String]) -> Result<Option<Change>, String> {
|
||||||
|
let known_friend_ids = known_friend_ids.iter().collect::<HashSet<_>>();
|
||||||
|
let mut by_remote = self.0.lock().map_err(|error| error.to_string())?;
|
||||||
|
let before = aggregate(&by_remote);
|
||||||
|
by_remote.retain(|_, friend_ids| {
|
||||||
|
friend_ids.retain(|friend_id| known_friend_ids.contains(friend_id));
|
||||||
|
!friend_ids.is_empty()
|
||||||
|
});
|
||||||
|
Ok(diff(before, &by_remote))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn snapshot(&self) -> Result<Vec<String>, String> {
|
||||||
|
let by_remote = self.0.lock().map_err(|error| error.to_string())?;
|
||||||
|
let mut friend_ids = aggregate(&by_remote).into_iter().collect::<Vec<_>>();
|
||||||
|
friend_ids.sort_unstable();
|
||||||
|
Ok(friend_ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn aggregate(by_remote: &HashMap<String, HashSet<String>>) -> HashSet<String> {
|
||||||
|
by_remote
|
||||||
|
.values()
|
||||||
|
.flat_map(|friend_ids| friend_ids.iter().cloned())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diff(before: HashSet<String>, by_remote: &HashMap<String, HashSet<String>>) -> Option<Change> {
|
||||||
|
let after = aggregate(by_remote);
|
||||||
|
if before == after {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut went_offline = before.difference(&after).cloned().collect::<Vec<_>>();
|
||||||
|
let mut online = after.into_iter().collect::<Vec<_>>();
|
||||||
|
went_offline.sort_unstable();
|
||||||
|
online.sort_unstable();
|
||||||
|
Some(Change {
|
||||||
|
online,
|
||||||
|
went_offline,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{Change, FriendPresence};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn friend_stays_online_while_any_remote_reports_presence() {
|
||||||
|
let presence = FriendPresence::default();
|
||||||
|
assert_eq!(
|
||||||
|
presence
|
||||||
|
.replace("remote-a", vec!["friend".to_owned()])
|
||||||
|
.unwrap(),
|
||||||
|
Some(Change {
|
||||||
|
online: vec!["friend".to_owned()],
|
||||||
|
went_offline: Vec::new(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
presence
|
||||||
|
.replace("remote-b", vec!["friend".to_owned()])
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert!(presence.remove("remote-a").unwrap().is_none());
|
||||||
|
assert_eq!(
|
||||||
|
presence.remove("remote-b").unwrap(),
|
||||||
|
Some(Change {
|
||||||
|
online: Vec::new(),
|
||||||
|
went_offline: vec!["friend".to_owned()],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn incremental_updates_and_friend_removal_share_the_same_snapshot() {
|
||||||
|
let presence = FriendPresence::default();
|
||||||
|
presence
|
||||||
|
.replace("remote", vec!["kept".to_owned(), "removed".to_owned()])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
presence
|
||||||
|
.update("remote", "added".to_owned(), true)
|
||||||
|
.unwrap()
|
||||||
|
.expect("friend came online")
|
||||||
|
.online,
|
||||||
|
["added", "kept", "removed"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
presence
|
||||||
|
.update("remote", "added".to_owned(), false)
|
||||||
|
.unwrap()
|
||||||
|
.expect("friend went offline")
|
||||||
|
.went_offline,
|
||||||
|
["added"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
presence
|
||||||
|
.retain(&["kept".to_owned()])
|
||||||
|
.unwrap()
|
||||||
|
.expect("unknown friend was removed")
|
||||||
|
.went_offline,
|
||||||
|
["removed"]
|
||||||
|
);
|
||||||
|
assert_eq!(presence.snapshot().unwrap(), ["kept"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,9 @@ async getPublicKey() : Promise<string> {
|
|||||||
async listStatuses() : Promise<ConnectionStatus[]> {
|
async listStatuses() : Promise<ConnectionStatus[]> {
|
||||||
return await TAURI_INVOKE("list_statuses");
|
return await TAURI_INVOKE("list_statuses");
|
||||||
},
|
},
|
||||||
|
async listFriendStatuses() : Promise<string[]> {
|
||||||
|
return await TAURI_INVOKE("list_friend_statuses");
|
||||||
|
},
|
||||||
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 });
|
||||||
},
|
},
|
||||||
@@ -66,6 +69,7 @@ cursorPositionChanged: CursorPositionChanged,
|
|||||||
foregroundAppChanged: ForegroundAppChanged,
|
foregroundAppChanged: ForegroundAppChanged,
|
||||||
friendForegroundAppChanged: FriendForegroundAppChanged,
|
friendForegroundAppChanged: FriendForegroundAppChanged,
|
||||||
friendInteractionReceived: FriendInteractionReceived,
|
friendInteractionReceived: FriendInteractionReceived,
|
||||||
|
friendStatusesChanged: FriendStatusesChanged,
|
||||||
friendsChanged: FriendsChanged,
|
friendsChanged: FriendsChanged,
|
||||||
networkStatusChanged: NetworkStatusChanged,
|
networkStatusChanged: NetworkStatusChanged,
|
||||||
profileChanged: ProfileChanged,
|
profileChanged: ProfileChanged,
|
||||||
@@ -75,6 +79,7 @@ cursorPositionChanged: "cursor-position-changed",
|
|||||||
foregroundAppChanged: "foreground-app-changed",
|
foregroundAppChanged: "foreground-app-changed",
|
||||||
friendForegroundAppChanged: "friend-foreground-app-changed",
|
friendForegroundAppChanged: "friend-foreground-app-changed",
|
||||||
friendInteractionReceived: "friend-interaction-received",
|
friendInteractionReceived: "friend-interaction-received",
|
||||||
|
friendStatusesChanged: "friend-statuses-changed",
|
||||||
friendsChanged: "friends-changed",
|
friendsChanged: "friends-changed",
|
||||||
networkStatusChanged: "network-status-changed",
|
networkStatusChanged: "network-status-changed",
|
||||||
profileChanged: "profile-changed",
|
profileChanged: "profile-changed",
|
||||||
@@ -107,6 +112,7 @@ mapped: CursorPosition }
|
|||||||
export type ForegroundAppChanged = { meta: AppMeta }
|
export type ForegroundAppChanged = { meta: AppMeta }
|
||||||
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 FriendsChanged = { friends: User[] }
|
export type FriendsChanged = { friends: User[] }
|
||||||
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[] }
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
friendStatusesListenerError,
|
||||||
|
onlineFriendIds,
|
||||||
|
} from "$lib/listeners/friend-statuses";
|
||||||
|
import { friends, friendsListenerError } from "$lib/listeners/friends";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h1>Friend statuses</h1>
|
||||||
|
|
||||||
|
{#if $friendsListenerError || $friendStatusesListenerError}
|
||||||
|
<p aria-live="polite">
|
||||||
|
{$friendsListenerError || $friendStatusesListenerError}
|
||||||
|
</p>
|
||||||
|
{:else if $friends.length === 0}
|
||||||
|
<p>No friends configured.</p>
|
||||||
|
{:else}
|
||||||
|
<ul aria-live="polite">
|
||||||
|
{#each $friends as friend (friend.id)}
|
||||||
|
{@const online = $onlineFriendIds.has(friend.id)}
|
||||||
|
<li>
|
||||||
|
<strong>{friend.displayName}</strong>: {online ? "Online" : "Offline"}
|
||||||
|
<small>({friend.id})</small>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { writable } from "svelte/store";
|
||||||
|
import { commands, events } from "$lib/bindings";
|
||||||
|
import { removeFriendForegroundApps } from "./live-metadata";
|
||||||
|
|
||||||
|
export const onlineFriendIds = writable<Set<string>>(new Set());
|
||||||
|
export const friendStatusesListenerError = writable("");
|
||||||
|
|
||||||
|
export async function initFriendStatusesListener() {
|
||||||
|
let current = new Set<string>();
|
||||||
|
|
||||||
|
const apply = (friendIds: string[]) => {
|
||||||
|
const next = new Set(friendIds);
|
||||||
|
const wentOffline = [...current].filter((friendId) => !next.has(friendId));
|
||||||
|
current = next;
|
||||||
|
onlineFriendIds.set(next);
|
||||||
|
|
||||||
|
removeFriendForegroundApps(wentOffline);
|
||||||
|
};
|
||||||
|
|
||||||
|
const unlisten = await events.friendStatusesChanged.listen((event) => {
|
||||||
|
apply(event.payload.friendIds);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
apply(await commands.listFriendStatuses());
|
||||||
|
} catch (error) {
|
||||||
|
friendStatusesListenerError.set(String(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
return unlisten;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { initConnectionStatusesListener } from "./connection-status";
|
import { initConnectionStatusesListener } from "./connection-status";
|
||||||
|
import { initFriendStatusesListener } from "./friend-statuses";
|
||||||
import { initFriendsListener } from "./friends";
|
import { initFriendsListener } from "./friends";
|
||||||
import { initInteractionListener } from "./interactions";
|
import { initInteractionListener } from "./interactions";
|
||||||
import { initLiveMetadataListeners } from "./live-metadata";
|
import { initLiveMetadataListeners } from "./live-metadata";
|
||||||
@@ -16,6 +17,7 @@ export function initAppListeners(): Unlisten {
|
|||||||
initRemotesListener(),
|
initRemotesListener(),
|
||||||
initProfileListener(),
|
initProfileListener(),
|
||||||
initConnectionStatusesListener(),
|
initConnectionStatusesListener(),
|
||||||
|
initFriendStatusesListener(),
|
||||||
initLiveMetadataListeners(),
|
initLiveMetadataListeners(),
|
||||||
initInteractionListener(),
|
initInteractionListener(),
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -19,6 +19,17 @@ export const liveMetadata = writable<LiveMetadata>({
|
|||||||
});
|
});
|
||||||
export const liveMetadataListenerError = writable("");
|
export const liveMetadataListenerError = writable("");
|
||||||
|
|
||||||
|
export function removeFriendForegroundApps(friendIds: string[]) {
|
||||||
|
if (friendIds.length === 0) return;
|
||||||
|
const removed = new Set(friendIds);
|
||||||
|
liveMetadata.update((metadata) => ({
|
||||||
|
...metadata,
|
||||||
|
foregroundApps: new Map(
|
||||||
|
[...metadata.foregroundApps].filter(([userId]) => !removed.has(userId)),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export async function initLiveMetadataListeners() {
|
export async function initLiveMetadataListeners() {
|
||||||
let localId = "";
|
let localId = "";
|
||||||
let pendingLocalForegroundApp: AppMeta | null = null;
|
let pendingLocalForegroundApp: AppMeta | null = null;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import FriendsList from "$lib/components/friends-list.svelte";
|
import FriendsList from "$lib/components/friends-list.svelte";
|
||||||
|
import FriendStatuses from "$lib/components/friend-statuses.svelte";
|
||||||
import ConnectionStatus from "$lib/components/connection-status.svelte";
|
import ConnectionStatus from "$lib/components/connection-status.svelte";
|
||||||
import Profile from "$lib/components/profile.svelte";
|
import Profile from "$lib/components/profile.svelte";
|
||||||
import RemotesList from "$lib/components/remotes-list.svelte";
|
import RemotesList from "$lib/components/remotes-list.svelte";
|
||||||
@@ -13,6 +14,9 @@
|
|||||||
<div>
|
<div>
|
||||||
<FriendsList />
|
<FriendsList />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<FriendStatuses />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<RemotesList />
|
<RemotesList />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
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 { friends } from "$lib/listeners/friends";
|
||||||
|
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";
|
||||||
import { profile } from "$lib/listeners/profile";
|
import { profile } from "$lib/listeners/profile";
|
||||||
@@ -28,6 +29,18 @@
|
|||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (
|
||||||
|
selectedUserId &&
|
||||||
|
selectedUserId !== $liveMetadata.localId &&
|
||||||
|
!$onlineFriendIds.has(selectedUserId)
|
||||||
|
) {
|
||||||
|
selectedUserId = null;
|
||||||
|
selectedUserX = null;
|
||||||
|
lockedPopoverUserId = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function displayName(userId: string) {
|
function displayName(userId: string) {
|
||||||
return $profile?.id === userId
|
return $profile?.id === userId
|
||||||
? $profile.displayName
|
? $profile.displayName
|
||||||
@@ -65,7 +78,7 @@
|
|||||||
{@const popoverId = `scene-user-${index}`}
|
{@const popoverId = `scene-user-${index}`}
|
||||||
{@const interaction =
|
{@const interaction =
|
||||||
$incomingInteraction?.friendId === userId ? $incomingInteraction : null}
|
$incomingInteraction?.friendId === userId ? $incomingInteraction : null}
|
||||||
{#if cursor}
|
{#if cursor && (userId === $liveMetadata.localId || $onlineFriendIds.has(userId))}
|
||||||
{@const renderedX =
|
{@const renderedX =
|
||||||
selectedUserId === userId ? selectedUserX : cursor.mapped.x}
|
selectedUserId === userId ? selectedUserX : cursor.mapped.x}
|
||||||
<div
|
<div
|
||||||
|
|||||||
Reference in New Issue
Block a user