diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b8e64d8..cc3a6b8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ use crate::{ models::app_data::AppData, + models::interaction::SendInteractionDto, remotes::{ dolls::{CreateDollDto, DollDto, DollsRemote, UpdateDollDto}, friends::{ @@ -14,6 +15,7 @@ use crate::{ }, cursor::start_cursor_tracking, doll_editor::open_doll_editor_window, + interaction::send_interaction, scene::{open_splash_window, set_pet_menu_state, set_scene_interactive}, }, state::{init_app_data, init_app_data_scoped, AppDataRefreshScope, FDOLL}, @@ -375,6 +377,11 @@ async fn logout_and_restart() -> Result<(), String> { .map_err(|e| e.to_string()) } +#[tauri::command] +async fn send_interaction_cmd(dto: SendInteractionDto) -> Result<(), String> { + send_interaction(dto).await +} + #[tauri::command] fn start_auth_flow() -> Result<(), String> { // Cancel any in-flight auth listener/state before starting a new one @@ -427,7 +434,8 @@ pub fn run() { set_scene_interactive, set_pet_menu_state, start_auth_flow, - logout_and_restart + logout_and_restart, + send_interaction_cmd ]) .setup(|app| { APP_HANDLE diff --git a/src-tauri/src/models/interaction.rs b/src-tauri/src/models/interaction.rs new file mode 100644 index 0000000..54d1372 --- /dev/null +++ b/src-tauri/src/models/interaction.rs @@ -0,0 +1,32 @@ +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +#[derive(Clone, Serialize, Deserialize, Debug, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct SendInteractionDto { + pub recipient_user_id: String, + pub content: String, + #[serde(rename = "type")] + pub type_: String, +} + +#[derive(Clone, Serialize, Deserialize, Debug, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct InteractionPayloadDto { + pub sender_user_id: String, + pub sender_name: String, + pub content: String, + #[serde(rename = "type")] + pub type_: String, + pub timestamp: String, +} + +#[derive(Clone, Serialize, Deserialize, Debug, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct InteractionDeliveryFailedDto { + pub recipient_user_id: String, + pub reason: String, +} diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index b946570..6d73636 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -1 +1,4 @@ pub mod app_data; +pub mod interaction; + +pub use interaction::*; diff --git a/src-tauri/src/services/interaction.rs b/src-tauri/src/services/interaction.rs new file mode 100644 index 0000000..f871e3c --- /dev/null +++ b/src-tauri/src/services/interaction.rs @@ -0,0 +1,72 @@ +use serde_json::json; +use tracing::{error, info}; + +use crate::{ + lock_r, + models::interaction::SendInteractionDto, + services::ws::WS_EVENT, + state::FDOLL, +}; + +pub async fn send_interaction(dto: SendInteractionDto) -> Result<(), String> { + // Check if WS is initialized + let client = { + let guard = lock_r!(FDOLL); + if let Some(clients) = &guard.clients { + if clients.is_ws_initialized { + clients.ws_client.clone() + } else { + return Err("WebSocket not initialized".to_string()); + } + } else { + return Err("App not fully initialized".to_string()); + } + }; + + if let Some(socket) = client { + // Prepare payload for client-send-interaction event + // The DTO structure matches what the server expects: + // { recipientUserId, content, type } (handled by serde rename_all="camelCase") + + // Note: The `type` field in DTO is mapped to `type_` in Rust struct but serialized as `type` + // due to camelCase renaming (if we rely on TS-RS output) or manual renaming. + // Wait, `type` is a reserved keyword in Rust so we used `type_`. + // We need to ensure serialization maps `type_` to `type`. + // However, serde's `rename_all = "camelCase"` handles `recipient_user_id` -> `recipientUserId`. + // It does NOT automatically handle `type_` -> `type`. + // We should add `#[serde(rename = "type")]` to the `type_` field in the model. + // I will fix the model first to ensure correct serialization. + + let payload = json!({ + "recipientUserId": dto.recipient_user_id, + "content": dto.content, + "type": dto.type_ + }); + + info!("Sending interaction via WS: {:?}", payload); + + // Blocking emission because rust_socketio::Client::emit is synchronous/blocking + // but we are in an async context. Ideally we spawn_blocking. + let spawn_result = tauri::async_runtime::spawn_blocking(move || { + socket.emit(WS_EVENT::CLIENT_SEND_INTERACTION, payload) + }).await; + + match spawn_result { + Ok(emit_result) => match emit_result { + Ok(_) => Ok(()), + Err(e) => { + let err_msg = format!("Failed to emit interaction event: {}", e); + error!("{}", err_msg); + Err(err_msg) + } + }, + Err(e) => { + let err_msg = format!("Failed to spawn blocking task for interaction emit: {}", e); + error!("{}", err_msg); + Err(err_msg) + } + } + } else { + Err("WebSocket client not available".to_string()) + } +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index d37d8ca..cd65ba1 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -4,6 +4,7 @@ pub mod client_config_manager; pub mod cursor; pub mod doll_editor; pub mod health_manager; +pub mod interaction; pub mod scene; pub mod sprite_recolor; pub mod welcome; diff --git a/src-tauri/src/services/ws.rs b/src-tauri/src/services/ws.rs index 8dbeba3..0faea40 100644 --- a/src-tauri/src/services/ws.rs +++ b/src-tauri/src/services/ws.rs @@ -5,6 +5,7 @@ use tracing::{error, info}; use crate::{ get_app_handle, lock_r, lock_w, + models::interaction::{InteractionDeliveryFailedDto, InteractionPayloadDto}, services::{ client_config_manager::AppConfig, cursor::{normalized_to_absolute, CursorPosition, CursorPositions}, @@ -49,6 +50,9 @@ impl WS_EVENT { pub const DOLL_DELETED: &str = "doll_deleted"; pub const CLIENT_INITIALIZE: &str = "client-initialize"; pub const INITIALIZED: &str = "initialized"; + pub const INTERACTION_RECEIVED: &str = "interaction-received"; + pub const INTERACTION_DELIVERY_FAILED: &str = "interaction-delivery-failed"; + pub const CLIENT_SEND_INTERACTION: &str = "client-send-interaction"; } fn emit_initialize(socket: &RawClient) { @@ -364,6 +368,60 @@ fn on_doll_deleted(payload: Payload, _socket: RawClient) { } } +fn on_interaction_received(payload: Payload, _socket: RawClient) { + match payload { + Payload::Text(values) => { + if let Some(first_value) = values.first() { + info!("Received interaction-received event: {:?}", first_value); + + let interaction_data: Result = + serde_json::from_value(first_value.clone()); + + match interaction_data { + Ok(data) => { + if let Err(e) = get_app_handle().emit(WS_EVENT::INTERACTION_RECEIVED, data) { + error!("Failed to emit interaction-received event: {:?}", e); + } + } + Err(e) => { + error!("Failed to parse interaction payload: {}", e); + } + } + } else { + info!("Received interaction-received event with empty payload"); + } + } + _ => error!("Received unexpected payload format for interaction-received"), + } +} + +fn on_interaction_delivery_failed(payload: Payload, _socket: RawClient) { + match payload { + Payload::Text(values) => { + if let Some(first_value) = values.first() { + info!("Received interaction-delivery-failed event: {:?}", first_value); + + let failure_data: Result = + serde_json::from_value(first_value.clone()); + + match failure_data { + Ok(data) => { + if let Err(e) = get_app_handle().emit(WS_EVENT::INTERACTION_DELIVERY_FAILED, data) { + error!("Failed to emit interaction-delivery-failed event: {:?}", e); + } + } + Err(e) => { + error!("Failed to parse interaction failure payload: {}", e); + } + } + } else { + info!("Received interaction-delivery-failed event with empty payload"); + } + } + _ => error!("Received unexpected payload format for interaction-delivery-failed"), + } +} + pub async fn report_cursor_data(cursor_position: CursorPosition) { // Only attempt to get clients if lock_r succeeds (it should, but safety first) // and if clients are actually initialized. @@ -471,6 +529,11 @@ pub async fn build_ws_client( .on(WS_EVENT::DOLL_UPDATED, on_doll_updated) .on(WS_EVENT::DOLL_DELETED, on_doll_deleted) .on(WS_EVENT::INITIALIZED, on_initialized) + .on(WS_EVENT::INTERACTION_RECEIVED, on_interaction_received) + .on( + WS_EVENT::INTERACTION_DELIVERY_FAILED, + on_interaction_delivery_failed, + ) // rust-socketio fires Event::Connect on initial connect AND reconnects // so we resend initialization there instead of a dedicated reconnect event. .on(Event::Connect, on_connected) diff --git a/src/events/interaction.ts b/src/events/interaction.ts new file mode 100644 index 0000000..aaf7d00 --- /dev/null +++ b/src/events/interaction.ts @@ -0,0 +1,33 @@ +import { listen } from "@tauri-apps/api/event"; +import { addInteraction } from "$lib/stores/interaction-store"; +import type { InteractionPayloadDto } from "../types/bindings/InteractionPayloadDto"; +import type { InteractionDeliveryFailedDto } from "../types/bindings/InteractionDeliveryFailedDto"; + +let unlistenReceived: (() => void) | undefined; +let unlistenFailed: (() => void) | undefined; + +export async function initInteractionListeners() { + unlistenReceived = await listen( + "interaction-received", + (event) => { + console.log("Received interaction:", event.payload); + addInteraction(event.payload); + }, + ); + + unlistenFailed = await listen( + "interaction-delivery-failed", + (event) => { + console.error("Interaction delivery failed:", event.payload); + // You might want to show a toast or alert here + alert( + `Failed to send message to user ${event.payload.recipientUserId}: ${event.payload.reason}`, + ); + }, + ); +} + +export function stopInteractionListeners() { + if (unlistenReceived) unlistenReceived(); + if (unlistenFailed) unlistenFailed(); +} diff --git a/src/lib/stores/interaction-store.ts b/src/lib/stores/interaction-store.ts new file mode 100644 index 0000000..9029ad7 --- /dev/null +++ b/src/lib/stores/interaction-store.ts @@ -0,0 +1,23 @@ +import { writable } from "svelte/store"; +import type { InteractionPayloadDto } from "../../types/bindings/InteractionPayloadDto"; + +// Map senderUserId -> InteractionPayloadDto +export const receivedInteractions = writable>(new Map()); + +export function addInteraction(interaction: InteractionPayloadDto) { + receivedInteractions.update((map) => { + // For now, we only store the latest message per user. + // In the future, we could store an array if we want a history. + const newMap = new Map(map); + newMap.set(interaction.senderUserId, interaction); + return newMap; + }); +} + +export function clearInteraction(userId: string) { + receivedInteractions.update((map) => { + const newMap = new Map(map); + newMap.delete(userId); + return newMap; + }); +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index ee18b0a..f1a114d 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -3,6 +3,7 @@ import { onMount, onDestroy } from "svelte"; import { initCursorTracking, stopCursorTracking } from "../events/cursor"; import { initAppDataListener } from "../events/app-data"; + import { initInteractionListeners, stopInteractionListeners } from "../events/interaction"; import { initSceneInteractiveListener, stopSceneInteractiveListener, @@ -15,6 +16,7 @@ await initCursorTracking(); await initAppDataListener(); await initSceneInteractiveListener(); + await initInteractionListeners(); } catch (err) { console.error("Failed to initialize event listeners:", err); } @@ -23,6 +25,7 @@ onDestroy(() => { stopCursorTracking(); stopSceneInteractiveListener(); + stopInteractionListeners(); }); } diff --git a/src/routes/scene/components/DesktopPet.svelte b/src/routes/scene/components/DesktopPet.svelte index 20c7a8c..b4062a2 100644 --- a/src/routes/scene/components/DesktopPet.svelte +++ b/src/routes/scene/components/DesktopPet.svelte @@ -5,6 +5,10 @@ import { getSpriteSheetUrl } from "$lib/utils/sprite-utils"; import PetSprite from "$lib/components/PetSprite.svelte"; import onekoGif from "../../../assets/oneko/oneko.gif"; + import { + receivedInteractions, + clearInteraction, + } from "$lib/stores/interaction-store"; import PetMenu from "./PetMenu.svelte"; import type { DollDto } from "../../../types/bindings/DollDto"; import type { UserBasicDto } from "../../../types/bindings/UserBasicDto"; @@ -26,6 +30,40 @@ let spriteSheetUrl = onekoGif; let isPetMenuOpen = false; + let receivedMessage: string | undefined = undefined; + let messageTimer: number | undefined = undefined; + + // Watch for received interactions for this user + $: { + const interaction = $receivedInteractions.get(user.id); + if (interaction && interaction.content !== receivedMessage) { + console.log(`Received interaction for ${user.id}: ${interaction.content}`); + receivedMessage = interaction.content; + isPetMenuOpen = true; + + // Make scene interactive so user can see it + invoke("set_scene_interactive", { + interactive: true, + shouldClick: false, + }); + + // Clear existing timer if any + if (messageTimer) clearTimeout(messageTimer); + + // Auto-close and clear after 8 seconds + messageTimer = setTimeout(() => { + isPetMenuOpen = false; + receivedMessage = undefined; + clearInteraction(user.id); + // We probably shouldn't disable interactivity globally here as other pets might be active, + // but 'set_pet_menu_state' in backend handles the window transparency logic per pet/menu. + // However, we did explicitly call set_scene_interactive(true). + // It might be safer to let the mouse-leave or other logic handle setting it back to false, + // or just leave it as is since the user might want to interact. + // For now, focusing on the message lifecycle. + }, 8000) as unknown as number; + } + } // Watch for color changes to regenerate sprite $: updateSprite( @@ -33,10 +71,22 @@ doll?.configuration.colorScheme.outline, ); - $: (isInteractive, (isPetMenuOpen = false)); + // This reactive statement forces the menu closed whenever `isInteractive` changes. + // This conflicts with our message logic because we explicitly set interactive=true when opening the menu for a message. + // We should remove this or condition it. + // The original intent was likely to close the menu if the user moves the mouse away (interactive becomes false), + // but `isInteractive` is driven by mouse hover usually. + // When we force it via invoke("set_scene_interactive", { interactive: true }), it might not reflect back into `isInteractive` prop immediately or correctly depending on how the parent passes it. + // Actually, `isInteractive` is a prop passed from +page.svelte probably based on hover state. + // If we want the menu to stay open during the message, we should probably ignore this auto-close behavior if a message is present. + + $: if (!receivedMessage && !isInteractive) { + isPetMenuOpen = false; + } $: { if (id) { + console.log(`Setting pet menu state for ${id}: ${isPetMenuOpen}`); invoke("set_pet_menu_state", { id, open: isPetMenuOpen }); } } @@ -99,13 +149,19 @@ aria-label="Pet Menu" > {#if doll} - + {/if} {/if} - - -
- - -
+ + {#if receivedMessage} +
+
+ {receivedMessage} +
+
+ {:else if showMessageInput} +
+ +
+ + +
+
+ {:else} +
+ + +
+
+ + +
+ {/if} diff --git a/src/types/bindings/HealthResponseDto.ts b/src/types/bindings/HealthResponseDto.ts new file mode 100644 index 0000000..7a77c85 --- /dev/null +++ b/src/types/bindings/HealthResponseDto.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type HealthResponseDto = { status: string, version: string, uptimeSecs: bigint, db: string, }; diff --git a/src/types/bindings/InteractionDeliveryFailedDto.ts b/src/types/bindings/InteractionDeliveryFailedDto.ts new file mode 100644 index 0000000..e938abb --- /dev/null +++ b/src/types/bindings/InteractionDeliveryFailedDto.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type InteractionDeliveryFailedDto = { recipientUserId: string, reason: string, }; diff --git a/src/types/bindings/InteractionPayloadDto.ts b/src/types/bindings/InteractionPayloadDto.ts new file mode 100644 index 0000000..9fa8aeb --- /dev/null +++ b/src/types/bindings/InteractionPayloadDto.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type InteractionPayloadDto = { senderUserId: string, senderName: string, content: string, type: string, timestamp: string, }; diff --git a/src/types/bindings/SendInteractionDto.ts b/src/types/bindings/SendInteractionDto.ts new file mode 100644 index 0000000..f5fd5d9 --- /dev/null +++ b/src/types/bindings/SendInteractionDto.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SendInteractionDto = { recipientUserId: string, content: string, type: string, };