moved friend cursor data aggregation from frontend to backend

This commit is contained in:
2026-03-09 12:30:29 +08:00
parent 23c778a0bb
commit 69cfebee3d
8 changed files with 201 additions and 96 deletions

View File

@@ -25,10 +25,10 @@ use tauri_specta::{Builder as SpectaBuilder, ErrorHandlingMode, collect_commands
use crate::services::app_events::{
AppDataRefreshed, CreateDoll, CursorMoved, EditDoll, FriendActiveDollChanged,
FriendCursorPositionUpdated, FriendDisconnected, FriendRequestAccepted,
FriendRequestDenied, FriendRequestReceived, FriendUserStatusChanged,
InteractionDeliveryFailed, InteractionReceived, SceneInteractiveChanged,
SetInteractionOverlay, Unfriended, UserStatusChanged,
FriendCursorPositionsUpdated, FriendDisconnected,
FriendRequestAccepted, FriendRequestDenied, FriendRequestReceived,
FriendUserStatusChanged, InteractionDeliveryFailed, InteractionReceived,
SceneInteractiveChanged, SetInteractionOverlay, Unfriended, UserStatusChanged,
};
static APP_HANDLE: std::sync::OnceLock<tauri::AppHandle<tauri::Wry>> = std::sync::OnceLock::new();
@@ -109,7 +109,7 @@ pub fn run() {
EditDoll,
CreateDoll,
UserStatusChanged,
FriendCursorPositionUpdated,
FriendCursorPositionsUpdated,
FriendDisconnected,
FriendActiveDollChanged,
FriendUserStatusChanged,

View File

@@ -12,7 +12,10 @@ use crate::{
},
interaction::{InteractionDeliveryFailedDto, InteractionPayloadDto},
},
services::{cursor::CursorPositions, ws::OutgoingFriendCursorPayload},
services::{
cursor::CursorPositions, friend_cursor::FriendCursorPositionsDto,
ws::OutgoingFriendCursorPayload,
},
};
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
@@ -47,6 +50,10 @@ pub struct UserStatusChanged(pub UserStatusPayload);
#[tauri_specta(event_name = "friend-cursor-position")]
pub struct FriendCursorPositionUpdated(pub OutgoingFriendCursorPayload);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "friend-cursor-positions")]
pub struct FriendCursorPositionsUpdated(pub FriendCursorPositionsDto);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "friend-disconnected")]
pub struct FriendDisconnected(pub FriendDisconnectedPayload);

View File

@@ -0,0 +1,147 @@
use std::{
collections::HashMap,
sync::{Arc, LazyLock, RwLock},
};
use serde::{Deserialize, Serialize};
use specta::Type;
use tauri_specta::Event as _;
use crate::{
get_app_handle, lock_r,
services::{app_events::FriendCursorPositionsUpdated, cursor::CursorPositions},
state::FDOLL,
};
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
#[serde(transparent)]
pub struct FriendCursorPositionsDto(pub HashMap<String, CursorPositions>);
#[derive(Default)]
struct FriendCursorProjection {
active_dolls: HashMap<String, bool>,
positions: HashMap<String, CursorPositions>,
}
static FRIEND_CURSOR_PROJECTION: LazyLock<Arc<RwLock<FriendCursorProjection>>> =
LazyLock::new(|| Arc::new(RwLock::new(FriendCursorProjection::default())));
pub fn sync_from_app_data() {
let friends = {
let guard = lock_r!(FDOLL);
guard.user_data.friends.clone().unwrap_or_default()
};
let mut projection = FRIEND_CURSOR_PROJECTION
.write()
.expect("friend cursor projection lock poisoned");
projection.active_dolls = friends
.into_iter()
.filter_map(|friendship| {
friendship.friend.map(|friend| {
let has_active_doll = friend.active_doll.is_some();
(friend.id, has_active_doll)
})
})
.collect();
let active_dolls = projection.active_dolls.clone();
projection
.positions
.retain(|user_id, _| active_dolls.get(user_id) == Some(&true));
emit_snapshot(&projection.positions);
}
pub fn clear() {
let mut projection = FRIEND_CURSOR_PROJECTION
.write()
.expect("friend cursor projection lock poisoned");
projection.active_dolls.clear();
projection.positions.clear();
emit_snapshot(&projection.positions);
}
pub fn update_position(user_id: String, position: CursorPositions) {
let mut projection = FRIEND_CURSOR_PROJECTION
.write()
.expect("friend cursor projection lock poisoned");
if !has_active_doll(&mut projection, &user_id) {
if projection.positions.remove(&user_id).is_some() {
emit_snapshot(&projection.positions);
}
return;
}
projection.positions.insert(user_id, position);
emit_snapshot(&projection.positions);
}
pub fn remove_friend(user_id: &str) {
let mut projection = FRIEND_CURSOR_PROJECTION
.write()
.expect("friend cursor projection lock poisoned");
let removed_active_doll = projection.active_dolls.remove(user_id).is_some();
let removed_position = projection.positions.remove(user_id).is_some();
if removed_active_doll || removed_position {
emit_snapshot(&projection.positions);
}
}
pub fn set_active_doll(user_id: &str, has_active_doll: bool) {
let mut projection = FRIEND_CURSOR_PROJECTION
.write()
.expect("friend cursor projection lock poisoned");
projection
.active_dolls
.insert(user_id.to_string(), has_active_doll);
if !has_active_doll {
if projection.positions.remove(user_id).is_some() {
emit_snapshot(&projection.positions);
}
}
}
fn has_active_doll(projection: &mut FriendCursorProjection, user_id: &str) -> bool {
if let Some(has_active_doll) = projection.active_dolls.get(user_id) {
return *has_active_doll;
}
let has_active_doll = {
let guard = lock_r!(FDOLL);
guard
.user_data
.friends
.as_ref()
.and_then(|friends| {
friends.iter().find_map(|friendship| {
let friend = friendship.friend.as_ref()?;
(friend.id == user_id).then_some(friend)
})
})
.and_then(|friend| friend.active_doll.as_ref())
.is_some()
};
projection
.active_dolls
.insert(user_id.to_string(), has_active_doll);
has_active_doll
}
fn emit_snapshot(positions: &HashMap<String, CursorPositions>) {
let payload = FriendCursorPositionsDto(positions.clone());
if let Err(err) = FriendCursorPositionsUpdated(payload).emit(get_app_handle()) {
tracing::warn!("Failed to emit friend cursor positions update: {}", err);
}
}

View File

@@ -8,6 +8,7 @@ pub mod auth;
pub mod client_config_manager;
pub mod cursor;
pub mod doll_editor;
pub mod friend_cursor;
pub mod health_manager;
pub mod health_monitor;
pub mod interaction;

View File

@@ -7,18 +7,16 @@ use crate::models::event_payloads::{
UnfriendedPayload,
};
use crate::services::app_events::{
FriendActiveDollChanged, FriendCursorPositionUpdated, FriendDisconnected,
FriendRequestAccepted, FriendRequestDenied, FriendRequestReceived, FriendUserStatusChanged,
Unfriended,
FriendActiveDollChanged, FriendDisconnected, FriendRequestAccepted, FriendRequestDenied,
FriendRequestReceived, FriendUserStatusChanged, Unfriended,
};
use crate::services::{
cursor::{normalized_to_absolute, CursorPositions},
friend_cursor,
};
use crate::services::cursor::{normalized_to_absolute, CursorPositions};
use crate::state::AppDataRefreshScope;
use super::{
emitter, refresh,
types::{IncomingFriendCursorPayload, OutgoingFriendCursorPayload},
utils,
};
use super::{emitter, refresh, types::IncomingFriendCursorPayload, utils};
/// Handler for friend-request-received event
pub fn on_friend_request_received(payload: Payload, _socket: RawClient) {
@@ -64,15 +62,13 @@ pub fn on_friend_cursor_position(payload: Payload, _socket: RawClient) {
let mapped_pos = &friend_data.position;
let raw_pos = normalized_to_absolute(mapped_pos);
let outgoing_payload = OutgoingFriendCursorPayload {
user_id: friend_data.user_id,
position: CursorPositions {
friend_cursor::update_position(
friend_data.user_id,
CursorPositions {
raw: raw_pos,
mapped: mapped_pos.clone(),
},
};
emitter::emit_to_frontend_typed(&FriendCursorPositionUpdated(outgoing_payload));
);
}
}
@@ -81,6 +77,7 @@ pub fn on_friend_disconnected(payload: Payload, _socket: RawClient) {
if let Ok(data) =
utils::extract_and_parse::<FriendDisconnectedPayload>(payload, "friend-disconnected")
{
friend_cursor::remove_friend(&data.user_id);
emitter::emit_to_frontend_typed(&FriendDisconnected(data));
}
}
@@ -114,6 +111,7 @@ pub fn on_friend_active_doll_changed(payload: Payload, _socket: RawClient) {
payload,
"friend-active-doll-changed",
) {
friend_cursor::set_active_doll(&data.friend_id, data.doll.is_some());
emitter::emit_to_frontend_typed(&FriendActiveDollChanged(data));
refresh::refresh_app_data(AppDataRefreshScope::Friends);
}

View File

@@ -1,7 +1,7 @@
use crate::{
get_app_handle, lock_r, lock_w,
remotes::{dolls::DollsRemote, friends::FriendRemote, user::UserRemote},
services::app_events::AppDataRefreshed,
services::{app_events::AppDataRefreshed, friend_cursor},
state::FDOLL,
};
use std::{collections::HashSet, sync::LazyLock};
@@ -161,6 +161,8 @@ pub async fn init_app_data_scoped(scope: AppDataRefreshScope) {
Ok(friends) => {
let mut guard = lock_w!(crate::state::FDOLL);
guard.user_data.friends = Some(friends);
drop(guard);
friend_cursor::sync_from_app_data();
}
Err(e) => {
warn!("Failed to fetch friends list: {}", e);
@@ -260,4 +262,6 @@ pub fn clear_app_data() {
guard.user_data.dolls = None;
guard.user_data.user = None;
guard.user_data.friends = None;
drop(guard);
friend_cursor::clear();
}

View File

@@ -1,81 +1,32 @@
import { writable } from "svelte/store";
import {
events,
type CursorPositions,
type DollDto,
type OutgoingFriendCursorPayload,
} from "$lib/bindings";
import { createEventSource, removeFromStore } from "./listener-utils";
type FriendCursorData = {
position: CursorPositions;
lastUpdated: number;
};
import { events, type CursorPositions } from "$lib/bindings";
import { createEventSource } from "./listener-utils";
export const friendsCursorPositions = writable<Record<string, CursorPositions>>(
{},
);
export const friendsActiveDolls = writable<Record<string, DollDto | null>>({});
// Here for now. Will extract into shared
// util when there's more similar cases.
function toCursorPositionsRecord(
payload: Partial<Record<string, CursorPositions>>,
): Record<string, CursorPositions> {
return Object.fromEntries(
Object.entries(payload).filter(
(entry): entry is [string, CursorPositions] => {
return entry[1] !== undefined;
},
),
);
}
export const {
start: startFriendCursorTracking,
stop: stopFriendCursorTracking,
} = createEventSource(async (addEventListener) => {
let friendCursorState: Record<string, FriendCursorData> = {};
addEventListener(
await events.friendCursorPositionUpdated.listen((event) => {
const data: OutgoingFriendCursorPayload = event.payload;
friendCursorState[data.userId] = {
position: data.position,
lastUpdated: Date.now(),
};
friendsCursorPositions.update((current) => {
return {
...current,
[data.userId]: data.position,
};
});
}),
);
addEventListener(
await events.friendDisconnected.listen((event) => {
const data = event.payload;
if (friendCursorState[data.userId]) {
delete friendCursorState[data.userId];
}
friendsCursorPositions.update((current) =>
removeFromStore(current, data.userId),
);
}),
);
addEventListener(
await events.friendActiveDollChanged.listen((event) => {
const payload = event.payload;
if (!payload.doll) {
friendsActiveDolls.update((current) => {
const next = { ...current };
next[payload.friendId] = null;
return next;
});
friendsCursorPositions.update((current) =>
removeFromStore(current, payload.friendId),
);
} else {
friendsActiveDolls.update((current) => {
return {
...current,
[payload.friendId]: payload.doll,
};
});
}
await events.friendCursorPositionsUpdated.listen((event) => {
friendsCursorPositions.set(toCursorPositionsRecord(event.payload));
}),
);
});

View File

@@ -130,7 +130,7 @@ createDoll: CreateDoll,
cursorMoved: CursorMoved,
editDoll: EditDoll,
friendActiveDollChanged: FriendActiveDollChanged,
friendCursorPositionUpdated: FriendCursorPositionUpdated,
friendCursorPositionsUpdated: FriendCursorPositionsUpdated,
friendDisconnected: FriendDisconnected,
friendRequestAccepted: FriendRequestAccepted,
friendRequestDenied: FriendRequestDenied,
@@ -148,7 +148,7 @@ createDoll: "create-doll",
cursorMoved: "cursor-moved",
editDoll: "edit-doll",
friendActiveDollChanged: "friend-active-doll-changed",
friendCursorPositionUpdated: "friend-cursor-position-updated",
friendCursorPositionsUpdated: "friend-cursor-positions-updated",
friendDisconnected: "friend-disconnected",
friendRequestAccepted: "friend-request-accepted",
friendRequestDenied: "friend-request-denied",
@@ -182,7 +182,8 @@ export type DollDto = { id: string; name: string; configuration: DollConfigurati
export type EditDoll = string
export type FriendActiveDollChanged = FriendActiveDollChangedPayload
export type FriendActiveDollChangedPayload = { friendId: string; doll: DollDto | null }
export type FriendCursorPositionUpdated = OutgoingFriendCursorPayload
export type FriendCursorPositionsDto = Partial<{ [key in string]: CursorPositions }>
export type FriendCursorPositionsUpdated = FriendCursorPositionsDto
export type FriendDisconnected = FriendDisconnectedPayload
export type FriendDisconnectedPayload = { userId: string }
export type FriendRequestAccepted = FriendRequestAcceptedPayload
@@ -200,10 +201,6 @@ export type InteractionDeliveryFailedDto = { recipientUserId: string; reason: st
export type InteractionPayloadDto = { senderUserId: string; senderName: string; content: string; type: string; timestamp: string }
export type InteractionReceived = InteractionPayloadDto
export type ModuleMetadata = { id: string; name: string; version: string; description: string | null }
/**
* Outgoing friend cursor position to frontend
*/
export type OutgoingFriendCursorPayload = { userId: string; position: CursorPositions }
export type PresenceStatus = { title: string | null; subtitle: string | null; graphicsB64: string | null }
export type SceneData = { display: DisplayData; grid_size: number }
export type SceneInteractiveChanged = boolean