minor cursor handling refactor improvemnts
This commit is contained in:
@@ -5,6 +5,7 @@ use crate::{
|
||||
app_data::{init_app_data_scoped, AppDataRefreshScope},
|
||||
app_state,
|
||||
friends,
|
||||
neko_positions,
|
||||
presence_modules::models::ModuleMetadata,
|
||||
sprite,
|
||||
},
|
||||
@@ -53,6 +54,12 @@ pub fn get_app_state() -> Result<AppState, String> {
|
||||
Ok(app_state::get_snapshot())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn get_neko_positions() -> Result<neko_positions::NekoPositionsDto, String> {
|
||||
Ok(neko_positions::get_snapshot())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn set_scene_setup_nekos_position(nekos_position: Option<NekoPosition>) {
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::services::{
|
||||
};
|
||||
use commands::app::{quit_app, restart_app, retry_connection};
|
||||
use commands::app_state::{
|
||||
get_active_doll_sprite_base64, get_app_data, get_app_state,
|
||||
get_active_doll_sprite_base64, get_app_data, get_app_state, get_neko_positions,
|
||||
get_friend_active_doll_sprites_base64, get_modules, refresh_app_data,
|
||||
set_scene_setup_nekos_opacity, set_scene_setup_nekos_position, set_scene_setup_nekos_scale,
|
||||
};
|
||||
@@ -26,11 +26,10 @@ use tauri_specta::{collect_commands, collect_events, Builder as SpectaBuilder, E
|
||||
|
||||
use crate::services::app_events::{
|
||||
ActiveDollSpriteChanged, AppDataRefreshed, AppStateChanged, AuthFlowUpdated, CreateDoll,
|
||||
CursorMoved, EditDoll, FriendActiveDollChanged, FriendActiveDollSpritesUpdated,
|
||||
FriendCursorPositionsUpdated, FriendDisconnected, FriendRequestAccepted,
|
||||
FriendRequestDenied, FriendRequestReceived, FriendUserStatusChanged,
|
||||
InteractionDeliveryFailed, InteractionReceived, SceneInteractiveChanged,
|
||||
SetInteractionOverlay, Unfriended, UserStatusChanged,
|
||||
EditDoll, FriendActiveDollChanged, FriendActiveDollSpritesUpdated, FriendDisconnected,
|
||||
FriendRequestAccepted, FriendRequestDenied, FriendRequestReceived, FriendUserStatusChanged,
|
||||
InteractionDeliveryFailed, InteractionReceived, NekoPositionsUpdated,
|
||||
SceneInteractiveChanged, SetInteractionOverlay, Unfriended, UserStatusChanged,
|
||||
};
|
||||
|
||||
static APP_HANDLE: std::sync::OnceLock<tauri::AppHandle<tauri::Wry>> = std::sync::OnceLock::new();
|
||||
@@ -68,6 +67,7 @@ pub fn run() {
|
||||
.commands(collect_commands![
|
||||
get_app_data,
|
||||
get_app_state,
|
||||
get_neko_positions,
|
||||
get_active_doll_sprite_base64,
|
||||
get_friend_active_doll_sprites_base64,
|
||||
refresh_app_data,
|
||||
@@ -108,16 +108,15 @@ pub fn run() {
|
||||
set_scene_setup_nekos_scale
|
||||
])
|
||||
.events(collect_events![
|
||||
CursorMoved,
|
||||
SceneInteractiveChanged,
|
||||
AppDataRefreshed,
|
||||
AppStateChanged,
|
||||
NekoPositionsUpdated,
|
||||
ActiveDollSpriteChanged,
|
||||
SetInteractionOverlay,
|
||||
EditDoll,
|
||||
CreateDoll,
|
||||
UserStatusChanged,
|
||||
FriendCursorPositionsUpdated,
|
||||
FriendDisconnected,
|
||||
FriendActiveDollChanged,
|
||||
FriendActiveDollSpritesUpdated,
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::{
|
||||
remotes::{dolls::DollsRemote, friends::FriendRemote, user::UserRemote},
|
||||
services::{
|
||||
app_events::{ActiveDollSpriteChanged, AppDataRefreshed},
|
||||
friends, sprite,
|
||||
friends, neko_positions, sprite,
|
||||
},
|
||||
state::FDOLL,
|
||||
};
|
||||
@@ -51,6 +51,8 @@ pub async fn init_app_data_scoped(scope: AppDataRefreshScope) {
|
||||
Ok(user) => {
|
||||
let mut guard = lock_w!(FDOLL);
|
||||
guard.user_data.user = Some(user);
|
||||
drop(guard);
|
||||
neko_positions::sync_from_app_data();
|
||||
}
|
||||
Err(error) => {
|
||||
warn!("Failed to fetch user profile: {}", error);
|
||||
|
||||
@@ -13,10 +13,7 @@ use crate::{
|
||||
},
|
||||
interaction::{InteractionDeliveryFailedDto, InteractionPayloadDto},
|
||||
},
|
||||
services::{
|
||||
cursor::CursorPositions,
|
||||
friends::{FriendActiveDollSpritesDto, FriendCursorPositionsDto},
|
||||
},
|
||||
services::{friends::FriendActiveDollSpritesDto, neko_positions::NekoPositionsDto},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
@@ -36,10 +33,6 @@ pub struct AuthFlowUpdatedPayload {
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
|
||||
#[tauri_specta(event_name = "cursor-position")]
|
||||
pub struct CursorMoved(pub CursorPositions);
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
|
||||
#[tauri_specta(event_name = "scene-interactive")]
|
||||
pub struct SceneInteractiveChanged(pub bool);
|
||||
@@ -73,8 +66,8 @@ pub struct CreateDoll;
|
||||
pub struct UserStatusChanged(pub UserStatusPayload);
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
|
||||
#[tauri_specta(event_name = "friend-cursor-positions")]
|
||||
pub struct FriendCursorPositionsUpdated(pub FriendCursorPositionsDto);
|
||||
#[tauri_specta(event_name = "neko-positions")]
|
||||
pub struct NekoPositionsUpdated(pub NekoPositionsDto);
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
|
||||
#[tauri_specta(event_name = "friend-disconnected")]
|
||||
|
||||
@@ -6,7 +6,7 @@ use tracing::warn;
|
||||
use crate::{
|
||||
get_app_handle,
|
||||
models::app_state::{AppState, NekoPosition},
|
||||
services::app_events::AppStateChanged,
|
||||
services::{app_events::AppStateChanged, neko_positions},
|
||||
};
|
||||
|
||||
static APP_STATE: LazyLock<Arc<RwLock<AppState>>> =
|
||||
@@ -21,6 +21,8 @@ pub fn set_scene_setup_nekos_position(nekos_position: Option<NekoPosition>) {
|
||||
let mut guard = APP_STATE.write().expect("app state lock poisoned");
|
||||
guard.scene_setup.nekos_position = nekos_position;
|
||||
emit_snapshot(&guard);
|
||||
drop(guard);
|
||||
neko_positions::refresh_from_scene_setup();
|
||||
}
|
||||
|
||||
pub fn set_scene_setup_nekos_opacity(nekos_opacity: f32) {
|
||||
|
||||
@@ -7,8 +7,11 @@ use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::{get_app_handle, lock_r, services::app_events::CursorMoved, state::FDOLL};
|
||||
use tauri_specta::Event as _;
|
||||
use crate::{
|
||||
lock_r,
|
||||
services::{neko_positions, ws::report_cursor_data},
|
||||
state::FDOLL,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -59,8 +62,7 @@ pub fn normalized_to_absolute(normalized: &CursorPosition) -> CursorPosition {
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize cursor tracking. Broadcasts cursor
|
||||
/// position changes via `cursor-position` event.
|
||||
/// Initialize cursor tracking.
|
||||
pub async fn init_cursor_tracking() {
|
||||
info!("start_cursor_tracking called");
|
||||
|
||||
@@ -88,22 +90,19 @@ async fn init_cursor_tracking_i() -> Result<(), String> {
|
||||
let (tx, mut rx) = mpsc::channel::<CursorPositions>(100);
|
||||
|
||||
// Spawn the consumer task
|
||||
// This task handles WebSocket reporting and local broadcasting.
|
||||
// This task handles WebSocket reporting and local position projection updates.
|
||||
// It runs independently of the device event loop.
|
||||
tauri::async_runtime::spawn(async move {
|
||||
info!("Cursor event consumer started");
|
||||
let app_handle = get_app_handle();
|
||||
|
||||
while let Some(positions) = rx.recv().await {
|
||||
let mapped_for_ws = positions.mapped.clone();
|
||||
|
||||
// 1. WebSocket reporting
|
||||
crate::services::ws::report_cursor_data(mapped_for_ws).await;
|
||||
report_cursor_data(mapped_for_ws).await;
|
||||
|
||||
// 2. Broadcast to local windows
|
||||
if let Err(e) = CursorMoved(positions).emit(app_handle) {
|
||||
error!("Failed to emit cursor position event: {:?}", e);
|
||||
}
|
||||
// 2. Update unified neko positions projection
|
||||
neko_positions::update_self_cursor(positions);
|
||||
}
|
||||
warn!("Cursor event consumer stopped (channel closed)");
|
||||
});
|
||||
|
||||
@@ -1,145 +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 && 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);
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,27 @@
|
||||
mod active_doll_sprites;
|
||||
mod cursor_positions;
|
||||
|
||||
use crate::{models::dolls::DollDto, services::cursor::CursorPositions};
|
||||
use crate::{models::dolls::DollDto, services::neko_positions};
|
||||
|
||||
pub use active_doll_sprites::FriendActiveDollSpritesDto;
|
||||
pub use cursor_positions::FriendCursorPositionsDto;
|
||||
|
||||
pub fn sync_from_app_data() {
|
||||
active_doll_sprites::sync_from_app_data();
|
||||
cursor_positions::sync_from_app_data();
|
||||
neko_positions::sync_from_app_data();
|
||||
}
|
||||
|
||||
pub fn clear() {
|
||||
active_doll_sprites::clear();
|
||||
cursor_positions::clear();
|
||||
neko_positions::clear();
|
||||
}
|
||||
|
||||
pub fn remove_friend(user_id: &str) {
|
||||
active_doll_sprites::remove_friend(user_id);
|
||||
cursor_positions::remove_friend(user_id);
|
||||
neko_positions::remove_friend(user_id);
|
||||
}
|
||||
|
||||
pub fn set_active_doll(user_id: &str, doll: Option<&DollDto>) {
|
||||
active_doll_sprites::set_active_doll(user_id, doll);
|
||||
cursor_positions::set_active_doll(user_id, doll.is_some());
|
||||
}
|
||||
|
||||
pub fn update_cursor_position(user_id: String, position: CursorPositions) {
|
||||
cursor_positions::update_position(user_id, position);
|
||||
neko_positions::set_friend_active_doll(user_id, doll.is_some());
|
||||
}
|
||||
|
||||
pub fn sync_active_doll_sprites_from_app_data() {
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod friends;
|
||||
pub mod health_manager;
|
||||
pub mod health_monitor;
|
||||
pub mod interaction;
|
||||
pub mod neko_positions;
|
||||
pub mod petpet;
|
||||
pub mod presence_modules;
|
||||
pub mod scene;
|
||||
|
||||
307
src-tauri/src/services/neko_positions.rs
Normal file
307
src-tauri/src/services/neko_positions.rs
Normal file
@@ -0,0 +1,307 @@
|
||||
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,
|
||||
models::app_state::NekoPosition,
|
||||
services::{
|
||||
app_events::NekoPositionsUpdated,
|
||||
app_state,
|
||||
cursor::{CursorPosition, CursorPositions},
|
||||
},
|
||||
state::FDOLL,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NekoPositionDto {
|
||||
pub user_id: String,
|
||||
pub is_self: bool,
|
||||
pub cursor: CursorPositions,
|
||||
pub target: CursorPosition,
|
||||
pub override_applied: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(transparent)]
|
||||
pub struct NekoPositionsDto(pub HashMap<String, NekoPositionDto>);
|
||||
|
||||
#[derive(Default)]
|
||||
struct NekoPositionsProjection {
|
||||
self_cursor: Option<CursorPositions>,
|
||||
friend_cursors: HashMap<String, CursorPositions>,
|
||||
friend_active_dolls: HashMap<String, bool>,
|
||||
}
|
||||
|
||||
static NEKO_POSITIONS: LazyLock<Arc<RwLock<NekoPositionsProjection>>> =
|
||||
LazyLock::new(|| Arc::new(RwLock::new(NekoPositionsProjection::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 = NEKO_POSITIONS
|
||||
.write()
|
||||
.expect("neko positions projection lock poisoned");
|
||||
|
||||
projection.friend_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.friend_active_dolls.clone();
|
||||
projection
|
||||
.friend_cursors
|
||||
.retain(|user_id, _| active_dolls.get(user_id) == Some(&true));
|
||||
|
||||
emit_snapshot(&projection);
|
||||
}
|
||||
|
||||
pub fn clear() {
|
||||
let mut projection = NEKO_POSITIONS
|
||||
.write()
|
||||
.expect("neko positions projection lock poisoned");
|
||||
|
||||
projection.self_cursor = None;
|
||||
projection.friend_cursors.clear();
|
||||
projection.friend_active_dolls.clear();
|
||||
|
||||
emit_snapshot(&projection);
|
||||
}
|
||||
|
||||
pub fn update_self_cursor(position: CursorPositions) {
|
||||
let mut projection = NEKO_POSITIONS
|
||||
.write()
|
||||
.expect("neko positions projection lock poisoned");
|
||||
|
||||
projection.self_cursor = Some(position);
|
||||
emit_snapshot(&projection);
|
||||
}
|
||||
|
||||
pub fn update_friend_cursor(user_id: String, position: CursorPositions) {
|
||||
let mut projection = NEKO_POSITIONS
|
||||
.write()
|
||||
.expect("neko positions projection lock poisoned");
|
||||
|
||||
if !has_friend_active_doll(&mut projection, &user_id) {
|
||||
if projection.friend_cursors.remove(&user_id).is_some() {
|
||||
emit_snapshot(&projection);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
projection.friend_cursors.insert(user_id, position);
|
||||
emit_snapshot(&projection);
|
||||
}
|
||||
|
||||
pub fn remove_friend(user_id: &str) {
|
||||
let mut projection = NEKO_POSITIONS
|
||||
.write()
|
||||
.expect("neko positions projection lock poisoned");
|
||||
|
||||
let removed_active_doll = projection.friend_active_dolls.remove(user_id).is_some();
|
||||
let removed_position = projection.friend_cursors.remove(user_id).is_some();
|
||||
|
||||
if removed_active_doll || removed_position {
|
||||
emit_snapshot(&projection);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_friend_active_doll(user_id: &str, has_active_doll: bool) {
|
||||
let mut projection = NEKO_POSITIONS
|
||||
.write()
|
||||
.expect("neko positions projection lock poisoned");
|
||||
|
||||
projection
|
||||
.friend_active_dolls
|
||||
.insert(user_id.to_string(), has_active_doll);
|
||||
|
||||
if !has_active_doll && projection.friend_cursors.remove(user_id).is_some() {
|
||||
emit_snapshot(&projection);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn refresh_from_scene_setup() {
|
||||
let projection = NEKO_POSITIONS
|
||||
.read()
|
||||
.expect("neko positions projection lock poisoned");
|
||||
emit_snapshot(&projection);
|
||||
}
|
||||
|
||||
pub fn get_snapshot() -> NekoPositionsDto {
|
||||
let projection = NEKO_POSITIONS
|
||||
.read()
|
||||
.expect("neko positions projection lock poisoned");
|
||||
build_snapshot(&projection)
|
||||
}
|
||||
|
||||
fn has_friend_active_doll(projection: &mut NekoPositionsProjection, user_id: &str) -> bool {
|
||||
if let Some(has_active_doll) = projection.friend_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
|
||||
.friend_active_dolls
|
||||
.insert(user_id.to_string(), has_active_doll);
|
||||
|
||||
has_active_doll
|
||||
}
|
||||
|
||||
fn has_self_active_doll() -> bool {
|
||||
let guard = lock_r!(FDOLL);
|
||||
guard
|
||||
.user_data
|
||||
.user
|
||||
.as_ref()
|
||||
.and_then(|user| user.active_doll_id.as_ref())
|
||||
.is_some()
|
||||
}
|
||||
|
||||
fn get_self_user_id() -> Option<String> {
|
||||
let guard = lock_r!(FDOLL);
|
||||
guard.user_data.user.as_ref().map(|user| user.id.clone())
|
||||
}
|
||||
|
||||
fn get_display_size() -> (f64, f64) {
|
||||
let guard = lock_r!(FDOLL);
|
||||
(
|
||||
guard.user_data.scene.display.screen_width as f64,
|
||||
guard.user_data.scene.display.screen_height as f64,
|
||||
)
|
||||
}
|
||||
|
||||
fn emit_snapshot(projection: &NekoPositionsProjection) {
|
||||
let payload = build_snapshot(projection);
|
||||
|
||||
if let Err(err) = NekoPositionsUpdated(payload).emit(get_app_handle()) {
|
||||
tracing::warn!("Failed to emit neko positions update: {}", err);
|
||||
}
|
||||
}
|
||||
|
||||
fn build_snapshot(projection: &NekoPositionsProjection) -> NekoPositionsDto {
|
||||
let mut entries: Vec<(String, bool, CursorPositions)> = Vec::new();
|
||||
|
||||
if has_self_active_doll() {
|
||||
if let (Some(self_user_id), Some(self_cursor)) =
|
||||
(get_self_user_id(), projection.self_cursor.clone())
|
||||
{
|
||||
entries.push((self_user_id, true, self_cursor));
|
||||
}
|
||||
}
|
||||
|
||||
for (user_id, cursor) in &projection.friend_cursors {
|
||||
if projection.friend_active_dolls.get(user_id) == Some(&true) {
|
||||
entries.push((user_id.clone(), false, cursor.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
entries.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
let app_state = app_state::get_snapshot();
|
||||
let override_anchor = app_state.scene_setup.nekos_position;
|
||||
let (screen_width, screen_height) = get_display_size();
|
||||
|
||||
let total = entries.len();
|
||||
|
||||
NekoPositionsDto(
|
||||
entries
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, (user_id, is_self, cursor))| {
|
||||
let (target, override_applied) = match &override_anchor {
|
||||
Some(anchor) => (
|
||||
get_cluster_target(
|
||||
anchor.clone(),
|
||||
index,
|
||||
total,
|
||||
screen_width,
|
||||
screen_height,
|
||||
),
|
||||
true,
|
||||
),
|
||||
None => (cursor.raw.clone(), false),
|
||||
};
|
||||
|
||||
(
|
||||
user_id.clone(),
|
||||
NekoPositionDto {
|
||||
user_id,
|
||||
is_self,
|
||||
cursor,
|
||||
target,
|
||||
override_applied,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn get_cluster_target(
|
||||
anchor: NekoPosition,
|
||||
index: usize,
|
||||
count: usize,
|
||||
screen_width: f64,
|
||||
screen_height: f64,
|
||||
) -> CursorPosition {
|
||||
let spacing = 36.0;
|
||||
let margin = 28.0;
|
||||
|
||||
let columns = (count as f64).sqrt().ceil().max(1.0) as usize;
|
||||
let rows = count.div_ceil(columns).max(1);
|
||||
let col = index % columns;
|
||||
let row = index / columns;
|
||||
|
||||
let block_width = (columns.saturating_sub(1)) as f64 * spacing;
|
||||
let block_height = (rows.saturating_sub(1)) as f64 * spacing;
|
||||
|
||||
let start_x = match anchor {
|
||||
NekoPosition::TopLeft | NekoPosition::Left | NekoPosition::BottomLeft => margin,
|
||||
NekoPosition::Top | NekoPosition::Bottom => (screen_width - block_width) / 2.0,
|
||||
NekoPosition::TopRight | NekoPosition::Right | NekoPosition::BottomRight => {
|
||||
screen_width - margin - block_width
|
||||
}
|
||||
};
|
||||
|
||||
let start_y = match anchor {
|
||||
NekoPosition::TopLeft | NekoPosition::Top | NekoPosition::TopRight => margin,
|
||||
NekoPosition::Left | NekoPosition::Right => (screen_height - block_height) / 2.0,
|
||||
NekoPosition::BottomLeft | NekoPosition::Bottom | NekoPosition::BottomRight => {
|
||||
screen_height - margin - block_height
|
||||
}
|
||||
};
|
||||
|
||||
CursorPosition {
|
||||
x: (start_x + col as f64 * spacing).clamp(0.0, screen_width),
|
||||
y: (start_y + row as f64 * spacing).clamp(0.0, screen_height),
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ use crate::services::app_events::{
|
||||
};
|
||||
use crate::services::{
|
||||
cursor::{normalized_to_absolute, CursorPositions},
|
||||
friends,
|
||||
friends, neko_positions,
|
||||
};
|
||||
|
||||
use super::{emitter, refresh, types::IncomingFriendCursorPayload, utils};
|
||||
@@ -62,13 +62,12 @@ pub fn on_friend_cursor_position(payload: Payload, _socket: RawClient) {
|
||||
let mapped_pos = &friend_data.position;
|
||||
let raw_pos = normalized_to_absolute(mapped_pos);
|
||||
|
||||
friends::update_cursor_position(
|
||||
friend_data.user_id,
|
||||
CursorPositions {
|
||||
let position = CursorPositions {
|
||||
raw: raw_pos,
|
||||
mapped: mapped_pos.clone(),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
neko_positions::update_friend_cursor(friend_data.user_id, position);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { writable } from "svelte/store";
|
||||
import { events, type CursorPositions } from "$lib/bindings";
|
||||
import { createEventSource } from "./listener-utils";
|
||||
|
||||
export const cursorPositionOnScreen = writable<CursorPositions>({
|
||||
raw: { x: 0, y: 0 },
|
||||
mapped: { x: 0, y: 0 },
|
||||
});
|
||||
|
||||
export const { start: startCursorTracking, stop: stopCursorTracking } =
|
||||
createEventSource(async (addEventListener) => {
|
||||
addEventListener(
|
||||
await events.cursorMoved.listen((event) => {
|
||||
cursorPositionOnScreen.set(event.payload);
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import { writable } from "svelte/store";
|
||||
import { events, type CursorPositions } from "$lib/bindings";
|
||||
import { createEventSource } from "./listener-utils";
|
||||
|
||||
export const friendsCursorPositions = writable<Record<string, CursorPositions>>(
|
||||
{},
|
||||
);
|
||||
|
||||
// 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) => {
|
||||
addEventListener(
|
||||
await events.friendCursorPositionsUpdated.listen((event) => {
|
||||
friendsCursorPositions.set(toCursorPositionsRecord(event.payload));
|
||||
}),
|
||||
);
|
||||
});
|
||||
16
src/events/neko-positions.ts
Normal file
16
src/events/neko-positions.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { writable } from "svelte/store";
|
||||
import { commands, events, type NekoPositionsDto } from "$lib/bindings";
|
||||
import { createEventSource } from "./listener-utils";
|
||||
|
||||
export const nekoPositions = writable<NekoPositionsDto>({});
|
||||
|
||||
export const { start: startNekoPositions, stop: stopNekoPositions } =
|
||||
createEventSource(async (addEventListener) => {
|
||||
nekoPositions.set(await commands.getNekoPositions());
|
||||
|
||||
addEventListener(
|
||||
await events.nekoPositionsUpdated.listen((event) => {
|
||||
nekoPositions.set(event.payload);
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -11,6 +11,9 @@ async getAppData() : Promise<UserData> {
|
||||
async getAppState() : Promise<AppState> {
|
||||
return await TAURI_INVOKE("get_app_state");
|
||||
},
|
||||
async getNekoPositions() : Promise<NekoPositionsDto> {
|
||||
return await TAURI_INVOKE("get_neko_positions");
|
||||
},
|
||||
async getActiveDollSpriteBase64() : Promise<string | null> {
|
||||
return await TAURI_INVOKE("get_active_doll_sprite_base64");
|
||||
},
|
||||
@@ -142,11 +145,9 @@ appDataRefreshed: AppDataRefreshed,
|
||||
appStateChanged: AppStateChanged,
|
||||
authFlowUpdated: AuthFlowUpdated,
|
||||
createDoll: CreateDoll,
|
||||
cursorMoved: CursorMoved,
|
||||
editDoll: EditDoll,
|
||||
friendActiveDollChanged: FriendActiveDollChanged,
|
||||
friendActiveDollSpritesUpdated: FriendActiveDollSpritesUpdated,
|
||||
friendCursorPositionsUpdated: FriendCursorPositionsUpdated,
|
||||
friendDisconnected: FriendDisconnected,
|
||||
friendRequestAccepted: FriendRequestAccepted,
|
||||
friendRequestDenied: FriendRequestDenied,
|
||||
@@ -154,6 +155,7 @@ friendRequestReceived: FriendRequestReceived,
|
||||
friendUserStatusChanged: FriendUserStatusChanged,
|
||||
interactionDeliveryFailed: InteractionDeliveryFailed,
|
||||
interactionReceived: InteractionReceived,
|
||||
nekoPositionsUpdated: NekoPositionsUpdated,
|
||||
sceneInteractiveChanged: SceneInteractiveChanged,
|
||||
setInteractionOverlay: SetInteractionOverlay,
|
||||
unfriended: Unfriended,
|
||||
@@ -164,11 +166,9 @@ appDataRefreshed: "app-data-refreshed",
|
||||
appStateChanged: "app-state-changed",
|
||||
authFlowUpdated: "auth-flow-updated",
|
||||
createDoll: "create-doll",
|
||||
cursorMoved: "cursor-moved",
|
||||
editDoll: "edit-doll",
|
||||
friendActiveDollChanged: "friend-active-doll-changed",
|
||||
friendActiveDollSpritesUpdated: "friend-active-doll-sprites-updated",
|
||||
friendCursorPositionsUpdated: "friend-cursor-positions-updated",
|
||||
friendDisconnected: "friend-disconnected",
|
||||
friendRequestAccepted: "friend-request-accepted",
|
||||
friendRequestDenied: "friend-request-denied",
|
||||
@@ -176,6 +176,7 @@ friendRequestReceived: "friend-request-received",
|
||||
friendUserStatusChanged: "friend-user-status-changed",
|
||||
interactionDeliveryFailed: "interaction-delivery-failed",
|
||||
interactionReceived: "interaction-received",
|
||||
nekoPositionsUpdated: "neko-positions-updated",
|
||||
sceneInteractiveChanged: "scene-interactive-changed",
|
||||
setInteractionOverlay: "set-interaction-overlay",
|
||||
unfriended: "unfriended",
|
||||
@@ -201,7 +202,6 @@ export type AuthFlowUpdated = AuthFlowUpdatedPayload
|
||||
export type AuthFlowUpdatedPayload = { provider: string; status: AuthFlowStatus; message: string | null }
|
||||
export type CreateDoll = null
|
||||
export type CreateDollDto = { name: string; configuration: DollConfigurationDto | null }
|
||||
export type CursorMoved = CursorPositions
|
||||
export type CursorPosition = { x: number; y: number }
|
||||
export type CursorPositions = { raw: CursorPosition; mapped: CursorPosition }
|
||||
export type DisplayData = { screen_width: number; screen_height: number; monitor_scale_factor: number }
|
||||
@@ -213,8 +213,6 @@ export type FriendActiveDollChanged = FriendActiveDollChangedPayload
|
||||
export type FriendActiveDollChangedPayload = { friendId: string; doll: DollDto | null }
|
||||
export type FriendActiveDollSpritesDto = Partial<{ [key in string]: string }>
|
||||
export type FriendActiveDollSpritesUpdated = FriendActiveDollSpritesDto
|
||||
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
|
||||
@@ -234,6 +232,9 @@ export type InteractionReceived = InteractionPayloadDto
|
||||
export type KeyboardAccelerator = { modifiers?: AcceleratorModifier[]; key?: AcceleratorKey | null }
|
||||
export type ModuleMetadata = { id: string; name: string; version: string; description: string | null }
|
||||
export type NekoPosition = "top-left" | "top" | "top-right" | "left" | "right" | "bottom-left" | "bottom" | "bottom-right"
|
||||
export type NekoPositionDto = { userId: string; isSelf: boolean; cursor: CursorPositions; target: CursorPosition; overrideApplied: boolean }
|
||||
export type NekoPositionsDto = Partial<{ [key in string]: NekoPositionDto }>
|
||||
export type NekoPositionsUpdated = NekoPositionsDto
|
||||
export type PresenceStatus = { title: string | null; subtitle: string | null; graphicsB64: string | null }
|
||||
export type SceneData = { display: DisplayData; grid_size: number }
|
||||
export type SceneInteractiveChanged = boolean
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<script>
|
||||
import { browser } from "$app/environment";
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { startCursorTracking, stopCursorTracking } from "../events/cursor";
|
||||
import {
|
||||
startFriendCursorTracking,
|
||||
stopFriendCursorTracking,
|
||||
} from "../events/friend-cursor";
|
||||
startNekoPositions,
|
||||
stopNekoPositions,
|
||||
} from "../events/neko-positions";
|
||||
import {
|
||||
startActiveDollSprite,
|
||||
stopActiveDollSprite,
|
||||
@@ -31,8 +30,7 @@
|
||||
await startAppState();
|
||||
await startActiveDollSprite();
|
||||
await startFriendActiveDollSprite();
|
||||
await startCursorTracking();
|
||||
await startFriendCursorTracking();
|
||||
await startNekoPositions();
|
||||
await startSceneInteractive();
|
||||
await startInteraction();
|
||||
await startUserStatus();
|
||||
@@ -42,8 +40,7 @@
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
stopCursorTracking();
|
||||
stopFriendCursorTracking();
|
||||
stopNekoPositions();
|
||||
stopActiveDollSprite();
|
||||
stopFriendActiveDollSprite();
|
||||
stopSceneInteractive();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { cursorPositionOnScreen } from "../../events/cursor";
|
||||
import { friendsCursorPositions } from "../../events/friend-cursor";
|
||||
import { nekoPositions } from "../../events/neko-positions";
|
||||
import { appData } from "../../events/app-data";
|
||||
import { activeDollSpriteUrl } from "../../events/active-doll-sprite";
|
||||
import { friendActiveDollSpriteUrls } from "../../events/friend-active-doll-sprite";
|
||||
@@ -10,7 +9,7 @@
|
||||
friendsPresenceStates,
|
||||
currentPresenceState,
|
||||
} from "../../events/user-status";
|
||||
import { commands } from "$lib/bindings";
|
||||
import { commands, type NekoPositionDto } from "$lib/bindings";
|
||||
import DebugBar from "./components/debug-bar.svelte";
|
||||
import Neko from "./components/neko/neko.svelte";
|
||||
import PetMenu from "./components/pet-menu/pet-menu.svelte";
|
||||
@@ -19,7 +18,7 @@
|
||||
import type { UserBasicDto } from "$lib/bindings";
|
||||
import { appState } from "../../events/app-state";
|
||||
|
||||
let debugMode = false;
|
||||
let debugMode = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
const config = await commands.getClientConfig();
|
||||
@@ -32,6 +31,12 @@
|
||||
?.friend ?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
let nekoEntries = $derived.by(() => {
|
||||
return Object.entries($nekoPositions).filter(
|
||||
(entry): entry is [string, NekoPositionDto] => entry[1] !== undefined,
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="w-svw h-svh p-4 relative overflow-hidden">
|
||||
@@ -42,30 +47,26 @@
|
||||
await commands.setSceneInteractive(false, true);
|
||||
}}> </button
|
||||
>
|
||||
{#if $appData?.user?.activeDollId}
|
||||
{#each nekoEntries as [userId, position] (userId)}
|
||||
{@const spriteUrl = position.isSelf
|
||||
? $activeDollSpriteUrl
|
||||
: $friendActiveDollSpriteUrls[userId]}
|
||||
{#if spriteUrl}
|
||||
{@const friend = position.isSelf ? undefined : getFriend(userId)}
|
||||
<Neko
|
||||
targetX={$cursorPositionOnScreen.raw.x}
|
||||
targetY={$cursorPositionOnScreen.raw.y}
|
||||
spriteUrl={$activeDollSpriteUrl}
|
||||
scale={$appState.sceneSetup.nekosScale}
|
||||
opacity={$appState.sceneSetup.nekosOpacity}
|
||||
/>
|
||||
{/if}
|
||||
{#each Object.entries($friendsCursorPositions) as [friendId, position] (friendId)}
|
||||
{#if $friendActiveDollSpriteUrls[friendId]}
|
||||
{@const friend = getFriend(friendId)}
|
||||
<Neko
|
||||
targetX={position.raw.x}
|
||||
targetY={position.raw.y}
|
||||
spriteUrl={$friendActiveDollSpriteUrls[friendId]}
|
||||
initialX={position.raw.x}
|
||||
initialY={position.raw.y}
|
||||
targetX={position.target.x}
|
||||
targetY={position.target.y}
|
||||
spriteUrl={spriteUrl}
|
||||
initialX={position.target.x}
|
||||
initialY={position.target.y}
|
||||
scale={$appState.sceneSetup.nekosScale}
|
||||
opacity={$appState.sceneSetup.nekosOpacity}
|
||||
>
|
||||
<PetMenu user={friend!} ariaLabel={`Open ${friend?.name} actions`} />
|
||||
<PetMessagePop userId={friendId} />
|
||||
<PetMessageSend userId={friendId} userName={friend?.name ?? "Friend"} />
|
||||
{#if !position.isSelf && friend}
|
||||
<PetMenu user={friend} ariaLabel={`Open ${friend.name} actions`} />
|
||||
<PetMessagePop userId={userId} />
|
||||
<PetMessageSend userId={userId} userName={friend.name} />
|
||||
{/if}
|
||||
</Neko>
|
||||
{/if}
|
||||
{/each}
|
||||
@@ -73,9 +74,8 @@
|
||||
<div id="debug-bar">
|
||||
<DebugBar
|
||||
isInteractive={$sceneInteractive}
|
||||
cursorPosition={$cursorPositionOnScreen}
|
||||
nekoPositions={$nekoPositions}
|
||||
presenceStatus={$currentPresenceState?.presenceStatus ?? null}
|
||||
friendsCursorPositions={$friendsCursorPositions}
|
||||
friends={$appData?.friends ?? []}
|
||||
friendsPresenceStates={$friendsPresenceStates}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type { PresenceStatus, UserStatusPayload } from "$lib/bindings";
|
||||
import type {
|
||||
NekoPositionDto,
|
||||
NekoPositionsDto,
|
||||
PresenceStatus,
|
||||
UserStatusPayload,
|
||||
} from "$lib/bindings";
|
||||
|
||||
interface Friend {
|
||||
friend?: {
|
||||
@@ -10,22 +15,32 @@
|
||||
|
||||
interface Props {
|
||||
isInteractive: boolean;
|
||||
cursorPosition: { mapped: { x: number; y: number } };
|
||||
nekoPositions: NekoPositionsDto;
|
||||
presenceStatus: PresenceStatus | null;
|
||||
friendsCursorPositions: Record<string, { mapped: { x: number; y: number } }>;
|
||||
friends: Friend[];
|
||||
friendsPresenceStates: Record<string, UserStatusPayload>;
|
||||
}
|
||||
|
||||
let {
|
||||
isInteractive,
|
||||
cursorPosition,
|
||||
nekoPositions,
|
||||
presenceStatus,
|
||||
friendsCursorPositions,
|
||||
friends,
|
||||
friendsPresenceStates,
|
||||
}: Props = $props();
|
||||
|
||||
let selfCursor = $derived(
|
||||
Object.values(nekoPositions).find((position) => position?.isSelf)?.cursor,
|
||||
);
|
||||
|
||||
let friendEntries = $derived.by(() => {
|
||||
return Object.entries(nekoPositions).filter(
|
||||
(entry): entry is [string, NekoPositionDto] => {
|
||||
return entry[1] !== undefined && !entry[1].isSelf;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
function getFriendById(userId: string) {
|
||||
const friend = friends.find((f) => f.friend?.id === userId);
|
||||
return friend?.friend;
|
||||
@@ -49,9 +64,11 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if selfCursor}
|
||||
<span class="font-mono text-xs badge py-3">
|
||||
{cursorPosition.mapped.x.toFixed(3)}, {cursorPosition.mapped.y.toFixed(3)}
|
||||
{selfCursor.mapped.x.toFixed(3)}, {selfCursor.mapped.y.toFixed(3)}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if presenceStatus}
|
||||
<span class="font-mono text-xs badge py-3 flex items-center gap-2">
|
||||
@@ -66,16 +83,17 @@
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if Object.keys(friendsCursorPositions).length > 0}
|
||||
{#if friendEntries.length > 0}
|
||||
<div class="flex flex-col gap-2">
|
||||
<div>
|
||||
{#each Object.entries(friendsCursorPositions) as [userId, position]}
|
||||
{#each friendEntries as [userId, position]}
|
||||
{@const status = getFriendStatus(userId)}
|
||||
<div class="badge py-3 text-xs text-left flex flex-row gap-2">
|
||||
<span class="font-bold">{getFriendById(userId)?.name}</span>
|
||||
<div class="flex flex-row font-mono gap-2">
|
||||
<span>
|
||||
{position.mapped.x.toFixed(3)}, {position.mapped.y.toFixed(3)}
|
||||
{position.cursor.mapped.x.toFixed(3)},
|
||||
{position.cursor.mapped.y.toFixed(3)}
|
||||
</span>
|
||||
{#if status}
|
||||
<span class="flex items-center gap-1">
|
||||
|
||||
Reference in New Issue
Block a user