frontend events system refactor

This commit is contained in:
2026-03-07 01:14:41 +08:00
parent 59253d286c
commit 93e33e8d64
14 changed files with 322 additions and 224 deletions

View File

@@ -15,6 +15,16 @@ pub enum AppEvents {
EditDoll, EditDoll,
CreateDoll, CreateDoll,
UserStatusChanged, UserStatusChanged,
FriendCursorPosition,
FriendDisconnected,
FriendActiveDollChanged,
FriendUserStatus,
InteractionReceived,
InteractionDeliveryFailed,
FriendRequestReceived,
FriendRequestAccepted,
FriendRequestDenied,
Unfriended,
} }
impl AppEvents { impl AppEvents {
@@ -27,6 +37,16 @@ impl AppEvents {
AppEvents::EditDoll => "edit-doll", AppEvents::EditDoll => "edit-doll",
AppEvents::CreateDoll => "create-doll", AppEvents::CreateDoll => "create-doll",
AppEvents::UserStatusChanged => "user-status-changed", AppEvents::UserStatusChanged => "user-status-changed",
AppEvents::FriendCursorPosition => "friend-cursor-position",
AppEvents::FriendDisconnected => "friend-disconnected",
AppEvents::FriendActiveDollChanged => "friend-active-doll-changed",
AppEvents::FriendUserStatus => "friend-user-status",
AppEvents::InteractionReceived => "interaction-received",
AppEvents::InteractionDeliveryFailed => "interaction-delivery-failed",
AppEvents::FriendRequestReceived => "friend-request-received",
AppEvents::FriendRequestAccepted => "friend-request-accepted",
AppEvents::FriendRequestDenied => "friend-request-denied",
AppEvents::Unfriended => "unfriended",
} }
} }
} }

View File

@@ -1,26 +1,27 @@
use rust_socketio::{Payload, RawClient}; use rust_socketio::{Payload, RawClient};
use tracing::info; use tracing::info;
use crate::services::app_events::AppEvents;
use crate::services::cursor::{normalized_to_absolute, CursorPositions}; use crate::services::cursor::{normalized_to_absolute, CursorPositions};
use crate::state::AppDataRefreshScope; use crate::state::AppDataRefreshScope;
use super::{ use super::{
emitter, refresh, emitter, refresh,
types::{IncomingFriendCursorPayload, OutgoingFriendCursorPayload, WS_EVENT}, types::{IncomingFriendCursorPayload, OutgoingFriendCursorPayload},
utils, 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) {
if let Ok(value) = utils::extract_text_value(payload, "friend-request-received") { if let Ok(value) = utils::extract_text_value(payload, "friend-request-received") {
emitter::emit_to_frontend(WS_EVENT::FRIEND_REQUEST_RECEIVED, value); emitter::emit_to_frontend(AppEvents::FriendRequestReceived.as_str(), value);
} }
} }
/// Handler for friend-request-accepted event /// Handler for friend-request-accepted event
pub fn on_friend_request_accepted(payload: Payload, _socket: RawClient) { pub fn on_friend_request_accepted(payload: Payload, _socket: RawClient) {
if let Ok(value) = utils::extract_text_value(payload, "friend-request-accepted") { if let Ok(value) = utils::extract_text_value(payload, "friend-request-accepted") {
emitter::emit_to_frontend(WS_EVENT::FRIEND_REQUEST_ACCEPTED, value); emitter::emit_to_frontend(AppEvents::FriendRequestAccepted.as_str(), value);
refresh::refresh_app_data(AppDataRefreshScope::Friends); refresh::refresh_app_data(AppDataRefreshScope::Friends);
} }
} }
@@ -28,14 +29,14 @@ pub fn on_friend_request_accepted(payload: Payload, _socket: RawClient) {
/// Handler for friend-request-denied event /// Handler for friend-request-denied event
pub fn on_friend_request_denied(payload: Payload, _socket: RawClient) { pub fn on_friend_request_denied(payload: Payload, _socket: RawClient) {
if let Ok(value) = utils::extract_text_value(payload, "friend-request-denied") { if let Ok(value) = utils::extract_text_value(payload, "friend-request-denied") {
emitter::emit_to_frontend(WS_EVENT::FRIEND_REQUEST_DENIED, value); emitter::emit_to_frontend(AppEvents::FriendRequestDenied.as_str(), value);
} }
} }
/// Handler for unfriended event /// Handler for unfriended event
pub fn on_unfriended(payload: Payload, _socket: RawClient) { pub fn on_unfriended(payload: Payload, _socket: RawClient) {
if let Ok(value) = utils::extract_text_value(payload, "unfriended") { if let Ok(value) = utils::extract_text_value(payload, "unfriended") {
emitter::emit_to_frontend(WS_EVENT::UNFRIENDED, value); emitter::emit_to_frontend(AppEvents::Unfriended.as_str(), value);
refresh::refresh_app_data(AppDataRefreshScope::Friends); refresh::refresh_app_data(AppDataRefreshScope::Friends);
} }
} }
@@ -56,14 +57,14 @@ pub fn on_friend_cursor_position(payload: Payload, _socket: RawClient) {
}, },
}; };
emitter::emit_to_frontend(WS_EVENT::FRIEND_CURSOR_POSITION, outgoing_payload); emitter::emit_to_frontend(AppEvents::FriendCursorPosition.as_str(), outgoing_payload);
} }
} }
/// Handler for friend-disconnected event /// Handler for friend-disconnected event
pub fn on_friend_disconnected(payload: Payload, _socket: RawClient) { pub fn on_friend_disconnected(payload: Payload, _socket: RawClient) {
if let Ok(value) = utils::extract_text_value(payload, "friend-disconnected") { if let Ok(value) = utils::extract_text_value(payload, "friend-disconnected") {
emitter::emit_to_frontend(WS_EVENT::FRIEND_DISCONNECTED, value); emitter::emit_to_frontend(AppEvents::FriendDisconnected.as_str(), value);
} }
} }
@@ -93,7 +94,7 @@ fn handle_friend_doll_change(event_name: &str, payload: Payload) {
/// Handler for friend-active-doll-changed event /// Handler for friend-active-doll-changed event
pub fn on_friend_active_doll_changed(payload: Payload, _socket: RawClient) { pub fn on_friend_active_doll_changed(payload: Payload, _socket: RawClient) {
if let Ok(value) = utils::extract_text_value(payload, "friend-active-doll-changed") { if let Ok(value) = utils::extract_text_value(payload, "friend-active-doll-changed") {
emitter::emit_to_frontend(WS_EVENT::FRIEND_ACTIVE_DOLL_CHANGED, value); emitter::emit_to_frontend(AppEvents::FriendActiveDollChanged.as_str(), value);
refresh::refresh_app_data(AppDataRefreshScope::Friends); refresh::refresh_app_data(AppDataRefreshScope::Friends);
} }
} }
@@ -101,6 +102,6 @@ pub fn on_friend_active_doll_changed(payload: Payload, _socket: RawClient) {
/// Handler for friend-user-status event /// Handler for friend-user-status event
pub fn on_friend_user_status(payload: Payload, _socket: RawClient) { pub fn on_friend_user_status(payload: Payload, _socket: RawClient) {
if let Ok(value) = utils::extract_text_value(payload, "friend-user-status") { if let Ok(value) = utils::extract_text_value(payload, "friend-user-status") {
emitter::emit_to_frontend(WS_EVENT::FRIEND_USER_STATUS, value); emitter::emit_to_frontend(AppEvents::FriendUserStatus.as_str(), value);
} }
} }

View File

@@ -1,15 +1,16 @@
use rust_socketio::{Payload, RawClient}; use rust_socketio::{Payload, RawClient};
use crate::models::interaction::{InteractionDeliveryFailedDto, InteractionPayloadDto}; use crate::models::interaction::{InteractionDeliveryFailedDto, InteractionPayloadDto};
use crate::services::app_events::AppEvents;
use super::{emitter, types::WS_EVENT, utils}; use super::{emitter, utils};
/// Handler for interaction-received event /// Handler for interaction-received event
pub fn on_interaction_received(payload: Payload, _socket: RawClient) { pub fn on_interaction_received(payload: Payload, _socket: RawClient) {
if let Ok(data) = if let Ok(data) =
utils::extract_and_parse::<InteractionPayloadDto>(payload, "interaction-received") utils::extract_and_parse::<InteractionPayloadDto>(payload, "interaction-received")
{ {
emitter::emit_to_frontend(WS_EVENT::INTERACTION_RECEIVED, data); emitter::emit_to_frontend(AppEvents::InteractionReceived.as_str(), data);
} }
} }
@@ -19,6 +20,6 @@ pub fn on_interaction_delivery_failed(payload: Payload, _socket: RawClient) {
payload, payload,
"interaction-delivery-failed", "interaction-delivery-failed",
) { ) {
emitter::emit_to_frontend(WS_EVENT::INTERACTION_DELIVERY_FAILED, data); emitter::emit_to_frontend(AppEvents::InteractionDeliveryFailed.as_str(), data);
} }
} }

View File

@@ -1,24 +1,27 @@
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import { type UserData } from "../types/bindings/UserData"; import { type UserData } from "../types/bindings/UserData";
import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { AppEvents } from "../types/bindings/AppEventsConstants"; import { AppEvents } from "../types/bindings/AppEventsConstants";
import { createListenerSubscription, setupHmrCleanup } from "./listener-utils";
export let appData = writable<UserData | null>(null); export const appData = writable<UserData | null>(null);
let unlisten: UnlistenFn | null = null; const subscription = createListenerSubscription();
let isListening = false;
export async function initAppDataListener() { export async function initAppDataListener() {
try { try {
if (isListening) return; if (subscription.isListening()) return;
appData.set(await invoke("get_app_data")); appData.set(await invoke("get_app_data"));
unlisten = await listen<UserData>(AppEvents.AppDataRefreshed, (event) => { const unlisten = await listen<UserData>(
AppEvents.AppDataRefreshed,
(event) => {
console.log("app-data-refreshed", event.payload); console.log("app-data-refreshed", event.payload);
appData.set(event.payload); appData.set(event.payload);
}); },
);
isListening = true; subscription.setUnlisten(unlisten);
subscription.setListening(true);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
throw error; throw error;
@@ -26,16 +29,7 @@ export async function initAppDataListener() {
} }
export function stopAppDataListener() { export function stopAppDataListener() {
if (unlisten) { subscription.stop();
unlisten();
unlisten = null;
isListening = false;
}
} }
// Handle HMR (Hot Module Replacement) cleanup setupHmrCleanup(stopAppDataListener);
if (import.meta.hot) {
import.meta.hot.dispose(() => {
stopAppDataListener();
});
}

View File

@@ -1,12 +1,18 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import type { CursorPositions } from "../types/bindings/CursorPositions"; import type { CursorPositions } from "../types/bindings/CursorPositions";
import type { CursorPosition } from "../types/bindings/CursorPosition"; import type { CursorPosition } from "../types/bindings/CursorPosition";
import type { DollDto } from "../types/bindings/DollDto"; import type { DollDto } from "../types/bindings/DollDto";
import { AppEvents } from "../types/bindings/AppEventsConstants"; import { AppEvents } from "../types/bindings/AppEventsConstants";
import {
createMultiListenerSubscription,
parseEventPayload,
removeFromStore,
setupHmrCleanup,
} from "./listener-utils";
export let cursorPositionOnScreen = writable<CursorPositions>({ export const cursorPositionOnScreen = writable<CursorPositions>({
raw: { x: 0, y: 0 }, raw: { x: 0, y: 0 },
mapped: { x: 0, y: 0 }, mapped: { x: 0, y: 0 },
}); });
@@ -26,16 +32,12 @@ type FriendCursorData = {
// The exported store will only expose the position part to consumers, // The exported store will only expose the position part to consumers,
// but internally we manage the full data. // but internally we manage the full data.
// Actually, it's easier if we just export the positions and manage state internally. // Actually, it's easier if we just export the positions and manage state internally.
export let friendsCursorPositions = writable<Record<string, CursorPositions>>( export const friendsCursorPositions = writable<Record<string, CursorPositions>>(
{}, {},
); );
export let friendsActiveDolls = writable<Record<string, DollDto | null>>({}); export const friendsActiveDolls = writable<Record<string, DollDto | null>>({});
let unlistenCursor: UnlistenFn | null = null; const subscription = createMultiListenerSubscription();
let unlistenFriendCursor: UnlistenFn | null = null;
let unlistenFriendDisconnected: UnlistenFn | null = null;
let unlistenFriendActiveDollChanged: UnlistenFn | null = null;
let isListening = false;
// Internal state to track timestamps // Internal state to track timestamps
let friendCursorState: Record<string, FriendCursorData> = {}; let friendCursorState: Record<string, FriendCursorData> = {};
@@ -46,22 +48,21 @@ let friendCursorState: Record<string, FriendCursorData> = {};
* but all windows can independently listen to the broadcast events. * but all windows can independently listen to the broadcast events.
*/ */
export async function initCursorTracking() { export async function initCursorTracking() {
if (isListening) { if (subscription.isListening()) return;
return;
}
try { try {
// Listen to cursor position events (each window subscribes independently) // Listen to cursor position events (each window subscribes independently)
unlistenCursor = await listen<CursorPositions>( const unlistenCursor = await listen<CursorPositions>(
AppEvents.CursorPosition, AppEvents.CursorPosition,
(event) => { (event) => {
cursorPositionOnScreen.set(event.payload); cursorPositionOnScreen.set(event.payload);
}, },
); );
subscription.addUnlisten(unlistenCursor);
// Listen to friend cursor position events // Listen to friend cursor position events
unlistenFriendCursor = await listen<FriendCursorPosition>( const unlistenFriendCursor = await listen<FriendCursorPosition>(
"friend-cursor-position", AppEvents.FriendCursorPosition,
(event) => { (event) => {
// We now receive a clean object from Rust // We now receive a clean object from Rust
const data = event.payload; const data = event.payload;
@@ -80,20 +81,16 @@ export async function initCursorTracking() {
}); });
}, },
); );
subscription.addUnlisten(unlistenFriendCursor);
// Listen to friend disconnected events // Listen to friend disconnected events
unlistenFriendDisconnected = await listen<[{ userId: string }]>( const unlistenFriendDisconnected = await listen<
"friend-disconnected", [{ userId: string }] | { userId: string } | string
(event) => { >(AppEvents.FriendDisconnected, (event) => {
let payload = event.payload; const payload = parseEventPayload<
if (typeof payload === "string") { [{ userId: string }] | { userId: string }
try { >(event.payload, "friend-disconnected");
payload = JSON.parse(payload); if (!payload) return;
} catch (e) {
console.error("Failed to parse friend disconnected payload:", e);
return;
}
}
const data = Array.isArray(payload) ? payload[0] : payload; const data = Array.isArray(payload) ? payload[0] : payload;
@@ -103,35 +100,25 @@ export async function initCursorTracking() {
} }
// Update svelte store // Update svelte store
friendsCursorPositions.update((current) => { friendsCursorPositions.update((current) =>
const next = { ...current }; removeFromStore(current, data.userId),
delete next[data.userId];
return next;
});
},
); );
});
subscription.addUnlisten(unlistenFriendDisconnected);
// Listen to friend active doll changed events // Listen to friend active doll changed events
unlistenFriendActiveDollChanged = await listen< const unlistenFriendActiveDollChanged = await listen<
| string | string
| { | {
friendId: string; friendId: string;
doll: DollDto | null; doll: DollDto | null;
} }
>("friend-active-doll-changed", (event) => { >(AppEvents.FriendActiveDollChanged, (event) => {
let data = event.payload; const data = parseEventPayload<{
friendId: string;
if (typeof data === "string") { doll: DollDto | null;
try { }>(event.payload, "friend-active-doll-changed");
data = JSON.parse(data); if (!data) return;
} catch (e) {
console.error(
"Failed to parse friend-active-doll-changed payload:",
e,
);
return;
}
}
// Cast to expected type after parsing // Cast to expected type after parsing
const payload = data as { friendId: string; doll: DollDto | null }; const payload = data as { friendId: string; doll: DollDto | null };
@@ -149,11 +136,9 @@ export async function initCursorTracking() {
}); });
// Also remove from cursor positions so the sprite disappears // Also remove from cursor positions so the sprite disappears
friendsCursorPositions.update((current) => { friendsCursorPositions.update((current) =>
const next = { ...current }; removeFromStore(current, payload.friendId),
delete next[payload.friendId]; );
return next;
});
} else { } else {
// Update or add the new doll configuration // Update or add the new doll configuration
friendsActiveDolls.update((current) => { friendsActiveDolls.update((current) => {
@@ -164,8 +149,9 @@ export async function initCursorTracking() {
}); });
} }
}); });
subscription.addUnlisten(unlistenFriendActiveDollChanged);
isListening = true; subscription.setListening(true);
} catch (err) { } catch (err) {
console.error("Failed to initialize cursor tracking:", err); console.error("Failed to initialize cursor tracking:", err);
throw err; throw err;
@@ -177,28 +163,7 @@ export async function initCursorTracking() {
* Note: This doesn't stop the Rust-side tracking, just stops this window from receiving events. * Note: This doesn't stop the Rust-side tracking, just stops this window from receiving events.
*/ */
export function stopCursorTracking() { export function stopCursorTracking() {
if (unlistenCursor) { subscription.stop();
unlistenCursor();
unlistenCursor = null;
}
if (unlistenFriendCursor) {
unlistenFriendCursor();
unlistenFriendCursor = null;
}
if (unlistenFriendDisconnected) {
unlistenFriendDisconnected();
unlistenFriendDisconnected = null;
}
if (unlistenFriendActiveDollChanged) {
unlistenFriendActiveDollChanged();
unlistenFriendActiveDollChanged = null;
}
isListening = false;
} }
// Handle HMR (Hot Module Replacement) cleanup setupHmrCleanup(stopCursorTracking);
if (import.meta.hot) {
import.meta.hot.dispose(() => {
stopCursorTracking();
});
}

View File

@@ -2,20 +2,27 @@ import { listen } from "@tauri-apps/api/event";
import { addInteraction } from "$lib/stores/interaction-store"; import { addInteraction } from "$lib/stores/interaction-store";
import type { InteractionPayloadDto } from "../types/bindings/InteractionPayloadDto"; import type { InteractionPayloadDto } from "../types/bindings/InteractionPayloadDto";
import type { InteractionDeliveryFailedDto } from "../types/bindings/InteractionDeliveryFailedDto"; import type { InteractionDeliveryFailedDto } from "../types/bindings/InteractionDeliveryFailedDto";
import { AppEvents } from "../types/bindings/AppEventsConstants";
import {
createMultiListenerSubscription,
setupHmrCleanup,
} from "./listener-utils";
let unlistenReceived: (() => void) | undefined; const subscription = createMultiListenerSubscription();
let unlistenFailed: (() => void) | undefined;
export async function initInteractionListeners() { export async function initInteractionListeners() {
unlistenReceived = await listen<InteractionPayloadDto>( if (subscription.isListening()) return;
"interaction-received",
const unlistenReceived = await listen<InteractionPayloadDto>(
AppEvents.InteractionReceived,
(event) => { (event) => {
addInteraction(event.payload); addInteraction(event.payload);
}, },
); );
subscription.addUnlisten(unlistenReceived);
unlistenFailed = await listen<InteractionDeliveryFailedDto>( const unlistenFailed = await listen<InteractionDeliveryFailedDto>(
"interaction-delivery-failed", AppEvents.InteractionDeliveryFailed,
(event) => { (event) => {
console.error("Interaction delivery failed:", event.payload); console.error("Interaction delivery failed:", event.payload);
// You might want to show a toast or alert here // You might want to show a toast or alert here
@@ -24,9 +31,12 @@ export async function initInteractionListeners() {
); );
}, },
); );
subscription.addUnlisten(unlistenFailed);
subscription.setListening(true);
} }
export function stopInteractionListeners() { export function stopInteractionListeners() {
if (unlistenReceived) unlistenReceived(); subscription.stop();
if (unlistenFailed) unlistenFailed();
} }
setupHmrCleanup(stopInteractionListeners);

View File

@@ -0,0 +1,101 @@
import type { UnlistenFn } from "@tauri-apps/api/event";
export type ListenerSubscription = {
stop: () => void;
isListening: () => boolean;
setListening: (value: boolean) => void;
setUnlisten: (unlisten: UnlistenFn | null) => void;
};
export type MultiListenerSubscription = {
stop: () => void;
isListening: () => boolean;
setListening: (value: boolean) => void;
addUnlisten: (unlisten: UnlistenFn | null) => void;
};
export function createListenerSubscription(
stopFn: () => void = () => {},
): ListenerSubscription {
let unlisten: UnlistenFn | null = null;
let listening = false;
return {
stop: () => {
if (unlisten) {
unlisten();
unlisten = null;
}
listening = false;
stopFn();
},
isListening: () => listening,
setListening: (value) => {
listening = value;
},
setUnlisten: (next) => {
unlisten = next;
},
};
}
export function createMultiListenerSubscription(
stopFn: () => void = () => {},
): MultiListenerSubscription {
let unlistens: UnlistenFn[] = [];
let listening = false;
return {
stop: () => {
for (const unlisten of unlistens) {
unlisten();
}
unlistens = [];
listening = false;
stopFn();
},
isListening: () => listening,
setListening: (value) => {
listening = value;
},
addUnlisten: (unlisten) => {
if (unlisten) {
unlistens.push(unlisten);
}
},
};
}
export function setupHmrCleanup(cleanup: () => void) {
if (import.meta.hot) {
import.meta.hot.dispose(() => {
cleanup();
});
}
}
export function parseEventPayload<T>(
payload: unknown,
errorLabel: string,
): T | null {
if (typeof payload === "string") {
try {
return JSON.parse(payload) as T;
} catch (error) {
console.error(`Failed to parse ${errorLabel} payload`, error);
return null;
}
}
return payload as T;
}
export function removeFromStore<T>(
current: Record<string, T>,
key: string,
): Record<string, T> {
if (!(key in current)) return current;
const next = { ...current };
delete next[key];
return next;
}

View File

@@ -1,22 +1,26 @@
import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import { AppEvents } from "../types/bindings/AppEventsConstants"; import { AppEvents } from "../types/bindings/AppEventsConstants";
import { createListenerSubscription, setupHmrCleanup } from "./listener-utils";
export const sceneInteractive = writable<boolean>(false); export const sceneInteractive = writable<boolean>(false);
let unlisten: UnlistenFn | null = null; const subscription = createListenerSubscription();
let isListening = false;
export async function initSceneInteractiveListener() { export async function initSceneInteractiveListener() {
if (isListening) return; if (subscription.isListening()) return;
try { try {
// ensure initial default matches backend default // ensure initial default matches backend default
sceneInteractive.set(false); sceneInteractive.set(false);
unlisten = await listen<boolean>(AppEvents.SceneInteractive, (event) => { const unlisten = await listen<boolean>(
AppEvents.SceneInteractive,
(event) => {
sceneInteractive.set(Boolean(event.payload)); sceneInteractive.set(Boolean(event.payload));
}); },
isListening = true; );
subscription.setUnlisten(unlisten);
subscription.setListening(true);
} catch (error) { } catch (error) {
console.error("Failed to initialize scene interactive listener:", error); console.error("Failed to initialize scene interactive listener:", error);
throw error; throw error;
@@ -24,15 +28,7 @@ export async function initSceneInteractiveListener() {
} }
export function stopSceneInteractiveListener() { export function stopSceneInteractiveListener() {
if (unlisten) { subscription.stop();
unlisten();
unlisten = null;
isListening = false;
}
} }
if (import.meta.hot) { setupHmrCleanup(stopSceneInteractiveListener);
import.meta.hot.dispose(() => {
stopSceneInteractiveListener();
});
}

View File

@@ -1,6 +1,13 @@
import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import type { PresenceStatus } from "../types/bindings/PresenceStatus"; import type { PresenceStatus } from "../types/bindings/PresenceStatus";
import { AppEvents } from "../types/bindings/AppEventsConstants";
import {
createMultiListenerSubscription,
parseEventPayload,
removeFromStore,
setupHmrCleanup,
} from "./listener-utils";
export type UserStatus = { export type UserStatus = {
presenceStatus: PresenceStatus; presenceStatus: PresenceStatus;
@@ -9,27 +16,23 @@ export type UserStatus = {
export const friendsUserStatuses = writable<Record<string, UserStatus>>({}); export const friendsUserStatuses = writable<Record<string, UserStatus>>({});
let unlistenStatus: UnlistenFn | null = null; const subscription = createMultiListenerSubscription();
let unlistenFriendDisconnected: UnlistenFn | null = null;
let isListening = false;
export async function initUserStatusListeners() { export async function initUserStatusListeners() {
if (isListening) return; if (subscription.isListening()) return;
try { try {
unlistenStatus = await listen<unknown>("friend-user-status", (event) => { const unlistenStatus = await listen<unknown>(
let payload = event.payload as any; AppEvents.FriendUserStatus,
if (typeof payload === "string") { (event) => {
try { const payload = parseEventPayload<{
payload = JSON.parse(payload); userId?: string;
} catch (error) { status?: UserStatus;
console.error("Failed to parse friend-user-status payload", error); }>(event.payload, "friend-user-status");
return; if (!payload) return;
}
}
const userId = payload?.userId as string | undefined; const userId = payload.userId;
const status = payload?.status as UserStatus | undefined; const status = payload.status;
if (!userId || !status) return; if (!userId || !status) return;
if (!status.presenceStatus) return; if (!status.presenceStatus) return;
@@ -51,33 +54,27 @@ export async function initUserStatusListeners() {
state: status.state, state: status.state,
}, },
})); }));
}); },
);
subscription.addUnlisten(unlistenStatus);
unlistenFriendDisconnected = await listen< const unlistenFriendDisconnected = await listen<
[{ userId: string }] | { userId: string } | string [{ userId: string }] | { userId: string } | string
>("friend-disconnected", (event) => { >(AppEvents.FriendDisconnected, (event) => {
let payload = event.payload as any; const payload = parseEventPayload<
if (typeof payload === "string") { [{ userId: string }] | { userId: string }
try { >(event.payload, "friend-disconnected");
payload = JSON.parse(payload); if (!payload) return;
} catch (error) {
console.error("Failed to parse friend-disconnected payload", error);
return;
}
}
const data = Array.isArray(payload) ? payload[0] : payload; const data = Array.isArray(payload) ? payload[0] : payload;
const userId = data?.userId as string | undefined; const userId = data?.userId as string | undefined;
if (!userId) return; if (!userId) return;
friendsUserStatuses.update((current) => { friendsUserStatuses.update((current) => removeFromStore(current, userId));
const next = { ...current };
delete next[userId];
return next;
});
}); });
subscription.addUnlisten(unlistenFriendDisconnected);
isListening = true; subscription.setListening(true);
} catch (error) { } catch (error) {
console.error("Failed to initialize user status listeners", error); console.error("Failed to initialize user status listeners", error);
throw error; throw error;
@@ -85,19 +82,7 @@ export async function initUserStatusListeners() {
} }
export function stopUserStatusListeners() { export function stopUserStatusListeners() {
if (unlistenStatus) { subscription.stop();
unlistenStatus();
unlistenStatus = null;
}
if (unlistenFriendDisconnected) {
unlistenFriendDisconnected();
unlistenFriendDisconnected = null;
}
isListening = false;
} }
if (import.meta.hot) { setupHmrCleanup(stopUserStatusListeners);
import.meta.hot.dispose(() => {
stopUserStatusListeners();
});
}

View File

@@ -5,11 +5,12 @@
import YourDolls from "./tabs/your-dolls/index.svelte"; import YourDolls from "./tabs/your-dolls/index.svelte";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { AppEvents } from "../../types/bindings/AppEventsConstants";
let showInteractionOverlay = false; let showInteractionOverlay = false;
onMount(() => { onMount(() => {
const unlisten = listen("set-interaction-overlay", (event) => { const unlisten = listen(AppEvents.SetInteractionOverlay, (event) => {
showInteractionOverlay = event.payload as boolean; showInteractionOverlay = event.payload as boolean;
}); });

View File

@@ -3,6 +3,7 @@
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { appData } from "../../../events/app-data"; import { appData } from "../../../events/app-data";
import { AppEvents } from "../../../types/bindings/AppEventsConstants";
import type { FriendRequestResponseDto } from "../../../types/bindings/FriendRequestResponseDto.js"; import type { FriendRequestResponseDto } from "../../../types/bindings/FriendRequestResponseDto.js";
import type { FriendshipResponseDto } from "../../../types/bindings/FriendshipResponseDto.js"; import type { FriendshipResponseDto } from "../../../types/bindings/FriendshipResponseDto.js";
import type { UserBasicDto } from "../../../types/bindings/UserBasicDto.js"; import type { UserBasicDto } from "../../../types/bindings/UserBasicDto.js";
@@ -50,26 +51,26 @@
refreshSent(); refreshSent();
unlisteners.push( unlisteners.push(
await listen("friend-request-received", () => { await listen(AppEvents.FriendRequestReceived, () => {
refreshReceived(); refreshReceived();
}), }),
); );
unlisteners.push( unlisteners.push(
await listen("friend-request-accepted", () => { await listen(AppEvents.FriendRequestAccepted, () => {
refreshSent(); refreshSent();
invoke("refresh_app_data"); invoke("refresh_app_data");
}), }),
); );
unlisteners.push( unlisteners.push(
await listen("friend-request-denied", () => { await listen(AppEvents.FriendRequestDenied, () => {
refreshSent(); refreshSent();
}), }),
); );
unlisteners.push( unlisteners.push(
await listen("unfriended", () => { await listen(AppEvents.Unfriended, () => {
invoke("refresh_app_data"); invoke("refresh_app_data");
}), }),
); );

View File

@@ -13,9 +13,13 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import DesktopPet from "./components/DesktopPet.svelte"; import DesktopPet from "./components/DesktopPet.svelte";
import FullscreenModal from "./components/FullscreenModal.svelte"; import FullscreenModal from "./components/FullscreenModal.svelte";
import { receivedInteractions, clearInteraction } from "$lib/stores/interaction-store"; import {
receivedInteractions,
clearInteraction,
} from "$lib/stores/interaction-store";
import { INTERACTION_TYPE_HEADPAT } from "$lib/constants/interaction"; import { INTERACTION_TYPE_HEADPAT } from "$lib/constants/interaction";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { AppEvents } from "../../types/bindings/AppEventsConstants";
import { getSpriteSheetUrl } from "$lib/utils/sprite-utils"; import { getSpriteSheetUrl } from "$lib/utils/sprite-utils";
import { onMount } from "svelte"; import { onMount } from "svelte";
import type { PresenceStatus } from "../../types/bindings/PresenceStatus"; import type { PresenceStatus } from "../../types/bindings/PresenceStatus";
@@ -61,7 +65,9 @@
let userPetpetGif = ""; let userPetpetGif = "";
if (userDoll) { if (userDoll) {
try { try {
const gifBase64 = await invoke<string>("encode_pet_doll_gif_base64", { doll: userDoll }); const gifBase64 = await invoke<string>("encode_pet_doll_gif_base64", {
doll: userDoll,
});
userPetpetGif = `data:image/gif;base64,${gifBase64}`; userPetpetGif = `data:image/gif;base64,${gifBase64}`;
} catch (e) { } catch (e) {
console.error("Failed to generate user petpet:", e); console.error("Failed to generate user petpet:", e);
@@ -102,9 +108,14 @@
if (interaction.type === INTERACTION_TYPE_HEADPAT) { if (interaction.type === INTERACTION_TYPE_HEADPAT) {
if (showFullscreenModal) { if (showFullscreenModal) {
// Queue the headpat for later (deduplicate by replacing existing from same user) // Queue the headpat for later (deduplicate by replacing existing from same user)
const existingIndex = headpatQueue.findIndex((h) => h.userId === userId); const existingIndex = headpatQueue.findIndex(
(h) => h.userId === userId,
);
if (existingIndex >= 0) { if (existingIndex >= 0) {
headpatQueue[existingIndex] = { userId, content: interaction.content }; headpatQueue[existingIndex] = {
userId,
content: interaction.content,
};
} else { } else {
headpatQueue.push({ userId, content: interaction.content }); headpatQueue.push({ userId, content: interaction.content });
} }
@@ -165,10 +176,12 @@
let presenceStatus: PresenceStatus | null = $state(null); let presenceStatus: PresenceStatus | null = $state(null);
onMount(() => { onMount(() => {
const unlisten = listen<UserStatus>("user-status-changed", (event) => { const unlisten = listen<UserStatus>(
console.log("event received"); AppEvents.UserStatusChanged,
(event) => {
presenceStatus = event.payload.presenceStatus; presenceStatus = event.payload.presenceStatus;
}); },
);
return () => { return () => {
unlisten.then((u) => u()); unlisten.then((u) => u());

View File

@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type AppEvents = "cursor-position" | "scene-interactive" | "app-data-refreshed" | "set-interaction-overlay" | "edit-doll" | "create-doll" | "user-status-changed"; export type AppEvents = "cursor-position" | "scene-interactive" | "app-data-refreshed" | "set-interaction-overlay" | "edit-doll" | "create-doll" | "user-status-changed" | "friend-cursor-position" | "friend-disconnected" | "friend-active-doll-changed" | "friend-user-status" | "interaction-received" | "interaction-delivery-failed" | "friend-request-received" | "friend-request-accepted" | "friend-request-denied" | "unfriended";

View File

@@ -9,6 +9,16 @@ export const AppEvents = {
EditDoll: "edit-doll", EditDoll: "edit-doll",
CreateDoll: "create-doll", CreateDoll: "create-doll",
UserStatusChanged: "user-status-changed", UserStatusChanged: "user-status-changed",
FriendCursorPosition: "friend-cursor-position",
FriendDisconnected: "friend-disconnected",
FriendActiveDollChanged: "friend-active-doll-changed",
FriendUserStatus: "friend-user-status",
InteractionReceived: "interaction-received",
InteractionDeliveryFailed: "interaction-delivery-failed",
FriendRequestReceived: "friend-request-received",
FriendRequestAccepted: "friend-request-accepted",
FriendRequestDenied: "friend-request-denied",
Unfriended: "unfriended",
} as const; } as const;
export type AppEvents = typeof AppEvents[keyof typeof AppEvents]; export type AppEvents = typeof AppEvents[keyof typeof AppEvents];