diff --git a/crates/chattyness-db/src/models.rs b/crates/chattyness-db/src/models.rs index 9c4ac98..5d40fea 100644 --- a/crates/chattyness-db/src/models.rs +++ b/crates/chattyness-db/src/models.rs @@ -1829,6 +1829,9 @@ pub struct ChannelMemberInfo { /// Current emotion slot (0-9) pub current_emotion: i16, pub joined_at: DateTime, + /// Whether this user is a guest (has the 'guest' tag) + #[serde(default)] + pub is_guest: bool, } /// Request to update position in a channel. diff --git a/crates/chattyness-db/src/queries/channel_members.rs b/crates/chattyness-db/src/queries/channel_members.rs index 74252d6..230a81e 100644 --- a/crates/chattyness-db/src/queries/channel_members.rs +++ b/crates/chattyness-db/src/queries/channel_members.rs @@ -176,7 +176,8 @@ pub async fn get_channel_members<'e>( cm.is_moving, cm.is_afk, 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 LEFT JOIN auth.users u ON cm.user_id = u.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_afk, 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 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 diff --git a/crates/chattyness-user-ui/src/components/avatar_editor.rs b/crates/chattyness-user-ui/src/components/avatar_editor.rs index b4bb3b6..b1d7a00 100644 --- a/crates/chattyness-user-ui/src/components/avatar_editor.rs +++ b/crates/chattyness-user-ui/src/components/avatar_editor.rs @@ -12,6 +12,7 @@ use chattyness_db::models::{AvatarWithPaths, InventoryItem}; #[cfg(feature = "hydrate")] use chattyness_db::ws_messages::ClientMessage; +use super::modals::GuestLockedOverlay; use super::ws_client::WsSenderStorage; #[cfg(feature = "hydrate")] use crate::utils::normalize_asset_path; @@ -216,6 +217,7 @@ fn RenderedPreview(#[prop(into)] avatar: Signal>) -> imp /// - `realm_slug`: Current realm slug for API calls /// - `on_avatar_update`: Callback when avatar is updated /// - `ws_sender`: WebSocket sender for broadcasting avatar changes +/// - `is_guest`: Whether the current user is a guest (shows locked overlay) #[component] pub fn AvatarEditorPopup( #[prop(into)] open: Signal, @@ -224,7 +226,11 @@ pub fn AvatarEditorPopup( #[prop(into)] realm_slug: Signal, on_avatar_update: Callback, ws_sender: WsSenderStorage, + /// Whether the current user is a guest. Guests see a locked overlay. + #[prop(optional, into)] + is_guest: Option>, ) -> impl IntoView { + let is_guest = is_guest.unwrap_or_else(|| Signal::derive(|| false)); // Tab state let (active_tab, set_active_tab) = signal(EditorTab::BaseLayers); @@ -798,6 +804,11 @@ pub fn AvatarEditorPopup( + + // Guest locked overlay + + + // Context menu diff --git a/crates/chattyness-user-ui/src/components/inventory.rs b/crates/chattyness-user-ui/src/components/inventory.rs index c9eaf67..add9e96 100644 --- a/crates/chattyness-user-ui/src/components/inventory.rs +++ b/crates/chattyness-user-ui/src/components/inventory.rs @@ -8,7 +8,7 @@ use chattyness_db::models::{InventoryItem, PublicProp}; #[cfg(feature = "hydrate")] use chattyness_db::ws_messages::ClientMessage; -use super::modals::Modal; +use super::modals::{GuestLockedOverlay, Modal}; use super::tabs::{Tab, TabBar}; use super::ws_client::WsSender; @@ -24,13 +24,18 @@ use super::ws_client::WsSender; /// - `on_close`: Callback when popup should close /// - `ws_sender`: WebSocket sender for dropping props /// - `realm_slug`: Current realm slug for fetching realm props +/// - `is_guest`: Whether the current user is a guest (shows locked overlay) #[component] pub fn InventoryPopup( #[prop(into)] open: Signal, on_close: Callback<()>, ws_sender: StoredValue, LocalStorage>, #[prop(into)] realm_slug: Signal, + /// Whether the current user is a guest. Guests see a locked overlay. + #[prop(optional, into)] + is_guest: Option>, ) -> impl IntoView { + let is_guest = is_guest.unwrap_or_else(|| Signal::derive(|| false)); // Tab state let (active_tab, set_active_tab) = signal("my_inventory"); @@ -238,52 +243,59 @@ pub fn InventoryPopup( max_width="max-w-2xl" class="max-h-[80vh] flex flex-col" > - // Tab bar - +
+ // Tab bar + - // Tab content -
- // My Inventory tab - - - + // Tab content +
+ // My Inventory tab + + + - // Server tab - - - + // Server tab + + + - // Realm tab - - + // Realm tab + + + +
+ + // Guest locked overlay + +
diff --git a/crates/chattyness-user-ui/src/components/modals.rs b/crates/chattyness-user-ui/src/components/modals.rs index 5baa588..3bcde67 100644 --- a/crates/chattyness-user-ui/src/components/modals.rs +++ b/crates/chattyness-user-ui/src/components/modals.rs @@ -314,3 +314,45 @@ pub fn ConfirmModal( } } + +// ============================================================================ +// 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 +///
+/// // Modal content here +/// +///
+/// ``` +#[component] +pub fn GuestLockedOverlay() -> impl IntoView { + view! { + + } +} diff --git a/crates/chattyness-user-ui/src/components/scene_viewer.rs b/crates/chattyness-user-ui/src/components/scene_viewer.rs index 664cac7..23dff40 100644 --- a/crates/chattyness-user-ui/src/components/scene_viewer.rs +++ b/crates/chattyness-user-ui/src/components/scene_viewer.rs @@ -56,10 +56,14 @@ pub fn RealmSceneViewer( /// Current user's guest_session_id (for context menu filtering). #[prop(optional, into)] current_guest_session_id: Option>>, + /// Whether the current user is a guest (guests cannot use context menu). + #[prop(optional, into)] + is_guest: Option>, /// Callback when whisper is requested on a member. #[prop(optional, into)] on_whisper_request: Option>, ) -> impl IntoView { + let is_guest = is_guest.unwrap_or_else(|| Signal::derive(|| false)); // Use default settings if none provided let settings = settings.unwrap_or_else(|| Signal::derive(ViewerSettings::default)); let dimensions = parse_bounds_dimensions(&scene.bounds_wkt); @@ -183,14 +187,19 @@ pub fn RealmSceneViewer( move |ev: web_sys::MouseEvent| { use wasm_bindgen::JsCast; - // Get click position - let client_x = ev.client_x() as f64; - let client_y = ev.client_y() as f64; + // Guests cannot message other users - don't show context menu + if is_guest.get() { + return; + } // Get current user identity for filtering 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(); + // 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 let document = web_sys::window().unwrap().document().unwrap(); diff --git a/crates/chattyness-user-ui/src/components/ws_client.rs b/crates/chattyness-user-ui/src/components/ws_client.rs index a4e6952..ef026fa 100644 --- a/crates/chattyness-user-ui/src/components/ws_client.rs +++ b/crates/chattyness-user-ui/src/components/ws_client.rs @@ -60,6 +60,8 @@ pub struct ChannelMemberInfo { pub guest_session_id: Option, /// The user's display name. pub display_name: String, + /// Whether this user is a guest (has the 'guest' tag). + pub is_guest: bool, } /// WebSocket error info for UI display. @@ -240,6 +242,7 @@ pub fn use_channel_websocket( user_id: member.user_id, guest_session_id: member.guest_session_id, display_name: member.display_name.clone(), + is_guest: member.is_guest, }; callback.run(info); } diff --git a/crates/chattyness-user-ui/src/pages/realm.rs b/crates/chattyness-user-ui/src/pages/realm.rs index 9a28abe..882847f 100644 --- a/crates/chattyness-user-ui/src/pages/realm.rs +++ b/crates/chattyness-user-ui/src/pages/realm.rs @@ -98,6 +98,8 @@ pub fn RealmPage() -> impl IntoView { // Current user identity (received from WebSocket Welcome message) let (current_user_id, set_current_user_id) = signal(Option::::None); let (current_guest_session_id, set_current_guest_session_id) = signal(Option::::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 let (whisper_target, set_whisper_target) = signal(Option::::None); @@ -313,6 +315,7 @@ pub fn RealmPage() -> impl IntoView { set_current_user_id.set(info.user_id); set_current_guest_session_id.set(info.guest_session_id); set_current_display_name.set(info.display_name.clone()); + set_is_guest.set(info.is_guest); }); // Callback for WebSocket errors (whisper failures, etc.) @@ -772,6 +775,7 @@ pub fn RealmPage() -> impl IntoView { fading_members=Signal::derive(move || fading_members.get()) current_user_id=Signal::derive(move || current_user_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 />
@@ -823,6 +827,7 @@ pub fn RealmPage() -> impl IntoView { }) ws_sender=ws_sender_for_inv 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()); }) ws_sender=ws_sender_for_avatar + is_guest=Signal::derive(move || is_guest.get()) /> } }