ui: inform guests of restricted interfaces

This commit is contained in:
Evan Carroll 2026-01-19 00:38:37 -06:00
parent 1f922f8221
commit 39750c1d82
8 changed files with 137 additions and 49 deletions

View file

@ -1829,6 +1829,9 @@ pub struct ChannelMemberInfo {
/// Current emotion slot (0-9) /// Current emotion slot (0-9)
pub current_emotion: i16, pub current_emotion: i16,
pub joined_at: DateTime<Utc>, pub joined_at: DateTime<Utc>,
/// Whether this user is a guest (has the 'guest' tag)
#[serde(default)]
pub is_guest: bool,
} }
/// Request to update position in a channel. /// Request to update position in a channel.

View file

@ -176,7 +176,8 @@ pub async fn get_channel_members<'e>(
cm.is_moving, cm.is_moving,
cm.is_afk, cm.is_afk,
COALESCE(aa.current_emotion, 0::smallint) as current_emotion, COALESCE(aa.current_emotion, 0::smallint) as current_emotion,
cm.joined_at cm.joined_at,
COALESCE('guest' = ANY(u.tags), false) as is_guest
FROM scene.instance_members cm FROM scene.instance_members cm
LEFT JOIN auth.users u ON cm.user_id = u.id LEFT JOIN auth.users u ON cm.user_id = u.id
LEFT JOIN auth.guest_sessions gs ON cm.guest_session_id = gs.id LEFT JOIN auth.guest_sessions gs ON cm.guest_session_id = gs.id
@ -214,7 +215,8 @@ pub async fn get_channel_member<'e>(
cm.is_moving, cm.is_moving,
cm.is_afk, cm.is_afk,
COALESCE(aa.current_emotion, 0::smallint) as current_emotion, COALESCE(aa.current_emotion, 0::smallint) as current_emotion,
cm.joined_at cm.joined_at,
COALESCE('guest' = ANY(u.tags), false) as is_guest
FROM scene.instance_members cm FROM scene.instance_members cm
LEFT JOIN auth.users u ON cm.user_id = u.id LEFT JOIN auth.users u ON cm.user_id = u.id
LEFT JOIN auth.active_avatars aa ON cm.user_id = aa.user_id AND aa.realm_id = $3 LEFT JOIN auth.active_avatars aa ON cm.user_id = aa.user_id AND aa.realm_id = $3

View file

@ -12,6 +12,7 @@ use chattyness_db::models::{AvatarWithPaths, InventoryItem};
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
use chattyness_db::ws_messages::ClientMessage; use chattyness_db::ws_messages::ClientMessage;
use super::modals::GuestLockedOverlay;
use super::ws_client::WsSenderStorage; use super::ws_client::WsSenderStorage;
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
use crate::utils::normalize_asset_path; use crate::utils::normalize_asset_path;
@ -216,6 +217,7 @@ fn RenderedPreview(#[prop(into)] avatar: Signal<Option<AvatarWithPaths>>) -> imp
/// - `realm_slug`: Current realm slug for API calls /// - `realm_slug`: Current realm slug for API calls
/// - `on_avatar_update`: Callback when avatar is updated /// - `on_avatar_update`: Callback when avatar is updated
/// - `ws_sender`: WebSocket sender for broadcasting avatar changes /// - `ws_sender`: WebSocket sender for broadcasting avatar changes
/// - `is_guest`: Whether the current user is a guest (shows locked overlay)
#[component] #[component]
pub fn AvatarEditorPopup( pub fn AvatarEditorPopup(
#[prop(into)] open: Signal<bool>, #[prop(into)] open: Signal<bool>,
@ -224,7 +226,11 @@ pub fn AvatarEditorPopup(
#[prop(into)] realm_slug: Signal<String>, #[prop(into)] realm_slug: Signal<String>,
on_avatar_update: Callback<AvatarWithPaths>, on_avatar_update: Callback<AvatarWithPaths>,
ws_sender: WsSenderStorage, ws_sender: WsSenderStorage,
/// Whether the current user is a guest. Guests see a locked overlay.
#[prop(optional, into)]
is_guest: Option<Signal<bool>>,
) -> impl IntoView { ) -> impl IntoView {
let is_guest = is_guest.unwrap_or_else(|| Signal::derive(|| false));
// Tab state // Tab state
let (active_tab, set_active_tab) = signal(EditorTab::BaseLayers); let (active_tab, set_active_tab) = signal(EditorTab::BaseLayers);
@ -798,6 +804,11 @@ pub fn AvatarEditorPopup(
</div> </div>
</div> </div>
</div> </div>
// Guest locked overlay
<Show when=move || is_guest.get()>
<GuestLockedOverlay />
</Show>
</div> </div>
// Context menu // Context menu

View file

@ -8,7 +8,7 @@ use chattyness_db::models::{InventoryItem, PublicProp};
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
use chattyness_db::ws_messages::ClientMessage; use chattyness_db::ws_messages::ClientMessage;
use super::modals::Modal; use super::modals::{GuestLockedOverlay, Modal};
use super::tabs::{Tab, TabBar}; use super::tabs::{Tab, TabBar};
use super::ws_client::WsSender; use super::ws_client::WsSender;
@ -24,13 +24,18 @@ use super::ws_client::WsSender;
/// - `on_close`: Callback when popup should close /// - `on_close`: Callback when popup should close
/// - `ws_sender`: WebSocket sender for dropping props /// - `ws_sender`: WebSocket sender for dropping props
/// - `realm_slug`: Current realm slug for fetching realm props /// - `realm_slug`: Current realm slug for fetching realm props
/// - `is_guest`: Whether the current user is a guest (shows locked overlay)
#[component] #[component]
pub fn InventoryPopup( pub fn InventoryPopup(
#[prop(into)] open: Signal<bool>, #[prop(into)] open: Signal<bool>,
on_close: Callback<()>, on_close: Callback<()>,
ws_sender: StoredValue<Option<WsSender>, LocalStorage>, ws_sender: StoredValue<Option<WsSender>, LocalStorage>,
#[prop(into)] realm_slug: Signal<String>, #[prop(into)] realm_slug: Signal<String>,
/// Whether the current user is a guest. Guests see a locked overlay.
#[prop(optional, into)]
is_guest: Option<Signal<bool>>,
) -> impl IntoView { ) -> impl IntoView {
let is_guest = is_guest.unwrap_or_else(|| Signal::derive(|| false));
// Tab state // Tab state
let (active_tab, set_active_tab) = signal("my_inventory"); let (active_tab, set_active_tab) = signal("my_inventory");
@ -238,52 +243,59 @@ pub fn InventoryPopup(
max_width="max-w-2xl" max_width="max-w-2xl"
class="max-h-[80vh] flex flex-col" class="max-h-[80vh] flex flex-col"
> >
// Tab bar <div class="relative flex-1 flex flex-col">
<TabBar // Tab bar
tabs=vec![ <TabBar
Tab::new("my_inventory", "My Inventory"), tabs=vec![
Tab::new("server", "Server"), Tab::new("my_inventory", "My Inventory"),
Tab::new("realm", "Realm"), Tab::new("server", "Server"),
] Tab::new("realm", "Realm"),
active=Signal::derive(move || active_tab.get()) ]
on_select=Callback::new(move |id| set_active_tab.set(id)) active=Signal::derive(move || active_tab.get())
/> on_select=Callback::new(move |id| set_active_tab.set(id))
/>
// Tab content // Tab content
<div class="flex-1 overflow-y-auto min-h-[300px]"> <div class="flex-1 overflow-y-auto min-h-[300px]">
// My Inventory tab // My Inventory tab
<Show when=move || active_tab.get() == "my_inventory"> <Show when=move || active_tab.get() == "my_inventory">
<MyInventoryTab <MyInventoryTab
items=items items=items
loading=loading loading=loading
error=error error=error
selected_item=selected_item selected_item=selected_item
set_selected_item=set_selected_item set_selected_item=set_selected_item
dropping=dropping dropping=dropping
on_drop=Callback::new(handle_drop) on_drop=Callback::new(handle_drop)
/> />
</Show> </Show>
// Server tab // Server tab
<Show when=move || active_tab.get() == "server"> <Show when=move || active_tab.get() == "server">
<PublicPropsTab <PublicPropsTab
props=server_props props=server_props
loading=server_loading loading=server_loading
error=server_error error=server_error
tab_name="Server" tab_name="Server"
empty_message="No public server props available" empty_message="No public server props available"
/> />
</Show> </Show>
// Realm tab // Realm tab
<Show when=move || active_tab.get() == "realm"> <Show when=move || active_tab.get() == "realm">
<PublicPropsTab <PublicPropsTab
props=realm_props props=realm_props
loading=realm_loading loading=realm_loading
error=realm_error error=realm_error
tab_name="Realm" tab_name="Realm"
empty_message="No public realm props available" empty_message="No public realm props available"
/> />
</Show>
</div>
// Guest locked overlay
<Show when=move || is_guest.get()>
<GuestLockedOverlay />
</Show> </Show>
</div> </div>
</Modal> </Modal>

View file

@ -314,3 +314,45 @@ pub fn ConfirmModal(
</Show> </Show>
} }
} }
// ============================================================================
// Guest Locked Overlay
// ============================================================================
/// Overlay displayed when a feature is restricted to registered users.
///
/// Shows a semi-transparent backdrop with a padlock icon and diagonal
/// "Registered Users" text. Designed to be placed inside a modal container
/// with `position: relative`.
///
/// # Example
///
/// ```ignore
/// <div class="relative">
/// // Modal content here
/// <GuestLockedOverlay />
/// </div>
/// ```
#[component]
pub fn GuestLockedOverlay() -> impl IntoView {
view! {
<div
class="absolute inset-0 z-50 flex items-center justify-center bg-gray-900/80 backdrop-blur-sm rounded-lg"
role="alert"
aria-label="This feature is restricted to registered users"
>
<div class="flex flex-col items-center gap-4 transform -rotate-12">
<img
src="/icons/padlock.svg"
alt=""
class="w-16 h-16 text-gray-400"
style="filter: invert(60%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(90%) contrast(90%);"
aria-hidden="true"
/>
<span class="text-2xl font-bold text-gray-400 tracking-wider uppercase whitespace-nowrap">
"Registered Users"
</span>
</div>
</div>
}
}

View file

@ -56,10 +56,14 @@ pub fn RealmSceneViewer(
/// Current user's guest_session_id (for context menu filtering). /// Current user's guest_session_id (for context menu filtering).
#[prop(optional, into)] #[prop(optional, into)]
current_guest_session_id: Option<Signal<Option<Uuid>>>, current_guest_session_id: Option<Signal<Option<Uuid>>>,
/// Whether the current user is a guest (guests cannot use context menu).
#[prop(optional, into)]
is_guest: Option<Signal<bool>>,
/// Callback when whisper is requested on a member. /// Callback when whisper is requested on a member.
#[prop(optional, into)] #[prop(optional, into)]
on_whisper_request: Option<Callback<String>>, on_whisper_request: Option<Callback<String>>,
) -> impl IntoView { ) -> impl IntoView {
let is_guest = is_guest.unwrap_or_else(|| Signal::derive(|| false));
// Use default settings if none provided // Use default settings if none provided
let settings = settings.unwrap_or_else(|| Signal::derive(ViewerSettings::default)); let settings = settings.unwrap_or_else(|| Signal::derive(ViewerSettings::default));
let dimensions = parse_bounds_dimensions(&scene.bounds_wkt); let dimensions = parse_bounds_dimensions(&scene.bounds_wkt);
@ -183,14 +187,19 @@ pub fn RealmSceneViewer(
move |ev: web_sys::MouseEvent| { move |ev: web_sys::MouseEvent| {
use wasm_bindgen::JsCast; use wasm_bindgen::JsCast;
// Get click position // Guests cannot message other users - don't show context menu
let client_x = ev.client_x() as f64; if is_guest.get() {
let client_y = ev.client_y() as f64; return;
}
// Get current user identity for filtering // Get current user identity for filtering
let my_user_id = current_user_id.map(|s| s.get()).flatten(); let my_user_id = current_user_id.map(|s| s.get()).flatten();
let my_guest_session_id = current_guest_session_id.map(|s| s.get()).flatten(); let my_guest_session_id = current_guest_session_id.map(|s| s.get()).flatten();
// Get click position
let client_x = ev.client_x() as f64;
let client_y = ev.client_y() as f64;
// Query all avatar canvases and check for hit // Query all avatar canvases and check for hit
let document = web_sys::window().unwrap().document().unwrap(); let document = web_sys::window().unwrap().document().unwrap();

View file

@ -60,6 +60,8 @@ pub struct ChannelMemberInfo {
pub guest_session_id: Option<uuid::Uuid>, pub guest_session_id: Option<uuid::Uuid>,
/// The user's display name. /// The user's display name.
pub display_name: String, pub display_name: String,
/// Whether this user is a guest (has the 'guest' tag).
pub is_guest: bool,
} }
/// WebSocket error info for UI display. /// WebSocket error info for UI display.
@ -240,6 +242,7 @@ pub fn use_channel_websocket(
user_id: member.user_id, user_id: member.user_id,
guest_session_id: member.guest_session_id, guest_session_id: member.guest_session_id,
display_name: member.display_name.clone(), display_name: member.display_name.clone(),
is_guest: member.is_guest,
}; };
callback.run(info); callback.run(info);
} }

View file

@ -98,6 +98,8 @@ pub fn RealmPage() -> impl IntoView {
// Current user identity (received from WebSocket Welcome message) // Current user identity (received from WebSocket Welcome message)
let (current_user_id, set_current_user_id) = signal(Option::<Uuid>::None); let (current_user_id, set_current_user_id) = signal(Option::<Uuid>::None);
let (current_guest_session_id, set_current_guest_session_id) = signal(Option::<Uuid>::None); let (current_guest_session_id, set_current_guest_session_id) = signal(Option::<Uuid>::None);
// Whether the current user is a guest (has the 'guest' tag)
let (is_guest, set_is_guest) = signal(false);
// Whisper target - when set, triggers pre-fill in ChatInput // Whisper target - when set, triggers pre-fill in ChatInput
let (whisper_target, set_whisper_target) = signal(Option::<String>::None); let (whisper_target, set_whisper_target) = signal(Option::<String>::None);
@ -313,6 +315,7 @@ pub fn RealmPage() -> impl IntoView {
set_current_user_id.set(info.user_id); set_current_user_id.set(info.user_id);
set_current_guest_session_id.set(info.guest_session_id); set_current_guest_session_id.set(info.guest_session_id);
set_current_display_name.set(info.display_name.clone()); set_current_display_name.set(info.display_name.clone());
set_is_guest.set(info.is_guest);
}); });
// Callback for WebSocket errors (whisper failures, etc.) // Callback for WebSocket errors (whisper failures, etc.)
@ -772,6 +775,7 @@ pub fn RealmPage() -> impl IntoView {
fading_members=Signal::derive(move || fading_members.get()) fading_members=Signal::derive(move || fading_members.get())
current_user_id=Signal::derive(move || current_user_id.get()) current_user_id=Signal::derive(move || current_user_id.get())
current_guest_session_id=Signal::derive(move || current_guest_session_id.get()) current_guest_session_id=Signal::derive(move || current_guest_session_id.get())
is_guest=Signal::derive(move || is_guest.get())
on_whisper_request=on_whisper_request_cb on_whisper_request=on_whisper_request_cb
/> />
<div class="absolute bottom-0 left-0 right-0 z-10 pb-4 px-4 pointer-events-none"> <div class="absolute bottom-0 left-0 right-0 z-10 pb-4 px-4 pointer-events-none">
@ -823,6 +827,7 @@ pub fn RealmPage() -> impl IntoView {
}) })
ws_sender=ws_sender_for_inv ws_sender=ws_sender_for_inv
realm_slug=Signal::derive(move || slug.get()) realm_slug=Signal::derive(move || slug.get())
is_guest=Signal::derive(move || is_guest.get())
/> />
} }
} }
@ -867,6 +872,7 @@ pub fn RealmPage() -> impl IntoView {
set_skin_preview_path.set(updated.skin_layer[4].clone()); set_skin_preview_path.set(updated.skin_layer[4].clone());
}) })
ws_sender=ws_sender_for_avatar ws_sender=ws_sender_for_avatar
is_guest=Signal::derive(move || is_guest.get())
/> />
} }
} }