Compare commits

..

2 Commits

31 changed files with 609 additions and 296 deletions

View File

@@ -1,7 +1,7 @@
use crate::{ use crate::{
lock_r, lock_r,
models::app_data::UserData, models::app_data::UserData,
services::presence_modules::models::ModuleMetadata, services::{presence_modules::models::ModuleMetadata, presence_state::PresenceStateSnapshot},
state::{init_app_data_scoped, AppDataRefreshScope, FDOLL}, state::{init_app_data_scoped, AppDataRefreshScope, FDOLL},
}; };
@@ -26,3 +26,9 @@ pub fn get_modules() -> Result<Vec<ModuleMetadata>, String> {
let guard = lock_r!(FDOLL); let guard = lock_r!(FDOLL);
Ok(guard.modules.metadatas.clone()) Ok(guard.modules.metadatas.clone())
} }
#[tauri::command]
#[specta::specta]
pub fn get_presence_state() -> Result<PresenceStateSnapshot, String> {
Ok(crate::services::presence_state::get_presence_state_snapshot())
}

View File

@@ -1,4 +1,5 @@
use crate::{ use crate::{
get_app_handle,
models::dolls::{CreateDollDto, DollDto, UpdateDollDto}, models::dolls::{CreateDollDto, DollDto, UpdateDollDto},
remotes::{ remotes::{
dolls::DollsRemote, dolls::DollsRemote,
@@ -7,6 +8,9 @@ use crate::{
state::AppDataRefreshScope, state::AppDataRefreshScope,
commands::{refresh_app_data, refresh_app_data_conditionally, is_active_doll}, commands::{refresh_app_data, refresh_app_data_conditionally, is_active_doll},
}; };
use crate::commands::scene::get_user_active_doll;
use crate::services::app_events::UserActiveDollUpdated;
use tauri_specta::Event as _;
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
@@ -87,6 +91,9 @@ pub async fn set_active_doll(doll_id: String) -> Result<(), String> {
refresh_app_data(&[AppDataRefreshScope::User, AppDataRefreshScope::Friends]).await; refresh_app_data(&[AppDataRefreshScope::User, AppDataRefreshScope::Friends]).await;
let active_doll = get_user_active_doll().ok().flatten();
let _ = UserActiveDollUpdated(active_doll).emit(get_app_handle());
Ok(()) Ok(())
} }
@@ -100,5 +107,8 @@ pub async fn remove_active_doll() -> Result<(), String> {
refresh_app_data(&[AppDataRefreshScope::User, AppDataRefreshScope::Friends]).await; refresh_app_data(&[AppDataRefreshScope::User, AppDataRefreshScope::Friends]).await;
let active_doll = get_user_active_doll().ok().flatten();
let _ = UserActiveDollUpdated(active_doll).emit(get_app_handle());
Ok(()) Ok(())
} }

View File

@@ -7,6 +7,7 @@ pub mod friends;
pub mod interaction; pub mod interaction;
pub mod sprite; pub mod sprite;
pub mod petpet; pub mod petpet;
pub mod scene;
use crate::lock_r; use crate::lock_r;
use crate::state::{init_app_data_scoped, AppDataRefreshScope, FDOLL}; use crate::state::{init_app_data_scoped, AppDataRefreshScope, FDOLL};

View File

@@ -0,0 +1,32 @@
use crate::{
lock_r,
models::{dolls::DollDto, scene::SceneFriendNeko},
state::FDOLL,
};
#[tauri::command]
#[specta::specta]
pub fn get_user_active_doll() -> Result<Option<DollDto>, String> {
let guard = lock_r!(FDOLL);
let Some(user) = &guard.user_data.user else {
return Ok(None);
};
let Some(active_doll_id) = &user.active_doll_id else {
return Ok(None);
};
Ok(guard.user_data.dolls.as_ref().and_then(|dolls| {
dolls
.iter()
.find(|doll| doll.id == *active_doll_id)
.cloned()
}))
}
#[tauri::command]
#[specta::specta]
pub fn get_scene_friends() -> Result<Vec<SceneFriendNeko>, String> {
Ok(crate::services::scene_friends::get_scene_friends_snapshot())
}

View File

@@ -1,10 +1,11 @@
use crate::{ use crate::{
commands::app_state::get_modules, commands::app_state::{get_modules, get_presence_state},
services::{ services::{
doll_editor::open_doll_editor_window, doll_editor::open_doll_editor_window,
scene::{get_scene_interactive, set_pet_menu_state, set_scene_interactive}, scene::{get_scene_interactive, set_pet_menu_state, set_scene_interactive},
}, },
}; };
use commands::scene::{get_scene_friends, get_user_active_doll};
use commands::app::{quit_app, restart_app, retry_connection}; use commands::app::{quit_app, restart_app, retry_connection};
use commands::app_state::{get_app_data, refresh_app_data}; use commands::app_state::{get_app_data, refresh_app_data};
use commands::auth::{change_password, login, logout_and_restart, register, reset_password}; use commands::auth::{change_password, login, logout_and_restart, register, reset_password};
@@ -25,10 +26,11 @@ use tauri_specta::{Builder as SpectaBuilder, ErrorHandlingMode, collect_commands
use crate::services::app_events::{ use crate::services::app_events::{
AppDataRefreshed, CreateDoll, CursorMoved, EditDoll, FriendActiveDollChanged, AppDataRefreshed, CreateDoll, CursorMoved, EditDoll, FriendActiveDollChanged,
FriendCursorPositionsUpdated, FriendDisconnected, FriendCursorPositionUpdated, FriendDisconnected, FriendRequestAccepted,
FriendRequestAccepted, FriendRequestDenied, FriendRequestReceived, FriendRequestDenied, FriendRequestReceived, FriendUserStatusChanged, PresenceStateUpdated,
FriendUserStatusChanged, InteractionDeliveryFailed, InteractionReceived, SceneFriendsUpdated,
SceneInteractiveChanged, SetInteractionOverlay, Unfriended, UserStatusChanged, InteractionDeliveryFailed, InteractionReceived, SceneInteractiveChanged,
SetInteractionOverlay, Unfriended, UserActiveDollUpdated, UserStatusChanged,
}; };
static APP_HANDLE: std::sync::OnceLock<tauri::AppHandle<tauri::Wry>> = std::sync::OnceLock::new(); static APP_HANDLE: std::sync::OnceLock<tauri::AppHandle<tauri::Wry>> = std::sync::OnceLock::new();
@@ -93,13 +95,16 @@ pub fn run() {
get_scene_interactive, get_scene_interactive,
set_scene_interactive, set_scene_interactive,
set_pet_menu_state, set_pet_menu_state,
get_user_active_doll,
get_scene_friends,
login, login,
register, register,
change_password, change_password,
reset_password, reset_password,
logout_and_restart, logout_and_restart,
send_interaction_cmd, send_interaction_cmd,
get_modules get_modules,
get_presence_state
]) ])
.events(collect_events![ .events(collect_events![
CursorMoved, CursorMoved,
@@ -109,10 +114,13 @@ pub fn run() {
EditDoll, EditDoll,
CreateDoll, CreateDoll,
UserStatusChanged, UserStatusChanged,
FriendCursorPositionsUpdated, UserActiveDollUpdated,
FriendCursorPositionUpdated,
FriendDisconnected, FriendDisconnected,
FriendActiveDollChanged, FriendActiveDollChanged,
FriendUserStatusChanged, FriendUserStatusChanged,
PresenceStateUpdated,
SceneFriendsUpdated,
InteractionReceived, InteractionReceived,
InteractionDeliveryFailed, InteractionDeliveryFailed,
FriendRequestReceived, FriendRequestReceived,

View File

@@ -1,34 +1,34 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use specta::Type; use specta::Type;
#[derive(Default, Serialize, Deserialize, Clone, Debug, Type)] #[derive(Default, Serialize, Deserialize, Clone, Debug, Type, PartialEq, Eq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct DollColorSchemeDto { pub struct DollColorSchemeDto {
pub outline: String, pub outline: String,
pub body: String, pub body: String,
} }
#[derive(Default, Serialize, Deserialize, Clone, Debug, Type)] #[derive(Default, Serialize, Deserialize, Clone, Debug, Type, PartialEq, Eq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct DollConfigurationDto { pub struct DollConfigurationDto {
pub color_scheme: DollColorSchemeDto, pub color_scheme: DollColorSchemeDto,
} }
#[derive(Default, Serialize, Deserialize, Clone, Debug, Type)] #[derive(Default, Serialize, Deserialize, Clone, Debug, Type, PartialEq, Eq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct CreateDollDto { pub struct CreateDollDto {
pub name: String, pub name: String,
pub configuration: Option<DollConfigurationDto>, pub configuration: Option<DollConfigurationDto>,
} }
#[derive(Default, Serialize, Deserialize, Clone, Debug, Type)] #[derive(Default, Serialize, Deserialize, Clone, Debug, Type, PartialEq, Eq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct UpdateDollDto { pub struct UpdateDollDto {
pub name: Option<String>, pub name: Option<String>,
pub configuration: Option<DollConfigurationDto>, pub configuration: Option<DollConfigurationDto>,
} }
#[derive(Default, Serialize, Deserialize, Clone, Debug, Type)] #[derive(Default, Serialize, Deserialize, Clone, Debug, Type, PartialEq, Eq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct DollDto { pub struct DollDto {
pub id: String, pub id: String,

View File

@@ -5,14 +5,14 @@ use super::dolls::DollDto;
use super::friends::UserBasicDto; use super::friends::UserBasicDto;
use crate::services::presence_modules::models::PresenceStatus; use crate::services::presence_modules::models::PresenceStatus;
#[derive(Clone, Serialize, Deserialize, Debug, Type)] #[derive(Clone, Serialize, Deserialize, Debug, Type, PartialEq, Eq)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum UserStatusState { pub enum UserStatusState {
Idle, Idle,
Resting, Resting,
} }
#[derive(Clone, Serialize, Deserialize, Debug, Type)] #[derive(Clone, Serialize, Deserialize, Debug, Type, PartialEq, Eq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct UserStatusPayload { pub struct UserStatusPayload {
pub presence_status: PresenceStatus, pub presence_status: PresenceStatus,

View File

@@ -5,4 +5,5 @@ pub mod friends;
pub mod health; pub mod health;
pub mod interaction; pub mod interaction;
pub mod remote_error; pub mod remote_error;
pub mod scene;
pub mod user; pub mod user;

View File

@@ -0,0 +1,12 @@
use serde::{Deserialize, Serialize};
use specta::Type;
use crate::{models::dolls::DollDto, services::cursor::CursorPositions};
#[derive(Clone, Serialize, Deserialize, Debug, Type)]
#[serde(rename_all = "camelCase")]
pub struct SceneFriendNeko {
pub id: String,
pub position: CursorPositions,
pub active_doll: DollDto,
}

View File

@@ -5,15 +5,17 @@ use tauri_specta::Event;
use crate::{ use crate::{
models::{ models::{
app_data::UserData, app_data::UserData,
dolls::DollDto,
event_payloads::{ event_payloads::{
FriendActiveDollChangedPayload, FriendDisconnectedPayload, FriendActiveDollChangedPayload, FriendDisconnectedPayload,
FriendRequestAcceptedPayload, FriendRequestDeniedPayload, FriendRequestReceivedPayload, FriendRequestAcceptedPayload, FriendRequestDeniedPayload, FriendRequestReceivedPayload,
FriendUserStatusPayload, UnfriendedPayload, UserStatusPayload, FriendUserStatusPayload, UnfriendedPayload, UserStatusPayload,
}, },
interaction::{InteractionDeliveryFailedDto, InteractionPayloadDto}, interaction::{InteractionDeliveryFailedDto, InteractionPayloadDto},
scene::SceneFriendNeko,
}, },
services::{ services::{
cursor::CursorPositions, friend_cursor::FriendCursorPositionsDto, cursor::CursorPositions, presence_state::PresenceStateSnapshot,
ws::OutgoingFriendCursorPayload, ws::OutgoingFriendCursorPayload,
}, },
}; };
@@ -47,12 +49,12 @@ pub struct CreateDoll;
pub struct UserStatusChanged(pub UserStatusPayload); pub struct UserStatusChanged(pub UserStatusPayload);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] #[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "friend-cursor-position")] #[tauri_specta(event_name = "user-active-doll-updated")]
pub struct FriendCursorPositionUpdated(pub OutgoingFriendCursorPayload); pub struct UserActiveDollUpdated(pub Option<DollDto>);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] #[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "friend-cursor-positions")] #[tauri_specta(event_name = "friend-cursor-position")]
pub struct FriendCursorPositionsUpdated(pub FriendCursorPositionsDto); pub struct FriendCursorPositionUpdated(pub OutgoingFriendCursorPayload);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] #[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "friend-disconnected")] #[tauri_specta(event_name = "friend-disconnected")]
@@ -66,6 +68,14 @@ pub struct FriendActiveDollChanged(pub FriendActiveDollChangedPayload);
#[tauri_specta(event_name = "friend-user-status")] #[tauri_specta(event_name = "friend-user-status")]
pub struct FriendUserStatusChanged(pub FriendUserStatusPayload); pub struct FriendUserStatusChanged(pub FriendUserStatusPayload);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "scene-friends-updated")]
pub struct SceneFriendsUpdated(pub Vec<SceneFriendNeko>);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "presence-state-updated")]
pub struct PresenceStateUpdated(pub PresenceStateSnapshot);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] #[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "interaction-received")] #[tauri_specta(event_name = "interaction-received")]
pub struct InteractionReceived(pub InteractionPayloadDto); pub struct InteractionReceived(pub InteractionPayloadDto);

View File

@@ -15,14 +15,14 @@ use crate::{
}; };
use tauri_specta::Event as _; use tauri_specta::Event as _;
#[derive(Debug, Clone, Serialize, Deserialize, Type)] #[derive(Debug, Clone, Serialize, Deserialize, Type, PartialEq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct CursorPosition { pub struct CursorPosition {
pub x: f64, pub x: f64,
pub y: f64, pub y: f64,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Type)] #[derive(Debug, Clone, Serialize, Deserialize, Type, PartialEq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct CursorPositions { pub struct CursorPositions {
pub raw: CursorPosition, pub raw: CursorPosition,

View File

@@ -1,147 +0,0 @@
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,13 +8,14 @@ pub mod auth;
pub mod client_config_manager; pub mod client_config_manager;
pub mod cursor; pub mod cursor;
pub mod doll_editor; pub mod doll_editor;
pub mod friend_cursor;
pub mod health_manager; pub mod health_manager;
pub mod health_monitor; pub mod health_monitor;
pub mod interaction; pub mod interaction;
pub mod petpet; pub mod petpet;
pub mod presence_state;
pub mod presence_modules; pub mod presence_modules;
pub mod scene; pub mod scene;
pub mod scene_friends;
pub mod sprite_recolor; pub mod sprite_recolor;
pub mod welcome; pub mod welcome;
pub mod ws; pub mod ws;

View File

@@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use specta::Type; use specta::Type;
#[derive(Debug, Clone, Serialize, Deserialize, Type)] #[derive(Debug, Clone, Serialize, Deserialize, Type, PartialEq, Eq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct PresenceStatus { pub struct PresenceStatus {
pub title: Option<String>, pub title: Option<String>,

View File

@@ -0,0 +1,73 @@
use crate::{
get_app_handle, lock_r, lock_w,
models::event_payloads::UserStatusPayload,
services::app_events::PresenceStateUpdated,
state::{get_known_friend_ids, FDOLL},
};
use serde::{Deserialize, Serialize};
use specta::Type;
use tauri_specta::Event as _;
use tracing::error;
#[derive(Clone, Serialize, Deserialize, Debug, Type)]
#[serde(rename_all = "camelCase")]
pub struct PresenceStateSnapshot {
pub current: Option<UserStatusPayload>,
pub friends: std::collections::HashMap<String, UserStatusPayload>,
}
pub fn get_presence_state_snapshot() -> PresenceStateSnapshot {
let guard = lock_r!(FDOLL);
PresenceStateSnapshot {
current: guard.presence.current.clone(),
friends: guard.presence.friends.clone(),
}
}
pub fn set_current_presence(status: UserStatusPayload) -> bool {
let mut guard = lock_w!(FDOLL);
if guard.presence.current.as_ref() == Some(&status) {
return false;
}
guard.presence.current = Some(status);
true
}
pub fn set_friend_presence(friend_id: String, status: UserStatusPayload) -> bool {
let mut guard = lock_w!(FDOLL);
if guard.presence.friends.get(&friend_id) == Some(&status) {
return false;
}
guard.presence.friends.insert(friend_id, status);
true
}
pub fn remove_friend_presence(friend_id: &str) -> bool {
let mut guard = lock_w!(FDOLL);
guard.presence.friends.remove(friend_id).is_some()
}
pub fn clear_missing_friends_from_presence_state() -> bool {
let friend_ids = get_known_friend_ids();
let mut guard = lock_w!(FDOLL);
let initial_count = guard.presence.friends.len();
guard
.presence
.friends
.retain(|friend_id, _| friend_ids.contains(friend_id));
guard.presence.friends.len() != initial_count
}
pub fn emit_presence_state_updated() {
let snapshot = get_presence_state_snapshot();
if let Err(err) = PresenceStateUpdated(snapshot).emit(get_app_handle()) {
error!("Failed to emit presence state updated event: {}", err);
}
}

View File

@@ -0,0 +1,99 @@
use crate::{
get_app_handle, lock_r, lock_w,
models::{dolls::DollDto, scene::SceneFriendNeko},
services::{app_events::SceneFriendsUpdated, cursor::CursorPositions},
state::{get_known_friend_ids, FDOLL},
};
use tauri_specta::Event as _;
use tracing::error;
pub fn get_scene_friends_snapshot() -> Vec<SceneFriendNeko> {
let guard = lock_r!(FDOLL);
(guard.user_data.friends.as_ref())
.into_iter()
.flatten()
.filter_map(|friendship| {
let friend = friendship.friend.as_ref()?;
let position = guard.friend_scene.cursor_positions.get(&friend.id)?.clone();
let active_doll = guard
.friend_scene
.active_dolls
.get(&friend.id)
.cloned()
.flatten()
.or_else(|| friend.active_doll.clone())?;
Some(SceneFriendNeko {
id: friend.id.clone(),
position,
active_doll,
})
})
.collect()
}
pub fn set_friend_cursor_position(friend_id: String, position: CursorPositions) -> bool {
let mut guard = lock_w!(FDOLL);
if guard.friend_scene.cursor_positions.get(&friend_id) == Some(&position) {
return false;
}
guard
.friend_scene
.cursor_positions
.insert(friend_id, position);
true
}
pub fn set_friend_active_doll(friend_id: String, doll: Option<DollDto>) -> bool {
let mut guard = lock_w!(FDOLL);
if guard.friend_scene.active_dolls.get(&friend_id) == Some(&doll) {
return false;
}
guard.friend_scene.active_dolls.insert(friend_id, doll);
true
}
pub fn remove_friend(friend_id: &str) -> bool {
let mut guard = lock_w!(FDOLL);
let removed_cursor = guard
.friend_scene
.cursor_positions
.remove(friend_id)
.is_some();
let removed_doll = guard.friend_scene.active_dolls.remove(friend_id).is_some();
removed_cursor || removed_doll
}
pub fn clear_missing_friends_from_runtime_state() -> bool {
let friend_ids = get_known_friend_ids();
let mut guard = lock_w!(FDOLL);
let initial_cursor_count = guard.friend_scene.cursor_positions.len();
let initial_active_doll_count = guard.friend_scene.active_dolls.len();
guard
.friend_scene
.cursor_positions
.retain(|friend_id, _| friend_ids.contains(friend_id));
guard
.friend_scene
.active_dolls
.retain(|friend_id, _| friend_ids.contains(friend_id));
guard.friend_scene.cursor_positions.len() != initial_cursor_count
|| guard.friend_scene.active_dolls.len() != initial_active_doll_count
}
pub fn emit_scene_friends_updated() {
let snapshot = get_scene_friends_snapshot();
if let Err(err) = SceneFriendsUpdated(snapshot).emit(get_app_handle()) {
error!("Failed to emit scene friends updated event: {}", err);
}
}

View File

@@ -7,16 +7,21 @@ use crate::models::event_payloads::{
UnfriendedPayload, UnfriendedPayload,
}; };
use crate::services::app_events::{ use crate::services::app_events::{
FriendActiveDollChanged, FriendDisconnected, FriendRequestAccepted, FriendRequestDenied, FriendActiveDollChanged, FriendCursorPositionUpdated, FriendDisconnected,
FriendRequestReceived, FriendUserStatusChanged, Unfriended, FriendRequestAccepted, FriendRequestDenied, FriendRequestReceived, FriendUserStatusChanged,
Unfriended,
}; };
use crate::services::{ use crate::services::{
cursor::{normalized_to_absolute, CursorPositions}, cursor::{normalized_to_absolute, CursorPositions},
friend_cursor, presence_state, scene_friends,
}; };
use crate::state::AppDataRefreshScope; use crate::state::AppDataRefreshScope;
use super::{emitter, refresh, types::IncomingFriendCursorPayload, utils}; use super::{
emitter, refresh,
types::{IncomingFriendCursorPayload, OutgoingFriendCursorPayload},
utils,
};
/// Handler for friend-request-received event /// Handler for friend-request-received event
pub fn on_friend_request_received(payload: Payload, _socket: RawClient) { pub fn on_friend_request_received(payload: Payload, _socket: RawClient) {
@@ -62,13 +67,23 @@ pub fn on_friend_cursor_position(payload: Payload, _socket: RawClient) {
let mapped_pos = &friend_data.position; let mapped_pos = &friend_data.position;
let raw_pos = normalized_to_absolute(mapped_pos); let raw_pos = normalized_to_absolute(mapped_pos);
friend_cursor::update_position( let outgoing_payload = OutgoingFriendCursorPayload {
friend_data.user_id, user_id: friend_data.user_id,
CursorPositions { position: CursorPositions {
raw: raw_pos, raw: raw_pos,
mapped: mapped_pos.clone(), mapped: mapped_pos.clone(),
}, },
};
let scene_friends_changed = scene_friends::set_friend_cursor_position(
outgoing_payload.user_id.clone(),
outgoing_payload.position.clone(),
); );
emitter::emit_to_frontend_typed(&FriendCursorPositionUpdated(outgoing_payload));
if scene_friends_changed {
scene_friends::emit_scene_friends_updated();
}
} }
} }
@@ -77,8 +92,15 @@ pub fn on_friend_disconnected(payload: Payload, _socket: RawClient) {
if let Ok(data) = if let Ok(data) =
utils::extract_and_parse::<FriendDisconnectedPayload>(payload, "friend-disconnected") utils::extract_and_parse::<FriendDisconnectedPayload>(payload, "friend-disconnected")
{ {
friend_cursor::remove_friend(&data.user_id); let scene_friends_changed = scene_friends::remove_friend(&data.user_id);
let presence_changed = presence_state::remove_friend_presence(&data.user_id);
emitter::emit_to_frontend_typed(&FriendDisconnected(data)); emitter::emit_to_frontend_typed(&FriendDisconnected(data));
if scene_friends_changed {
scene_friends::emit_scene_friends_updated();
}
if presence_changed {
presence_state::emit_presence_state_updated();
}
} }
} }
@@ -111,7 +133,7 @@ pub fn on_friend_active_doll_changed(payload: Payload, _socket: RawClient) {
payload, payload,
"friend-active-doll-changed", "friend-active-doll-changed",
) { ) {
friend_cursor::set_active_doll(&data.friend_id, data.doll.is_some()); scene_friends::set_friend_active_doll(data.friend_id.clone(), data.doll.clone());
emitter::emit_to_frontend_typed(&FriendActiveDollChanged(data)); emitter::emit_to_frontend_typed(&FriendActiveDollChanged(data));
refresh::refresh_app_data(AppDataRefreshScope::Friends); refresh::refresh_app_data(AppDataRefreshScope::Friends);
} }
@@ -122,6 +144,11 @@ pub fn on_friend_user_status(payload: Payload, _socket: RawClient) {
if let Ok(data) = if let Ok(data) =
utils::extract_and_parse::<FriendUserStatusPayload>(payload, "friend-user-status") utils::extract_and_parse::<FriendUserStatusPayload>(payload, "friend-user-status")
{ {
let presence_changed =
presence_state::set_friend_presence(data.user_id.clone(), data.status.clone());
emitter::emit_to_frontend_typed(&FriendUserStatusChanged(data)); emitter::emit_to_frontend_typed(&FriendUserStatusChanged(data));
if presence_changed {
presence_state::emit_presence_state_updated();
}
} }
} }

View File

@@ -8,6 +8,7 @@ use tracing::warn;
use crate::models::event_payloads::UserStatusPayload; use crate::models::event_payloads::UserStatusPayload;
use crate::services::app_events::UserStatusChanged; use crate::services::app_events::UserStatusChanged;
use crate::services::presence_state;
use super::{emitter, types::WS_EVENT}; use super::{emitter, types::WS_EVENT};
@@ -28,6 +29,10 @@ pub async fn report_user_status(status: UserStatusPayload) {
warn!("Failed to emit user-status-changed event: {e}"); warn!("Failed to emit user-status-changed event: {e}");
} }
if presence_state::set_current_presence(status.clone()) {
presence_state::emit_presence_state_updated();
}
// Schedule new report after 500ms // Schedule new report after 500ms
let handle = async_runtime::spawn(async move { let handle = async_runtime::spawn(async move {
tokio::time::sleep(Duration::from_millis(500)).await; tokio::time::sleep(Duration::from_millis(500)).await;

View File

@@ -1,7 +1,14 @@
// in app-core/src/state.rs // in app-core/src/state.rs
use crate::{ use crate::{
lock_w, models::app_data::UserData, services::presence_modules::models::ModuleMetadata, lock_r,
lock_w,
models::{app_data::UserData, dolls::DollDto},
services::{
cursor::CursorPositions,
presence_modules::models::ModuleMetadata,
},
}; };
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, LazyLock, RwLock}; use std::sync::{Arc, LazyLock, RwLock};
use tauri::tray::TrayIcon; use tauri::tray::TrayIcon;
use tracing::info; use tracing::info;
@@ -20,12 +27,26 @@ pub struct Modules {
pub metadatas: Vec<ModuleMetadata>, pub metadatas: Vec<ModuleMetadata>,
} }
#[derive(Default, Clone)]
pub struct FriendSceneRuntimeState {
pub cursor_positions: HashMap<String, CursorPositions>,
pub active_dolls: HashMap<String, Option<DollDto>>,
}
#[derive(Default, Clone)]
pub struct PresenceRuntimeState {
pub current: Option<crate::models::event_payloads::UserStatusPayload>,
pub friends: HashMap<String, crate::models::event_payloads::UserStatusPayload>,
}
#[derive(Default)] #[derive(Default)]
pub struct AppState { pub struct AppState {
pub app_config: crate::services::client_config_manager::AppConfig, pub app_config: crate::services::client_config_manager::AppConfig,
pub network: NetworkState, pub network: NetworkState,
pub auth: AuthState, pub auth: AuthState,
pub user_data: UserData, pub user_data: UserData,
pub friend_scene: FriendSceneRuntimeState,
pub presence: PresenceRuntimeState,
pub tray: Option<TrayIcon>, pub tray: Option<TrayIcon>,
pub modules: Modules, pub modules: Modules,
} }
@@ -35,6 +56,22 @@ pub struct AppState {
pub static FDOLL: LazyLock<Arc<RwLock<AppState>>> = pub static FDOLL: LazyLock<Arc<RwLock<AppState>>> =
LazyLock::new(|| Arc::new(RwLock::new(AppState::default()))); LazyLock::new(|| Arc::new(RwLock::new(AppState::default())));
pub fn get_known_friend_ids() -> HashSet<String> {
let guard = lock_r!(FDOLL);
guard
.user_data
.friends
.as_ref()
.map(|friends| {
friends
.iter()
.filter_map(|friendship| friendship.friend.as_ref().map(|friend| friend.id.clone()))
.collect()
})
.unwrap_or_default()
}
/// Populate app state with initial /// Populate app state with initial
/// values and necesary client instances. /// values and necesary client instances.
pub fn init_app_state() { pub fn init_app_state() {
@@ -45,6 +82,8 @@ pub fn init_app_state() {
guard.network = init_network_state(); guard.network = init_network_state();
guard.auth = init_auth_state(); guard.auth = init_auth_state();
guard.user_data = UserData::default(); guard.user_data = UserData::default();
guard.friend_scene = FriendSceneRuntimeState::default();
guard.presence = PresenceRuntimeState::default();
guard.modules = Modules::default(); guard.modules = Modules::default();
} }
update_display_dimensions_for_scene_state(); update_display_dimensions_for_scene_state();

View File

@@ -1,7 +1,7 @@
use crate::{ use crate::{
get_app_handle, lock_r, lock_w, get_app_handle, lock_r, lock_w,
remotes::{dolls::DollsRemote, friends::FriendRemote, user::UserRemote}, remotes::{dolls::DollsRemote, friends::FriendRemote, user::UserRemote},
services::{app_events::AppDataRefreshed, friend_cursor}, services::{app_events::AppDataRefreshed, presence_state, scene_friends},
state::FDOLL, state::FDOLL,
}; };
use std::{collections::HashSet, sync::LazyLock}; use std::{collections::HashSet, sync::LazyLock};
@@ -161,8 +161,6 @@ pub async fn init_app_data_scoped(scope: AppDataRefreshScope) {
Ok(friends) => { Ok(friends) => {
let mut guard = lock_w!(crate::state::FDOLL); let mut guard = lock_w!(crate::state::FDOLL);
guard.user_data.friends = Some(friends); guard.user_data.friends = Some(friends);
drop(guard);
friend_cursor::sync_from_app_data();
} }
Err(e) => { Err(e) => {
warn!("Failed to fetch friends list: {}", e); warn!("Failed to fetch friends list: {}", e);
@@ -213,6 +211,15 @@ pub async fn init_app_data_scoped(scope: AppDataRefreshScope) {
let app_data_clone = guard.user_data.clone(); let app_data_clone = guard.user_data.clone();
drop(guard); // Drop lock before emitting to prevent potential deadlocks drop(guard); // Drop lock before emitting to prevent potential deadlocks
let refreshes_friend_runtime = matches!(
scope,
AppDataRefreshScope::All | AppDataRefreshScope::Friends
);
if refreshes_friend_runtime {
scene_friends::clear_missing_friends_from_runtime_state();
presence_state::clear_missing_friends_from_presence_state();
}
if let Err(e) = AppDataRefreshed(app_data_clone).emit(get_app_handle()) { if let Err(e) = AppDataRefreshed(app_data_clone).emit(get_app_handle()) {
warn!("Failed to emit app-data-refreshed event: {}", e); warn!("Failed to emit app-data-refreshed event: {}", e);
use tauri_plugin_dialog::MessageDialogBuilder; use tauri_plugin_dialog::MessageDialogBuilder;
@@ -227,6 +234,13 @@ pub async fn init_app_data_scoped(scope: AppDataRefreshScope) {
.kind(MessageDialogKind::Error) .kind(MessageDialogKind::Error)
.show(|_| {}); .show(|_| {});
} }
if refreshes_friend_runtime {
scene_friends::emit_scene_friends_updated();
}
if refreshes_friend_runtime {
presence_state::emit_presence_state_updated();
}
} }
Ok(()) Ok(())
@@ -262,6 +276,8 @@ pub fn clear_app_data() {
guard.user_data.dolls = None; guard.user_data.dolls = None;
guard.user_data.user = None; guard.user_data.user = None;
guard.user_data.friends = None; guard.user_data.friends = None;
drop(guard); guard.friend_scene.cursor_positions.clear();
friend_cursor::clear(); guard.friend_scene.active_dolls.clear();
guard.presence.current = None;
guard.presence.friends.clear();
} }

View File

@@ -1,32 +1,16 @@
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import { events, type CursorPositions } from "$lib/bindings"; import { commands, events, type SceneFriendNeko } from "$lib/bindings";
import { createEventSource } from "./listener-utils"; import { createEventSource } from "./listener-utils";
export const friendsCursorPositions = writable<Record<string, CursorPositions>>( export const sceneFriends = writable<SceneFriendNeko[]>([]);
{},
);
// Here for now. Will extract into shared export const { start: startFriendCursorTracking, stop: stopFriendCursorTracking } =
// util when there's more similar cases. createEventSource(async (addEventListener) => {
function toCursorPositionsRecord( sceneFriends.set(await commands.getSceneFriends());
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) => {
addEventListener( addEventListener(
await events.friendCursorPositionsUpdated.listen((event) => { await events.sceneFriendsUpdated.listen((event) => {
friendsCursorPositions.set(toCursorPositionsRecord(event.payload)); sceneFriends.set(event.payload);
}), }),
); );
}); });

View File

@@ -1,44 +1,32 @@
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import { events, type UserStatusPayload } from "$lib/bindings"; import {
import { createEventSource, removeFromStore } from "./listener-utils"; commands,
events,
type PresenceStateSnapshot,
type UserStatusPayload,
} from "$lib/bindings";
import { createEventSource } from "./listener-utils";
export const friendsPresenceStates = writable< export const friendsPresenceStates = writable<Record<string, UserStatusPayload>>({});
Record<string, UserStatusPayload>
>({});
export const currentPresenceState = writable<UserStatusPayload | null>(null); export const currentPresenceState = writable<UserStatusPayload | null>(null);
function applyPresenceSnapshot(snapshot: PresenceStateSnapshot) {
currentPresenceState.set(snapshot.current);
const friends = Object.fromEntries(
Object.entries(snapshot.friends).filter(([, status]) => status !== undefined),
) as Record<string, UserStatusPayload>;
friendsPresenceStates.set(friends);
}
export const { start: startUserStatus, stop: stopUserStatus } = export const { start: startUserStatus, stop: stopUserStatus } =
createEventSource(async (addEventListener) => { createEventSource(async (addEventListener) => {
addEventListener( applyPresenceSnapshot(await commands.getPresenceState());
await events.friendUserStatusChanged.listen((event) => {
const { userId, status } = event.payload;
const hasValidName =
(typeof status.presenceStatus.title === "string" &&
status.presenceStatus.title.trim() !== "") ||
(typeof status.presenceStatus.subtitle === "string" &&
status.presenceStatus.subtitle.trim() !== "");
if (!hasValidName) return;
friendsPresenceStates.update((current) => ({
...current,
[userId]: status,
}));
}),
);
addEventListener( addEventListener(
await events.userStatusChanged.listen((event) => { await events.presenceStateUpdated.listen((event) => {
currentPresenceState.set(event.payload); applyPresenceSnapshot(event.payload);
}),
);
addEventListener(
await events.friendDisconnected.listen((event) => {
const { userId } = event.payload;
friendsPresenceStates.update((current) =>
removeFromStore(current, userId),
);
}), }),
); );
}); });

View File

@@ -98,6 +98,12 @@ async setSceneInteractive(interactive: boolean, shouldClick: boolean) : Promise<
async setPetMenuState(id: string, open: boolean) : Promise<void> { async setPetMenuState(id: string, open: boolean) : Promise<void> {
await TAURI_INVOKE("set_pet_menu_state", { id, open }); await TAURI_INVOKE("set_pet_menu_state", { id, open });
}, },
async getUserActiveDoll() : Promise<DollDto | null> {
return await TAURI_INVOKE("get_user_active_doll");
},
async getSceneFriends() : Promise<SceneFriendNeko[]> {
return await TAURI_INVOKE("get_scene_friends");
},
async login(email: string, password: string) : Promise<null> { async login(email: string, password: string) : Promise<null> {
return await TAURI_INVOKE("login", { email, password }); return await TAURI_INVOKE("login", { email, password });
}, },
@@ -118,6 +124,9 @@ async sendInteractionCmd(dto: SendInteractionDto) : Promise<null> {
}, },
async getModules() : Promise<ModuleMetadata[]> { async getModules() : Promise<ModuleMetadata[]> {
return await TAURI_INVOKE("get_modules"); return await TAURI_INVOKE("get_modules");
},
async getPresenceState() : Promise<PresenceStateSnapshot> {
return await TAURI_INVOKE("get_presence_state");
} }
} }
@@ -131,7 +140,6 @@ cursorMoved: CursorMoved,
editDoll: EditDoll, editDoll: EditDoll,
friendActiveDollChanged: FriendActiveDollChanged, friendActiveDollChanged: FriendActiveDollChanged,
friendCursorPositionUpdated: FriendCursorPositionUpdated, friendCursorPositionUpdated: FriendCursorPositionUpdated,
friendCursorPositionsUpdated: FriendCursorPositionsUpdated,
friendDisconnected: FriendDisconnected, friendDisconnected: FriendDisconnected,
friendRequestAccepted: FriendRequestAccepted, friendRequestAccepted: FriendRequestAccepted,
friendRequestDenied: FriendRequestDenied, friendRequestDenied: FriendRequestDenied,
@@ -139,9 +147,12 @@ friendRequestReceived: FriendRequestReceived,
friendUserStatusChanged: FriendUserStatusChanged, friendUserStatusChanged: FriendUserStatusChanged,
interactionDeliveryFailed: InteractionDeliveryFailed, interactionDeliveryFailed: InteractionDeliveryFailed,
interactionReceived: InteractionReceived, interactionReceived: InteractionReceived,
presenceStateUpdated: PresenceStateUpdated,
sceneFriendsUpdated: SceneFriendsUpdated,
sceneInteractiveChanged: SceneInteractiveChanged, sceneInteractiveChanged: SceneInteractiveChanged,
setInteractionOverlay: SetInteractionOverlay, setInteractionOverlay: SetInteractionOverlay,
unfriended: Unfriended, unfriended: Unfriended,
userActiveDollUpdated: UserActiveDollUpdated,
userStatusChanged: UserStatusChanged userStatusChanged: UserStatusChanged
}>({ }>({
appDataRefreshed: "app-data-refreshed", appDataRefreshed: "app-data-refreshed",
@@ -150,7 +161,6 @@ cursorMoved: "cursor-moved",
editDoll: "edit-doll", editDoll: "edit-doll",
friendActiveDollChanged: "friend-active-doll-changed", friendActiveDollChanged: "friend-active-doll-changed",
friendCursorPositionUpdated: "friend-cursor-position-updated", friendCursorPositionUpdated: "friend-cursor-position-updated",
friendCursorPositionsUpdated: "friend-cursor-positions-updated",
friendDisconnected: "friend-disconnected", friendDisconnected: "friend-disconnected",
friendRequestAccepted: "friend-request-accepted", friendRequestAccepted: "friend-request-accepted",
friendRequestDenied: "friend-request-denied", friendRequestDenied: "friend-request-denied",
@@ -158,9 +168,12 @@ friendRequestReceived: "friend-request-received",
friendUserStatusChanged: "friend-user-status-changed", friendUserStatusChanged: "friend-user-status-changed",
interactionDeliveryFailed: "interaction-delivery-failed", interactionDeliveryFailed: "interaction-delivery-failed",
interactionReceived: "interaction-received", interactionReceived: "interaction-received",
presenceStateUpdated: "presence-state-updated",
sceneFriendsUpdated: "scene-friends-updated",
sceneInteractiveChanged: "scene-interactive-changed", sceneInteractiveChanged: "scene-interactive-changed",
setInteractionOverlay: "set-interaction-overlay", setInteractionOverlay: "set-interaction-overlay",
unfriended: "unfriended", unfriended: "unfriended",
userActiveDollUpdated: "user-active-doll-updated",
userStatusChanged: "user-status-changed" userStatusChanged: "user-status-changed"
}) })
@@ -185,8 +198,6 @@ export type EditDoll = string
export type FriendActiveDollChanged = FriendActiveDollChangedPayload export type FriendActiveDollChanged = FriendActiveDollChangedPayload
export type FriendActiveDollChangedPayload = { friendId: string; doll: DollDto | null } export type FriendActiveDollChangedPayload = { friendId: string; doll: DollDto | null }
export type FriendCursorPositionUpdated = OutgoingFriendCursorPayload export type FriendCursorPositionUpdated = OutgoingFriendCursorPayload
export type FriendCursorPositionsDto = Partial<{ [key in string]: CursorPositions }>
export type FriendCursorPositionsUpdated = FriendCursorPositionsDto
export type FriendDisconnected = FriendDisconnectedPayload export type FriendDisconnected = FriendDisconnectedPayload
export type FriendDisconnectedPayload = { userId: string } export type FriendDisconnectedPayload = { userId: string }
export type FriendRequestAccepted = FriendRequestAcceptedPayload export type FriendRequestAccepted = FriendRequestAcceptedPayload
@@ -208,8 +219,12 @@ export type ModuleMetadata = { id: string; name: string; version: string; descri
* Outgoing friend cursor position to frontend * Outgoing friend cursor position to frontend
*/ */
export type OutgoingFriendCursorPayload = { userId: string; position: CursorPositions } export type OutgoingFriendCursorPayload = { userId: string; position: CursorPositions }
export type PresenceStateSnapshot = { current: UserStatusPayload | null; friends: Partial<{ [key in string]: UserStatusPayload }> }
export type PresenceStateUpdated = PresenceStateSnapshot
export type PresenceStatus = { title: string | null; subtitle: string | null; graphicsB64: string | null } export type PresenceStatus = { title: string | null; subtitle: string | null; graphicsB64: string | null }
export type SceneData = { display: DisplayData; grid_size: number } export type SceneData = { display: DisplayData; grid_size: number }
export type SceneFriendNeko = { id: string; position: CursorPositions; activeDoll: DollDto }
export type SceneFriendsUpdated = SceneFriendNeko[]
export type SceneInteractiveChanged = boolean export type SceneInteractiveChanged = boolean
export type SendFriendRequestDto = { receiverId: string } export type SendFriendRequestDto = { receiverId: string }
export type SendInteractionDto = { recipientUserId: string; content: string; type: string } export type SendInteractionDto = { recipientUserId: string; content: string; type: string }
@@ -217,6 +232,7 @@ export type SetInteractionOverlay = boolean
export type Unfriended = UnfriendedPayload export type Unfriended = UnfriendedPayload
export type UnfriendedPayload = { friendId: string } export type UnfriendedPayload = { friendId: string }
export type UpdateDollDto = { name: string | null; configuration: DollConfigurationDto | null } export type UpdateDollDto = { name: string | null; configuration: DollConfigurationDto | null }
export type UserActiveDollUpdated = DollDto | null
export type UserBasicDto = { id: string; name: string; username: string | null; activeDoll: DollDto | null } export type UserBasicDto = { id: string; name: string; username: string | null; activeDoll: DollDto | null }
export type UserData = { user: UserProfile | null; friends: FriendshipResponseDto[] | null; dolls: DollDto[] | null; scene: SceneData } export type UserData = { user: UserProfile | null; friends: FriendshipResponseDto[] | null; dolls: DollDto[] | null; scene: SceneData }
export type UserProfile = { id: string; name: string; email: string; username: string | null; roles: string[]; createdAt: string; updatedAt: string; lastLoginAt: string | null; activeDollId: string | null } export type UserProfile = { id: string; name: string; email: string; username: string | null; roles: string[]; createdAt: string; updatedAt: string; lastLoginAt: string | null; activeDollId: string | null }

11
src/lib/scene.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { DollColorSchemeDto, DollDto } from "$lib/bindings";
export function getDollColorScheme(
doll: DollDto | null | undefined,
): DollColorSchemeDto | undefined {
if (!doll) {
return undefined;
}
return doll.configuration.colorScheme;
}

View File

@@ -1,28 +1,39 @@
import { commands } from "$lib/bindings"; import { commands, type DollColorSchemeDto } from "$lib/bindings";
import onekoGif from "../../assets/oneko/oneko.gif"; import onekoGif from "../../assets/oneko/oneko.gif";
export interface RecolorOptions { const spriteSheetUrlCache = new Map<string, Promise<string>>();
bodyColor: string;
outlineColor: string; function getSpriteCacheKey(options: DollColorSchemeDto): string {
applyTexture?: boolean; return `${options.body}:${options.outline}`;
} }
export async function getSpriteSheetUrl( export async function getSpriteSheetUrl(
options?: RecolorOptions, options?: DollColorSchemeDto,
): Promise<string> { ): Promise<string> {
if (!options || !options.bodyColor || !options.outlineColor) { if (!options || !options.body || !options.outline) {
return onekoGif; return onekoGif;
} }
try { const cacheKey = getSpriteCacheKey(options);
const result = await commands.recolorGifBase64( const cachedSpriteSheet = spriteSheetUrlCache.get(cacheKey);
options.bodyColor,
options.outlineColor, if (cachedSpriteSheet) {
options.applyTexture ?? true, return cachedSpriteSheet;
); }
return `data:image/gif;base64,${result}`;
} catch (e) { const spriteSheetPromise = commands
.recolorGifBase64(
options.body,
options.outline,
true, // default texture to true at this stage, maybe one day open up more customization options
)
.then((result) => `data:image/gif;base64,${result}`)
.catch((e) => {
console.error("Failed to recolor sprite:", e); console.error("Failed to recolor sprite:", e);
spriteSheetUrlCache.delete(cacheKey);
return onekoGif; return onekoGif;
} });
spriteSheetUrlCache.set(cacheKey, spriteSheetPromise);
return spriteSheetPromise;
} }

View File

@@ -3,10 +3,9 @@
import { SPRITE_SETS, SPRITE_SIZE } from "$lib/constants/pet-sprites"; import { SPRITE_SETS, SPRITE_SIZE } from "$lib/constants/pet-sprites";
import { getSpriteSheetUrl } from "$lib/utils/sprite-utils"; import { getSpriteSheetUrl } from "$lib/utils/sprite-utils";
import PetSprite from "$lib/components/PetSprite.svelte"; import PetSprite from "$lib/components/PetSprite.svelte";
import type { DollColorSchemeDto } from "$lib/bindings";
export let bodyColor: string; export let dollColorScheme: DollColorSchemeDto;
export let outlineColor: string;
export let applyTexture: boolean = true;
let previewBase64: string | null = null; let previewBase64: string | null = null;
let error: string | null = null; let error: string | null = null;
@@ -21,11 +20,7 @@
function generatePreview() { function generatePreview() {
error = null; error = null;
getSpriteSheetUrl({ getSpriteSheetUrl(dollColorScheme)
bodyColor,
outlineColor,
applyTexture,
})
.then((url: string) => { .then((url: string) => {
previewBase64 = url; previewBase64 = url;
}) })
@@ -70,7 +65,7 @@
}, 3000); }, 3000);
} }
$: if (bodyColor && outlineColor) { $: if (dollColorScheme) {
debouncedGeneratePreview(); debouncedGeneratePreview();
} }
@@ -103,7 +98,10 @@
/> />
</div> </div>
{:else} {:else}
<div class="size-full skeleton" style:background-color={bodyColor}></div> <div
class="size-full skeleton"
style:background-color={dollColorScheme.body}
></div>
{/if} {/if}
</div> </div>
</div> </div>

View File

@@ -43,10 +43,7 @@
class="flex flex-col w-full text-center py-6 gap-2 *:mx-auto hover:opacity-70 hover:cursor-pointer" class="flex flex-col w-full text-center py-6 gap-2 *:mx-auto hover:opacity-70 hover:cursor-pointer"
> >
<div class="flex justify-center"> <div class="flex justify-center">
<DollPreview <DollPreview dollColorScheme={doll.configuration.colorScheme} />
bodyColor={doll.configuration.colorScheme.body}
outlineColor={doll.configuration.colorScheme.outline}
/>
</div> </div>
<p <p
style:background-color={doll.configuration.colorScheme.body} style:background-color={doll.configuration.colorScheme.body}

View File

@@ -108,7 +108,9 @@
{:else} {:else}
<div class="h-full w-full p-4 gap-4 flex flex-col"> <div class="h-full w-full p-4 gap-4 flex flex-col">
<div class="flex justify-center mt-4"> <div class="flex justify-center mt-4">
<DollPreview {bodyColor} {outlineColor} /> <DollPreview
dollColorScheme={{ body: bodyColor, outline: outlineColor }}
/>
</div> </div>
<div class="form-control w-full"> <div class="form-control w-full">
<label class="label" for="name-input"> <label class="label" for="name-input">

View File

@@ -1,22 +1,118 @@
<script lang="ts"> <script lang="ts">
import type { UnlistenFn } from "@tauri-apps/api/event";
import { onMount } from "svelte";
import { cursorPositionOnScreen } from "../../events/cursor"; import { cursorPositionOnScreen } from "../../events/cursor";
import { friendsCursorPositions } from "../../events/friend-cursor"; import { sceneFriends } from "../../events/friend-cursor";
import { appData } from "../../events/app-data"; import { appData } from "../../events/app-data";
import { sceneInteractive } from "../../events/scene-interactive"; import { sceneInteractive } from "../../events/scene-interactive";
import { import {
friendsPresenceStates, friendsPresenceStates,
currentPresenceState, currentPresenceState,
} from "../../events/user-status"; } from "../../events/user-status";
import { commands } from "$lib/bindings"; import { commands, events, type SceneFriendNeko } from "$lib/bindings";
import { getDollColorScheme } from "$lib/scene";
import { getSpriteSheetUrl } from "$lib/utils/sprite-utils"; import { getSpriteSheetUrl } from "$lib/utils/sprite-utils";
import DebugBar from "./components/debug-bar.svelte"; import DebugBar from "./components/debug-bar.svelte";
import Neko from "./components/neko/neko.svelte"; import Neko from "./components/neko/neko.svelte";
let spriteUrl = $state(""); let userSpriteUrl = $state("");
let friendSpriteUrls = $state<Record<string, string>>({});
let latestFriendSpritesRequest = 0;
let cachedFriendSpriteUrls: Record<string, string> = {};
let cachedFriendSpriteKeys: Record<string, string> = {};
async function fetchUserSprite() {
try {
const doll = await commands.getUserActiveDoll();
const url = await getSpriteSheetUrl(getDollColorScheme(doll));
userSpriteUrl = url;
} catch {
const url = await getSpriteSheetUrl();
userSpriteUrl = url;
}
}
function getFriendSpriteKey(friend: SceneFriendNeko): string {
const colorScheme = getDollColorScheme(friend.activeDoll);
if (!colorScheme) {
return "default";
}
return `${colorScheme.body}:${colorScheme.outline}`;
}
onMount(() => {
void fetchUserSprite();
let isDisposed = false;
let unlisten: UnlistenFn | undefined;
void (async () => {
const stopListening = await events.userActiveDollUpdated.listen(() => {
void fetchUserSprite();
});
if (isDisposed) {
stopListening();
return;
}
unlisten = stopListening;
})();
return () => {
isDisposed = true;
unlisten?.();
};
});
$effect(() => { $effect(() => {
getSpriteSheetUrl().then((url) => { const friends = $sceneFriends;
spriteUrl = url; const requestId = ++latestFriendSpritesRequest;
if (friends.length === 0) {
friendSpriteUrls = {};
cachedFriendSpriteUrls = {};
cachedFriendSpriteKeys = {};
return;
}
void Promise.all(
friends.map(async (friend: SceneFriendNeko) => {
const spriteKey = getFriendSpriteKey(friend);
const existingSpriteUrl = cachedFriendSpriteUrls[friend.id];
if (existingSpriteUrl && cachedFriendSpriteKeys[friend.id] === spriteKey) {
return [friend.id, spriteKey, existingSpriteUrl] as const;
}
return [
friend.id,
spriteKey,
await getSpriteSheetUrl(getDollColorScheme(friend.activeDoll)),
] as const;
}),
).then((entries) => {
if (requestId !== latestFriendSpritesRequest) {
return;
}
cachedFriendSpriteUrls = Object.fromEntries(
entries.map(
([friendId, , spriteUrl]: readonly [string, string, string]) => [
friendId,
spriteUrl,
],
),
);
cachedFriendSpriteKeys = Object.fromEntries(
entries.map(([friendId, spriteKey]: readonly [string, string, string]) => [
friendId,
spriteKey,
]),
);
friendSpriteUrls = cachedFriendSpriteUrls;
}); });
}); });
</script> </script>
@@ -32,14 +128,23 @@
<Neko <Neko
targetX={$cursorPositionOnScreen.raw.x} targetX={$cursorPositionOnScreen.raw.x}
targetY={$cursorPositionOnScreen.raw.y} targetY={$cursorPositionOnScreen.raw.y}
{spriteUrl} spriteUrl={userSpriteUrl}
/> />
{#each $sceneFriends as friend (friend.id)}
<Neko
targetX={friend.position.raw.x}
targetY={friend.position.raw.y}
spriteUrl={friendSpriteUrls[friend.id] ?? ""}
/>
{/each}
<div id="debug-bar"> <div id="debug-bar">
<DebugBar <DebugBar
isInteractive={$sceneInteractive} isInteractive={$sceneInteractive}
cursorPosition={$cursorPositionOnScreen} cursorPosition={$cursorPositionOnScreen}
presenceStatus={$currentPresenceState?.presenceStatus ?? null} presenceStatus={$currentPresenceState?.presenceStatus ?? null}
friendsCursorPositions={$friendsCursorPositions} friendsCursorPositions={Object.fromEntries(
$sceneFriends.map((friend: SceneFriendNeko) => [friend.id, friend.position]),
)}
friends={$appData?.friends ?? []} friends={$appData?.friends ?? []}
friendsPresenceStates={$friendsPresenceStates} friendsPresenceStates={$friendsPresenceStates}
/> />

View File

@@ -1,5 +1,9 @@
<script lang="ts"> <script lang="ts">
import type { PresenceStatus, UserStatusPayload } from "$lib/bindings"; import type {
CursorPositions,
PresenceStatus,
UserStatusPayload,
} from "$lib/bindings";
interface Friend { interface Friend {
friend?: { friend?: {
@@ -10,9 +14,9 @@
interface Props { interface Props {
isInteractive: boolean; isInteractive: boolean;
cursorPosition: { mapped: { x: number; y: number } }; cursorPosition: CursorPositions;
presenceStatus: PresenceStatus | null; presenceStatus: PresenceStatus | null;
friendsCursorPositions: Record<string, { mapped: { x: number; y: number } }>; friendsCursorPositions: Record<string, CursorPositions>;
friends: Friend[]; friends: Friend[];
friendsPresenceStates: Record<string, UserStatusPayload>; friendsPresenceStates: Record<string, UserStatusPayload>;
} }

View File

@@ -98,7 +98,9 @@
<input <input
class="input input-bordered input-sm" class="input input-bordered input-sm"
type="password" type="password"
autocomplete={useRegister ? "new-password" : "current-password"} autocomplete={useRegister
? "new-password"
: "current-password"}
bind:value={form.password} bind:value={form.password}
placeholder="••••••••" placeholder="••••••••"
/> />
@@ -146,7 +148,9 @@
} }
}} }}
> >
{useRegister ? "Already have an account? Sign in" : "New here? Create an account"} {useRegister
? "Already have an account? Sign in"
: "New here? Create an account"}
</button> </button>
<button <button
class="btn btn-link p-0 btn-sm text-base-content w-max" class="btn btn-link p-0 btn-sm text-base-content w-max"
@@ -175,7 +179,7 @@
> >
<div></div> <div></div>
<div class="flex flex-col scale-200 origin-bottom-right"> <div class="flex flex-col scale-200 origin-bottom-right">
<DollPreview bodyColor="b7f2ff" outlineColor="496065" /> <DollPreview dollColorScheme={{ body: "b7f2ff", outline: "496065" }} />
</div> </div>
</div> </div>
</div> </div>