friends connection status system
This commit is contained in:
@@ -46,6 +46,16 @@ impl CursorState {
|
||||
positions_by_user.insert(user_id, positions);
|
||||
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
|
||||
@@ -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)
|
||||
pub fn transform_cursor_pos(
|
||||
pos: &CursorPosition,
|
||||
@@ -181,6 +208,13 @@ mod tests {
|
||||
let snapshot = state.update("local".to_owned(), positions(3.0)).unwrap();
|
||||
assert_eq!(snapshot.len(), 2);
|
||||
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]
|
||||
|
||||
@@ -71,6 +71,7 @@ fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
profile::update_profile,
|
||||
keypair::get_public_key,
|
||||
network::list_statuses,
|
||||
network::list_friend_statuses,
|
||||
images::pick_and_send_image,
|
||||
images::send_image_bytes,
|
||||
interactions::send_interaction,
|
||||
@@ -81,6 +82,7 @@ fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
remotes::RemotesChanged,
|
||||
profile::ProfileChanged,
|
||||
network::NetworkStatusChanged,
|
||||
network::FriendStatusesChanged,
|
||||
cursor::CursorPositionChanged,
|
||||
ufa::ForegroundAppChanged,
|
||||
ufa::FriendForegroundAppChanged,
|
||||
|
||||
@@ -23,6 +23,10 @@ use crate::keypair::AppKeypair;
|
||||
use crate::live_data::LiveData;
|
||||
use crate::remotes::{self, Remote, RemotesChanged};
|
||||
|
||||
mod presence;
|
||||
|
||||
use presence::{Change as FriendPresenceChange, FriendPresence};
|
||||
|
||||
type Statuses = Arc<Mutex<HashMap<String, (u64, ConnectionStatus)>>>;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
@@ -48,6 +52,12 @@ pub struct NetworkStatusChanged {
|
||||
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 {
|
||||
remote: Remote,
|
||||
sender: mpsc::Sender<String>,
|
||||
@@ -66,6 +76,7 @@ struct InteractionRequest {
|
||||
pub struct Network {
|
||||
connections: Mutex<HashMap<String, Connection>>,
|
||||
statuses: Statuses,
|
||||
friend_presence: Arc<FriendPresence>,
|
||||
profile: watch::Sender<crate::user::User>,
|
||||
friends: watch::Sender<Vec<String>>,
|
||||
keypair: AppKeypair,
|
||||
@@ -173,6 +184,7 @@ impl Network {
|
||||
if let Some(connection) = connections.remove(&id) {
|
||||
connection.task.abort();
|
||||
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(
|
||||
handle.clone(),
|
||||
self.statuses.clone(),
|
||||
self.friend_presence.clone(),
|
||||
remote.clone(),
|
||||
generation,
|
||||
self.profile.subscribe(),
|
||||
@@ -228,6 +241,7 @@ pub async fn init(handle: &AppHandle) -> Result<(), Box<dyn std::error::Error>>
|
||||
let network = Network {
|
||||
connections: Mutex::new(HashMap::new()),
|
||||
statuses: Statuses::default(),
|
||||
friend_presence: Arc::default(),
|
||||
profile,
|
||||
friends,
|
||||
keypair,
|
||||
@@ -252,9 +266,10 @@ pub async fn init(handle: &AppHandle) -> Result<(), Box<dyn std::error::Error>>
|
||||
if *current == ids {
|
||||
return false;
|
||||
}
|
||||
*current = ids;
|
||||
*current = ids.clone();
|
||||
true
|
||||
});
|
||||
apply_friend_presence_change(&listener_handle, network.friend_presence.retain(&ids));
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
@@ -262,6 +277,7 @@ pub async fn init(handle: &AppHandle) -> Result<(), Box<dyn std::error::Error>>
|
||||
async fn run(
|
||||
handle: AppHandle,
|
||||
statuses: Statuses,
|
||||
friend_presence: Arc<FriendPresence>,
|
||||
remote: Remote,
|
||||
generation: u64,
|
||||
mut profiles: watch::Receiver<crate::user::User>,
|
||||
@@ -281,6 +297,7 @@ async fn run(
|
||||
if let Err(error) = connect(
|
||||
&handle,
|
||||
&statuses,
|
||||
&friend_presence,
|
||||
&remote,
|
||||
generation,
|
||||
&mut profiles,
|
||||
@@ -293,6 +310,7 @@ async fn run(
|
||||
{
|
||||
eprintln!("remote {} disconnected: {error}", remote.id);
|
||||
}
|
||||
apply_friend_presence_change(&handle, friend_presence.remove(&remote.id));
|
||||
changed(
|
||||
&handle,
|
||||
&statuses,
|
||||
@@ -307,6 +325,7 @@ async fn run(
|
||||
async fn connect(
|
||||
handle: &AppHandle,
|
||||
statuses: &Statuses,
|
||||
friend_presence: &FriendPresence,
|
||||
remote: &Remote,
|
||||
generation: u64,
|
||||
profiles: &mut watch::Receiver<crate::user::User>,
|
||||
@@ -354,6 +373,7 @@ async fn connect(
|
||||
.send(InteractionDeliveryStatus::Unavailable);
|
||||
}
|
||||
send(&mut writer, &ClientMessage::SyncFriendProfiles).await?;
|
||||
send(&mut writer, &ClientMessage::SyncFriendStatuses).await?;
|
||||
changed(
|
||||
handle,
|
||||
statuses,
|
||||
@@ -421,6 +441,21 @@ async fn connect(
|
||||
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 } => {
|
||||
match serde_json::from_str(&payload) {
|
||||
Ok(LiveData::Cursor { positions }) => {
|
||||
@@ -471,6 +506,42 @@ pub fn list_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(
|
||||
handle: &AppHandle,
|
||||
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"]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user