feat: add teleport

This commit is contained in:
Evan Carroll 2026-01-19 11:48:12 -06:00
parent 226c2e02b5
commit 32e5e42462
11 changed files with 603 additions and 16 deletions

View file

@ -20,14 +20,14 @@ use crate::components::{
#[cfg(feature = "hydrate")]
use crate::components::{
ChannelMemberInfo, ChatMessage, DEFAULT_BUBBLE_TIMEOUT_MS, FADE_DURATION_MS, HistoryEntry,
WsError, add_to_history, use_channel_websocket,
TeleportInfo, WsError, add_to_history, use_channel_websocket,
};
use crate::utils::LocalStoragePersist;
#[cfg(feature = "hydrate")]
use crate::utils::parse_bounds_dimensions;
use chattyness_db::models::{
AvatarWithPaths, ChannelMemberWithAvatar, EmotionAvailability, LooseProp, RealmRole,
RealmWithUserRole, Scene,
RealmWithUserRole, Scene, SceneSummary,
};
#[cfg(feature = "hydrate")]
use chattyness_db::ws_messages::ClientMessage;
@ -123,6 +123,15 @@ pub fn RealmPage() -> impl IntoView {
// Reconnection trigger - increment to force WebSocket reconnection
let (reconnect_trigger, set_reconnect_trigger) = signal(0u32);
// Current scene (changes when teleporting)
let (current_scene, set_current_scene) = signal(Option::<Scene>::None);
// Available scenes for teleportation (cached on load)
let (available_scenes, set_available_scenes) = signal(Vec::<SceneSummary>::new());
// Whether teleportation is allowed in this realm
let (allow_user_teleport, set_allow_user_teleport) = signal(false);
let realm_data = LocalResource::new(move || {
let slug = slug.get();
async move {
@ -324,6 +333,8 @@ pub fn RealmPage() -> impl IntoView {
// Display user-friendly error message
let msg = match error.code.as_str() {
"WHISPER_TARGET_NOT_FOUND" => error.message,
"TELEPORT_DISABLED" => error.message,
"SCENE_NOT_FOUND" => error.message,
_ => format!("Error: {}", error.message),
};
set_error_message.set(Some(msg));
@ -335,6 +346,47 @@ pub fn RealmPage() -> impl IntoView {
.forget();
});
// Callback for teleport approval - navigate to new scene
#[cfg(feature = "hydrate")]
let on_teleport_approved = Callback::new(move |info: TeleportInfo| {
let scene_id = info.scene_id;
let scene_slug = info.scene_slug;
let realm_slug = slug.get_untracked();
// Fetch the new scene data to update the canvas background
spawn_local(async move {
use gloo_net::http::Request;
let response = Request::get(&format!(
"/api/realms/{}/scenes/{}",
realm_slug, scene_slug
))
.send()
.await;
if let Ok(resp) = response {
if resp.ok() {
if let Ok(scene) = resp.json::<Scene>().await {
// Update scene dimensions from the new scene
if let Some((w, h)) = parse_bounds_dimensions(&scene.bounds_wkt) {
set_scene_dimensions.set((w as f64, h as f64));
}
// Update the current scene for the viewer
set_current_scene.set(Some(scene));
}
}
}
// Update channel_id to trigger WebSocket reconnection
set_channel_id.set(Some(scene_id));
// Clear members since we're switching scenes
set_members.set(Vec::new());
// Trigger a reconnect to ensure fresh connection
set_reconnect_trigger.update(|t| *t += 1);
});
});
#[cfg(feature = "hydrate")]
let (ws_state, ws_sender) = use_channel_websocket(
slug,
@ -348,9 +400,10 @@ pub fn RealmPage() -> impl IntoView {
on_member_fading,
Some(on_welcome),
Some(on_ws_error),
Some(on_teleport_approved),
);
// Set channel ID and scene dimensions when scene loads
// Set channel ID, current scene, and scene dimensions when entry scene loads
// Note: Currently using scene.id as the channel_id since channel_members
// uses scenes directly. Proper channel infrastructure can be added later.
#[cfg(feature = "hydrate")]
@ -360,6 +413,7 @@ pub fn RealmPage() -> impl IntoView {
return;
};
set_channel_id.set(Some(scene.id));
set_current_scene.set(Some(scene.clone()));
// Extract scene dimensions from bounds_wkt
if let Some((w, h)) = parse_bounds_dimensions(&scene.bounds_wkt) {
@ -368,6 +422,44 @@ pub fn RealmPage() -> impl IntoView {
});
}
// Fetch available scenes and realm settings when realm loads
#[cfg(feature = "hydrate")]
{
Effect::new(move |_| {
let Some(realm_with_role) = realm_data.get().flatten() else {
return;
};
// Set allow_user_teleport from realm settings
set_allow_user_teleport.set(realm_with_role.realm.allow_user_teleport);
// Fetch scenes list for teleport command
let current_slug = slug.get();
if current_slug.is_empty() {
return;
}
spawn_local(async move {
use gloo_net::http::Request;
let response = Request::get(&format!("/api/realms/{}/scenes", current_slug))
.send()
.await;
if let Ok(resp) = response {
if resp.ok() {
if let Ok(scenes) = resp.json::<Vec<SceneSummary>>().await {
// Filter out hidden scenes
let visible_scenes: Vec<SceneSummary> = scenes
.into_iter()
.filter(|s| !s.is_hidden)
.collect();
set_available_scenes.set(visible_scenes);
}
}
}
});
});
}
// Cleanup expired speech bubbles and fading members every second
#[cfg(feature = "hydrate")]
{
@ -729,11 +821,16 @@ pub fn RealmPage() -> impl IntoView {
let realm_slug_for_viewer = realm_slug_val.clone();
#[cfg(feature = "hydrate")]
let ws_sender_clone = ws_sender.clone();
// Read current_scene in reactive context (before .map())
// so changes trigger re-render
let current_scene_val = current_scene.get();
entry_scene
.get()
.map(|maybe_scene| {
match maybe_scene {
Some(scene) => {
Some(entry_scene_data) => {
// Use current_scene if set (after teleport), otherwise use entry scene
let display_scene = current_scene_val.clone().unwrap_or_else(|| entry_scene_data.clone());
let members_signal = Signal::derive(move || members.get());
let emotion_avail_signal = Signal::derive(move || emotion_availability.get());
let skin_path_signal = Signal::derive(move || skin_preview_path.get());
@ -755,10 +852,22 @@ pub fn RealmPage() -> impl IntoView {
let on_whisper_request_cb = Callback::new(move |target: String| {
set_whisper_target.set(Some(target));
});
let scenes_signal = Signal::derive(move || available_scenes.get());
let teleport_enabled_signal = Signal::derive(move || allow_user_teleport.get());
#[cfg(feature = "hydrate")]
let ws_for_teleport = ws_sender_clone.clone();
let on_teleport_cb = Callback::new(move |scene_id: Uuid| {
#[cfg(feature = "hydrate")]
ws_for_teleport.with_value(|sender| {
if let Some(send_fn) = sender {
send_fn(ClientMessage::Teleport { scene_id });
}
});
});
view! {
<div class="relative w-full">
<RealmSceneViewer
scene=scene
scene=display_scene
realm_slug=realm_slug_for_viewer.clone()
members=members_signal
active_bubbles=active_bubbles_signal
@ -789,6 +898,9 @@ pub fn RealmPage() -> impl IntoView {
on_open_settings=on_open_settings_cb
on_open_inventory=on_open_inventory_cb
whisper_target=whisper_target_signal
scenes=scenes_signal
allow_user_teleport=teleport_enabled_signal
on_teleport=on_teleport_cb
/>
</div>
</div>