feat: simplify notifications/logs
This commit is contained in:
parent
8c2e5d4f61
commit
39b5ac3f1d
7 changed files with 300 additions and 334 deletions
|
|
@ -16,9 +16,8 @@ pub mod keybindings_popup;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub mod log_popup;
|
pub mod log_popup;
|
||||||
pub mod modals;
|
pub mod modals;
|
||||||
pub mod notification_history;
|
|
||||||
pub mod register_modal;
|
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
|
pub mod register_modal;
|
||||||
pub mod scene_list_popup;
|
pub mod scene_list_popup;
|
||||||
pub mod scene_viewer;
|
pub mod scene_viewer;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
|
@ -43,7 +42,6 @@ pub use keybindings_popup::*;
|
||||||
pub use layout::*;
|
pub use layout::*;
|
||||||
pub use log_popup::*;
|
pub use log_popup::*;
|
||||||
pub use modals::*;
|
pub use modals::*;
|
||||||
pub use notification_history::*;
|
|
||||||
pub use notifications::*;
|
pub use notifications::*;
|
||||||
pub use register_modal::*;
|
pub use register_modal::*;
|
||||||
pub use reconnection_overlay::*;
|
pub use reconnection_overlay::*;
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,9 @@ pub struct ChatMessage {
|
||||||
/// For whispers: true = same scene (show as bubble), false = cross-scene (show as notification).
|
/// For whispers: true = same scene (show as bubble), false = cross-scene (show as notification).
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub is_same_scene: bool,
|
pub is_same_scene: bool,
|
||||||
|
/// Whether this is a system/admin message (teleport, summon, mod commands).
|
||||||
|
#[serde(default)]
|
||||||
|
pub is_system: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default function for serde that returns true.
|
/// Default function for serde that returns true.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
//! Message log popup component.
|
//! Message log popup component.
|
||||||
//!
|
//!
|
||||||
//! Displays a filterable chronological log of received messages.
|
//! Displays a filterable chronological log of received messages.
|
||||||
|
//! Includes persistent whisper history via LocalStorage.
|
||||||
|
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::reactive::owner::LocalStorage;
|
use leptos::reactive::owner::LocalStorage;
|
||||||
|
|
@ -8,6 +9,77 @@ use leptos::reactive::owner::LocalStorage;
|
||||||
use super::chat_types::{ChatMessage, MessageLog};
|
use super::chat_types::{ChatMessage, MessageLog};
|
||||||
use super::modals::Modal;
|
use super::modals::Modal;
|
||||||
|
|
||||||
|
/// LocalStorage key for persistent whisper history.
|
||||||
|
const WHISPER_STORAGE_KEY: &str = "chattyness_whisper_history";
|
||||||
|
/// Maximum number of whispers to keep in persistent history.
|
||||||
|
const MAX_WHISPER_HISTORY: usize = 100;
|
||||||
|
|
||||||
|
/// Load whisper history from LocalStorage.
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
pub fn load_whisper_history() -> Vec<ChatMessage> {
|
||||||
|
let window = match web_sys::window() {
|
||||||
|
Some(w) => w,
|
||||||
|
None => return Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let storage = match window.local_storage() {
|
||||||
|
Ok(Some(s)) => s,
|
||||||
|
_ => return Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = match storage.get_item(WHISPER_STORAGE_KEY) {
|
||||||
|
Ok(Some(j)) => j,
|
||||||
|
_ => return Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
serde_json::from_str(&json).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save whisper history to LocalStorage.
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
pub fn save_whisper_history(whispers: &[ChatMessage]) {
|
||||||
|
let window = match web_sys::window() {
|
||||||
|
Some(w) => w,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let storage = match window.local_storage() {
|
||||||
|
Ok(Some(s)) => s,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Ok(json) = serde_json::to_string(whispers) {
|
||||||
|
let _ = storage.set_item(WHISPER_STORAGE_KEY, &json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a whisper to persistent history, maintaining max size.
|
||||||
|
/// Deduplicates by message_id.
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
pub fn add_whisper_to_history(msg: ChatMessage) {
|
||||||
|
let mut history = load_whisper_history();
|
||||||
|
// Avoid duplicates by message_id
|
||||||
|
if !history.iter().any(|m| m.message_id == msg.message_id) {
|
||||||
|
history.insert(0, msg);
|
||||||
|
if history.len() > MAX_WHISPER_HISTORY {
|
||||||
|
history.truncate(MAX_WHISPER_HISTORY);
|
||||||
|
}
|
||||||
|
save_whisper_history(&history);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SSR stubs for persistence functions.
|
||||||
|
#[cfg(not(feature = "hydrate"))]
|
||||||
|
pub fn load_whisper_history() -> Vec<ChatMessage> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "hydrate"))]
|
||||||
|
pub fn save_whisper_history(_whispers: &[ChatMessage]) {}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "hydrate"))]
|
||||||
|
pub fn add_whisper_to_history(_msg: ChatMessage) {}
|
||||||
|
|
||||||
/// Filter mode for message log display.
|
/// Filter mode for message log display.
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||||
pub enum LogFilter {
|
pub enum LogFilter {
|
||||||
|
|
@ -18,16 +90,23 @@ pub enum LogFilter {
|
||||||
Chat,
|
Chat,
|
||||||
/// Show only whispers.
|
/// Show only whispers.
|
||||||
Whispers,
|
Whispers,
|
||||||
|
/// Show only system/admin messages (teleports, summons, mod commands).
|
||||||
|
System,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Message log popup component.
|
/// Message log popup component.
|
||||||
///
|
///
|
||||||
/// Displays a filterable list of messages from the session.
|
/// Displays a filterable list of messages from the session,
|
||||||
|
/// with persistent whisper history from LocalStorage.
|
||||||
#[component]
|
#[component]
|
||||||
pub fn LogPopup(
|
pub fn LogPopup(
|
||||||
#[prop(into)] open: Signal<bool>,
|
#[prop(into)] open: Signal<bool>,
|
||||||
message_log: StoredValue<MessageLog, LocalStorage>,
|
message_log: StoredValue<MessageLog, LocalStorage>,
|
||||||
on_close: Callback<()>,
|
on_close: Callback<()>,
|
||||||
|
/// Callback when user wants to reply to a whisper.
|
||||||
|
on_reply: Callback<String>,
|
||||||
|
/// Callback when user wants to see conversation context.
|
||||||
|
on_context: Callback<String>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let (filter, set_filter) = signal(LogFilter::All);
|
let (filter, set_filter) = signal(LogFilter::All);
|
||||||
|
|
||||||
|
|
@ -38,17 +117,46 @@ pub fn LogPopup(
|
||||||
// Reading open ensures we re-fetch messages when modal opens
|
// Reading open ensures we re-fetch messages when modal opens
|
||||||
let _ = open.get();
|
let _ = open.get();
|
||||||
let current_filter = filter.get();
|
let current_filter = filter.get();
|
||||||
message_log.with_value(|log| {
|
|
||||||
log.all_messages()
|
match current_filter {
|
||||||
.iter()
|
LogFilter::Whispers => {
|
||||||
.filter(|msg| match current_filter {
|
// For whispers, merge session messages with persistent history
|
||||||
LogFilter::All => true,
|
let mut session_whispers: Vec<ChatMessage> = message_log.with_value(|log| {
|
||||||
LogFilter::Chat => !msg.is_whisper,
|
log.all_messages()
|
||||||
LogFilter::Whispers => msg.is_whisper,
|
.iter()
|
||||||
|
.filter(|msg| msg.is_whisper)
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load persistent whispers and merge (avoiding duplicates by message_id)
|
||||||
|
let persistent = load_whisper_history();
|
||||||
|
for msg in persistent {
|
||||||
|
if !session_whispers.iter().any(|m| m.message_id == msg.message_id) {
|
||||||
|
session_whispers.push(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by timestamp (oldest first for display)
|
||||||
|
session_whispers.sort_by_key(|m| m.timestamp);
|
||||||
|
session_whispers
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// For All, Chat, or System, use session messages only
|
||||||
|
message_log.with_value(|log| {
|
||||||
|
log.all_messages()
|
||||||
|
.iter()
|
||||||
|
.filter(|msg| match current_filter {
|
||||||
|
LogFilter::All => true,
|
||||||
|
LogFilter::Chat => !msg.is_whisper && !msg.is_system,
|
||||||
|
LogFilter::System => msg.is_system,
|
||||||
|
LogFilter::Whispers => unreachable!(),
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
})
|
})
|
||||||
.cloned()
|
}
|
||||||
.collect::<Vec<_>>()
|
}
|
||||||
})
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto-scroll to bottom when modal opens
|
// Auto-scroll to bottom when modal opens
|
||||||
|
|
@ -111,6 +219,12 @@ pub fn LogPopup(
|
||||||
>
|
>
|
||||||
"Whispers"
|
"Whispers"
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class=move || tab_class(filter.get() == LogFilter::System)
|
||||||
|
on:click=move |_| set_filter.set(LogFilter::System)
|
||||||
|
>
|
||||||
|
"System"
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
// Message list
|
// Message list
|
||||||
|
|
@ -127,52 +241,114 @@ pub fn LogPopup(
|
||||||
<For
|
<For
|
||||||
each=move || filtered_messages()
|
each=move || filtered_messages()
|
||||||
key=|msg| msg.message_id
|
key=|msg| msg.message_id
|
||||||
children=move |msg: ChatMessage| {
|
children={
|
||||||
let is_whisper = msg.is_whisper;
|
let on_reply = on_reply.clone();
|
||||||
let display_name = msg.display_name.clone();
|
let on_context = on_context.clone();
|
||||||
let content = msg.content.clone();
|
let on_close = on_close.clone();
|
||||||
let timestamp = msg.timestamp;
|
move |msg: ChatMessage| {
|
||||||
|
let is_whisper = msg.is_whisper;
|
||||||
|
let is_system = msg.is_system;
|
||||||
|
let display_name = msg.display_name.clone();
|
||||||
|
let display_name_for_reply = display_name.clone();
|
||||||
|
let display_name_for_context = display_name.clone();
|
||||||
|
let content = msg.content.clone();
|
||||||
|
let timestamp = msg.timestamp;
|
||||||
|
|
||||||
view! {
|
let on_reply = on_reply.clone();
|
||||||
<li class=move || {
|
let on_context = on_context.clone();
|
||||||
if is_whisper {
|
let on_close = on_close.clone();
|
||||||
"py-1 px-2 rounded bg-purple-900/30 border-l-2 border-purple-500"
|
let on_close2 = on_close.clone();
|
||||||
} else {
|
|
||||||
"py-1 px-2"
|
view! {
|
||||||
}
|
<li class=move || {
|
||||||
}>
|
if is_system {
|
||||||
<span class="text-gray-500">
|
"py-1 px-2 rounded bg-yellow-900/30 border-l-2 border-yellow-500"
|
||||||
"["
|
} else if is_whisper {
|
||||||
{format_timestamp(timestamp)}
|
"py-1 px-2 rounded bg-purple-900/30 border-l-2 border-purple-500"
|
||||||
"] "
|
|
||||||
</span>
|
|
||||||
<span class=move || {
|
|
||||||
if is_whisper {
|
|
||||||
"text-purple-300 font-medium"
|
|
||||||
} else {
|
} else {
|
||||||
"text-blue-300 font-medium"
|
"py-1 px-2"
|
||||||
}
|
}
|
||||||
}>
|
}>
|
||||||
{display_name}
|
<div class="flex items-start justify-between">
|
||||||
</span>
|
<div class="flex-1">
|
||||||
<Show when=move || is_whisper>
|
<span class="text-gray-500">
|
||||||
<span class="text-purple-400 text-xs ml-1">
|
"["
|
||||||
"(whisper)"
|
{format_timestamp(timestamp)}
|
||||||
</span>
|
"] "
|
||||||
</Show>
|
</span>
|
||||||
<span class="text-gray-400">
|
<span class=move || {
|
||||||
": "
|
if is_system {
|
||||||
</span>
|
"text-yellow-300 font-medium"
|
||||||
<span class=move || {
|
} else if is_whisper {
|
||||||
if is_whisper {
|
"text-purple-300 font-medium"
|
||||||
"text-gray-300 italic"
|
} else {
|
||||||
} else {
|
"text-blue-300 font-medium"
|
||||||
"text-gray-200"
|
}
|
||||||
}
|
}>
|
||||||
}>
|
{display_name}
|
||||||
{content}
|
</span>
|
||||||
</span>
|
<Show when=move || is_system>
|
||||||
</li>
|
<span class="text-yellow-400 text-xs ml-1">
|
||||||
|
"(system)"
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<Show when=move || is_whisper && !is_system>
|
||||||
|
<span class="text-purple-400 text-xs ml-1">
|
||||||
|
"(whisper)"
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<span class="text-gray-400">
|
||||||
|
": "
|
||||||
|
</span>
|
||||||
|
<span class=move || {
|
||||||
|
if is_system {
|
||||||
|
"text-yellow-200"
|
||||||
|
} else if is_whisper {
|
||||||
|
"text-gray-300 italic"
|
||||||
|
} else {
|
||||||
|
"text-gray-200"
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
{content}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Show when=move || is_whisper && !is_system>
|
||||||
|
<div class="flex gap-1 ml-2 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
class="px-2 py-0.5 text-xs bg-gray-600 hover:bg-gray-500 rounded text-gray-300"
|
||||||
|
title="Reply"
|
||||||
|
on:click={
|
||||||
|
let on_reply = on_reply.clone();
|
||||||
|
let sender = display_name_for_reply.clone();
|
||||||
|
let on_close = on_close.clone();
|
||||||
|
move |_| {
|
||||||
|
on_reply.run(sender.clone());
|
||||||
|
on_close.run(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
"Reply"
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-2 py-0.5 text-xs bg-gray-600 hover:bg-gray-500 rounded text-gray-300"
|
||||||
|
title="View conversation"
|
||||||
|
on:click={
|
||||||
|
let on_context = on_context.clone();
|
||||||
|
let sender = display_name_for_context.clone();
|
||||||
|
let on_close = on_close2.clone();
|
||||||
|
move |_| {
|
||||||
|
on_context.run(sender.clone());
|
||||||
|
on_close.run(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
"Context"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,241 +0,0 @@
|
||||||
//! Notification history component with LocalStorage persistence.
|
|
||||||
//!
|
|
||||||
//! Shows last 100 notifications across sessions.
|
|
||||||
|
|
||||||
use leptos::prelude::*;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use super::chat_types::ChatMessage;
|
|
||||||
use super::modals::Modal;
|
|
||||||
|
|
||||||
const STORAGE_KEY: &str = "chattyness_notification_history";
|
|
||||||
const MAX_HISTORY_SIZE: usize = 100;
|
|
||||||
|
|
||||||
/// A stored notification entry for history.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct HistoryEntry {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub sender_name: String,
|
|
||||||
pub content: String,
|
|
||||||
pub timestamp: i64,
|
|
||||||
pub is_whisper: bool,
|
|
||||||
/// Type of notification (e.g., "whisper", "system", "mod").
|
|
||||||
pub notification_type: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl HistoryEntry {
|
|
||||||
/// Create from a whisper chat message.
|
|
||||||
pub fn from_whisper(msg: &ChatMessage) -> Self {
|
|
||||||
Self {
|
|
||||||
id: Uuid::new_v4(),
|
|
||||||
sender_name: msg.display_name.clone(),
|
|
||||||
content: msg.content.clone(),
|
|
||||||
timestamp: msg.timestamp,
|
|
||||||
is_whisper: true,
|
|
||||||
notification_type: "whisper".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load history from LocalStorage.
|
|
||||||
#[cfg(feature = "hydrate")]
|
|
||||||
pub fn load_history() -> Vec<HistoryEntry> {
|
|
||||||
let window = match web_sys::window() {
|
|
||||||
Some(w) => w,
|
|
||||||
None => return Vec::new(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let storage = match window.local_storage() {
|
|
||||||
Ok(Some(s)) => s,
|
|
||||||
_ => return Vec::new(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let json = match storage.get_item(STORAGE_KEY) {
|
|
||||||
Ok(Some(j)) => j,
|
|
||||||
_ => return Vec::new(),
|
|
||||||
};
|
|
||||||
|
|
||||||
serde_json::from_str(&json).unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Save history to LocalStorage.
|
|
||||||
#[cfg(feature = "hydrate")]
|
|
||||||
pub fn save_history(history: &[HistoryEntry]) {
|
|
||||||
let window = match web_sys::window() {
|
|
||||||
Some(w) => w,
|
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
let storage = match window.local_storage() {
|
|
||||||
Ok(Some(s)) => s,
|
|
||||||
_ => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Ok(json) = serde_json::to_string(history) {
|
|
||||||
let _ = storage.set_item(STORAGE_KEY, &json);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add an entry to history, maintaining max size.
|
|
||||||
#[cfg(feature = "hydrate")]
|
|
||||||
pub fn add_to_history(entry: HistoryEntry) {
|
|
||||||
let mut history = load_history();
|
|
||||||
history.insert(0, entry);
|
|
||||||
if history.len() > MAX_HISTORY_SIZE {
|
|
||||||
history.truncate(MAX_HISTORY_SIZE);
|
|
||||||
}
|
|
||||||
save_history(&history);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// SSR stubs
|
|
||||||
#[cfg(not(feature = "hydrate"))]
|
|
||||||
pub fn load_history() -> Vec<HistoryEntry> {
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(feature = "hydrate"))]
|
|
||||||
pub fn save_history(_history: &[HistoryEntry]) {}
|
|
||||||
|
|
||||||
#[cfg(not(feature = "hydrate"))]
|
|
||||||
pub fn add_to_history(_entry: HistoryEntry) {}
|
|
||||||
|
|
||||||
/// Notification history modal component.
|
|
||||||
#[component]
|
|
||||||
pub fn NotificationHistoryModal(
|
|
||||||
#[prop(into)] open: Signal<bool>,
|
|
||||||
on_close: Callback<()>,
|
|
||||||
/// Callback when user wants to reply to a message.
|
|
||||||
on_reply: Callback<String>,
|
|
||||||
/// Callback when user wants to see conversation context.
|
|
||||||
on_context: Callback<String>,
|
|
||||||
) -> impl IntoView {
|
|
||||||
// Load history when modal opens
|
|
||||||
let history = Signal::derive(move || {
|
|
||||||
if open.get() {
|
|
||||||
load_history()
|
|
||||||
} else {
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<Modal
|
|
||||||
open=open
|
|
||||||
on_close=on_close.clone()
|
|
||||||
title="Notification History"
|
|
||||||
title_id="notification-history-title"
|
|
||||||
max_width="max-w-2xl"
|
|
||||||
>
|
|
||||||
<div class="max-h-96 overflow-y-auto">
|
|
||||||
<Show
|
|
||||||
when=move || !history.get().is_empty()
|
|
||||||
fallback=|| view! {
|
|
||||||
<p class="text-gray-400 text-center py-8">
|
|
||||||
"No notifications yet"
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ul class="space-y-2">
|
|
||||||
<For
|
|
||||||
each=move || history.get()
|
|
||||||
key=|entry| entry.id
|
|
||||||
children={
|
|
||||||
let on_reply = on_reply.clone();
|
|
||||||
let on_context = on_context.clone();
|
|
||||||
let on_close = on_close.clone();
|
|
||||||
move |entry: HistoryEntry| {
|
|
||||||
let sender_name = entry.sender_name.clone();
|
|
||||||
let sender_for_reply = sender_name.clone();
|
|
||||||
let sender_for_context = sender_name.clone();
|
|
||||||
let on_reply = on_reply.clone();
|
|
||||||
let on_context = on_context.clone();
|
|
||||||
let on_close = on_close.clone();
|
|
||||||
let on_close2 = on_close.clone();
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<li class="p-3 bg-gray-700/50 rounded-lg border border-gray-600">
|
|
||||||
<div class="flex items-start justify-between">
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="flex items-center gap-2 mb-1">
|
|
||||||
<span class="text-purple-300 font-medium">
|
|
||||||
{sender_name}
|
|
||||||
</span>
|
|
||||||
<span class="text-xs text-gray-500">
|
|
||||||
{format_timestamp(entry.timestamp)}
|
|
||||||
</span>
|
|
||||||
<Show when=move || entry.is_whisper>
|
|
||||||
<span class="text-xs bg-purple-500/30 text-purple-300 px-1.5 py-0.5 rounded">
|
|
||||||
"whisper"
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<p class="text-gray-300 text-sm">
|
|
||||||
{entry.content.clone()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-1 ml-2">
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 text-xs bg-gray-600 hover:bg-gray-500 rounded text-gray-300"
|
|
||||||
title="Reply"
|
|
||||||
on:click={
|
|
||||||
let on_reply = on_reply.clone();
|
|
||||||
let sender = sender_for_reply.clone();
|
|
||||||
let on_close = on_close.clone();
|
|
||||||
move |_| {
|
|
||||||
on_reply.run(sender.clone());
|
|
||||||
on_close.run(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
"Reply"
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 text-xs bg-gray-600 hover:bg-gray-500 rounded text-gray-300"
|
|
||||||
title="View conversation"
|
|
||||||
on:click={
|
|
||||||
let on_context = on_context.clone();
|
|
||||||
let sender = sender_for_context.clone();
|
|
||||||
let on_close = on_close2.clone();
|
|
||||||
move |_| {
|
|
||||||
on_context.run(sender.clone());
|
|
||||||
on_close.run(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
"Context"
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ul>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// Footer hint
|
|
||||||
<div class="mt-4 pt-4 border-t border-gray-600 text-xs text-gray-500 text-center">
|
|
||||||
"Press " <kbd class="px-1.5 py-0.5 bg-gray-700 rounded">"Esc"</kbd> " to close"
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Format a timestamp for display.
|
|
||||||
fn format_timestamp(timestamp: i64) -> String {
|
|
||||||
#[cfg(feature = "hydrate")]
|
|
||||||
{
|
|
||||||
let date = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(timestamp as f64));
|
|
||||||
let hours = date.get_hours();
|
|
||||||
let minutes = date.get_minutes();
|
|
||||||
format!("{:02}:{:02}", hours, minutes)
|
|
||||||
}
|
|
||||||
#[cfg(not(feature = "hydrate"))]
|
|
||||||
{
|
|
||||||
let _ = timestamp;
|
|
||||||
String::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -56,8 +56,6 @@ pub fn NotificationToast(
|
||||||
on_reply: Callback<String>,
|
on_reply: Callback<String>,
|
||||||
/// Callback when user wants to see context (press 'c').
|
/// Callback when user wants to see context (press 'c').
|
||||||
on_context: Callback<String>,
|
on_context: Callback<String>,
|
||||||
/// Callback when user wants to see history (press 'h').
|
|
||||||
on_history: Callback<()>,
|
|
||||||
/// Callback when notification is dismissed.
|
/// Callback when notification is dismissed.
|
||||||
on_dismiss: Callback<Uuid>,
|
on_dismiss: Callback<Uuid>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
|
|
@ -139,7 +137,6 @@ pub fn NotificationToast(
|
||||||
|
|
||||||
let on_reply = on_reply.clone();
|
let on_reply = on_reply.clone();
|
||||||
let on_context = on_context.clone();
|
let on_context = on_context.clone();
|
||||||
let on_history = on_history.clone();
|
|
||||||
let on_dismiss = on_dismiss.clone();
|
let on_dismiss = on_dismiss.clone();
|
||||||
|
|
||||||
let closure = wasm_bindgen::closure::Closure::<dyn Fn(web_sys::KeyboardEvent)>::new(
|
let closure = wasm_bindgen::closure::Closure::<dyn Fn(web_sys::KeyboardEvent)>::new(
|
||||||
|
|
@ -156,11 +153,6 @@ pub fn NotificationToast(
|
||||||
on_context.run(display_name.clone());
|
on_context.run(display_name.clone());
|
||||||
on_dismiss.run(notif_id);
|
on_dismiss.run(notif_id);
|
||||||
}
|
}
|
||||||
"h" | "H" => {
|
|
||||||
ev.prevent_default();
|
|
||||||
on_history.run(());
|
|
||||||
on_dismiss.run(notif_id);
|
|
||||||
}
|
|
||||||
"Escape" => {
|
"Escape" => {
|
||||||
ev.prevent_default();
|
ev.prevent_default();
|
||||||
on_dismiss.run(notif_id);
|
on_dismiss.run(notif_id);
|
||||||
|
|
@ -217,10 +209,6 @@ pub fn NotificationToast(
|
||||||
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-gray-400">"c"</kbd>
|
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-gray-400">"c"</kbd>
|
||||||
" context"
|
" context"
|
||||||
</span>
|
</span>
|
||||||
<span>
|
|
||||||
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-gray-400">"h"</kbd>
|
|
||||||
" history"
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -578,6 +578,7 @@ fn handle_server_message(
|
||||||
timestamp,
|
timestamp,
|
||||||
is_whisper,
|
is_whisper,
|
||||||
is_same_scene,
|
is_same_scene,
|
||||||
|
is_system: false,
|
||||||
};
|
};
|
||||||
on_chat_message.run(chat_msg);
|
on_chat_message.run(chat_msg);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,14 +14,14 @@ use uuid::Uuid;
|
||||||
use crate::components::{
|
use crate::components::{
|
||||||
ActiveBubble, AvatarEditorPopup, Card, ChatInput, ConversationModal, EmotionKeybindings,
|
ActiveBubble, AvatarEditorPopup, Card, ChatInput, ConversationModal, EmotionKeybindings,
|
||||||
FadingMember, HotkeyHelp, InventoryPopup, KeybindingsPopup, LogPopup, MessageLog,
|
FadingMember, HotkeyHelp, InventoryPopup, KeybindingsPopup, LogPopup, MessageLog,
|
||||||
NotificationHistoryModal, NotificationMessage, NotificationToast, RealmHeader,
|
NotificationMessage, NotificationToast, RealmHeader, RealmSceneViewer, ReconnectionOverlay,
|
||||||
RealmSceneViewer, ReconnectionOverlay, RegisterModal, SettingsPopup, ViewerSettings,
|
RegisterModal, SettingsPopup, ViewerSettings,
|
||||||
};
|
};
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
use crate::components::{
|
use crate::components::{
|
||||||
ChannelMemberInfo, ChatMessage, DEFAULT_BUBBLE_TIMEOUT_MS, FADE_DURATION_MS, HistoryEntry,
|
ChannelMemberInfo, ChatMessage, DEFAULT_BUBBLE_TIMEOUT_MS, FADE_DURATION_MS,
|
||||||
MemberIdentityInfo, ModCommandResultInfo, SummonInfo, TeleportInfo, WsError, add_to_history,
|
MemberIdentityInfo, ModCommandResultInfo, SummonInfo, TeleportInfo, WsError,
|
||||||
use_channel_websocket,
|
add_whisper_to_history, use_channel_websocket,
|
||||||
};
|
};
|
||||||
use crate::utils::LocalStoragePersist;
|
use crate::utils::LocalStoragePersist;
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
|
|
@ -120,7 +120,6 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
// Notification state for cross-scene whispers
|
// Notification state for cross-scene whispers
|
||||||
let (current_notification, set_current_notification) =
|
let (current_notification, set_current_notification) =
|
||||||
signal(Option::<NotificationMessage>::None);
|
signal(Option::<NotificationMessage>::None);
|
||||||
let (history_modal_open, set_history_modal_open) = signal(false);
|
|
||||||
let (conversation_modal_open, set_conversation_modal_open) = signal(false);
|
let (conversation_modal_open, set_conversation_modal_open) = signal(false);
|
||||||
let (conversation_partner, set_conversation_partner) = signal(String::new());
|
let (conversation_partner, set_conversation_partner) = signal(String::new());
|
||||||
// Track all whisper messages for conversation view (client-side only)
|
// Track all whisper messages for conversation view (client-side only)
|
||||||
|
|
@ -268,8 +267,8 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add to notification history for persistence
|
// Add to persistent whisper history in LocalStorage
|
||||||
add_to_history(HistoryEntry::from_whisper(&msg));
|
add_whisper_to_history(msg.clone());
|
||||||
|
|
||||||
if msg.is_same_scene {
|
if msg.is_same_scene {
|
||||||
// Same scene whisper: show as italic bubble (handled by bubble rendering)
|
// Same scene whisper: show as italic bubble (handled by bubble rendering)
|
||||||
|
|
@ -368,6 +367,23 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
// Callback for teleport approval - navigate to new scene
|
// Callback for teleport approval - navigate to new scene
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
let on_teleport_approved = Callback::new(move |info: TeleportInfo| {
|
let on_teleport_approved = Callback::new(move |info: TeleportInfo| {
|
||||||
|
// Log teleport to message log
|
||||||
|
let teleport_msg = ChatMessage {
|
||||||
|
message_id: Uuid::new_v4(),
|
||||||
|
user_id: None,
|
||||||
|
guest_session_id: None,
|
||||||
|
display_name: "[SYSTEM]".to_string(),
|
||||||
|
content: format!("Teleported to scene: {}", info.scene_slug),
|
||||||
|
emotion: "neutral".to_string(),
|
||||||
|
x: 0.0,
|
||||||
|
y: 0.0,
|
||||||
|
timestamp: js_sys::Date::now() as i64,
|
||||||
|
is_whisper: false,
|
||||||
|
is_same_scene: true,
|
||||||
|
is_system: true,
|
||||||
|
};
|
||||||
|
message_log.update_value(|log| log.push(teleport_msg));
|
||||||
|
|
||||||
let scene_id = info.scene_id;
|
let scene_id = info.scene_id;
|
||||||
let scene_slug = info.scene_slug.clone();
|
let scene_slug = info.scene_slug.clone();
|
||||||
let realm_slug = slug.get_untracked();
|
let realm_slug = slug.get_untracked();
|
||||||
|
|
@ -431,6 +447,23 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
// Callback for being summoned by a moderator - show notification and teleport
|
// Callback for being summoned by a moderator - show notification and teleport
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
let on_summoned = Callback::new(move |info: SummonInfo| {
|
let on_summoned = Callback::new(move |info: SummonInfo| {
|
||||||
|
// Log summon to message log
|
||||||
|
let summon_msg = ChatMessage {
|
||||||
|
message_id: Uuid::new_v4(),
|
||||||
|
user_id: None,
|
||||||
|
guest_session_id: None,
|
||||||
|
display_name: "[MOD]".to_string(),
|
||||||
|
content: format!("Summoned by {} to scene: {}", info.summoned_by, info.scene_slug),
|
||||||
|
emotion: "neutral".to_string(),
|
||||||
|
x: 0.0,
|
||||||
|
y: 0.0,
|
||||||
|
timestamp: js_sys::Date::now() as i64,
|
||||||
|
is_whisper: false,
|
||||||
|
is_same_scene: true,
|
||||||
|
is_system: true,
|
||||||
|
};
|
||||||
|
message_log.update_value(|log| log.push(summon_msg));
|
||||||
|
|
||||||
// Show notification
|
// Show notification
|
||||||
set_mod_notification.set(Some((true, format!("Summoned by {}", info.summoned_by))));
|
set_mod_notification.set(Some((true, format!("Summoned by {}", info.summoned_by))));
|
||||||
|
|
||||||
|
|
@ -503,6 +536,24 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
// Callback for mod command result - show notification
|
// Callback for mod command result - show notification
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
let on_mod_command_result = Callback::new(move |info: ModCommandResultInfo| {
|
let on_mod_command_result = Callback::new(move |info: ModCommandResultInfo| {
|
||||||
|
// Log mod command result to message log
|
||||||
|
let status = if info.success { "OK" } else { "FAILED" };
|
||||||
|
let mod_msg = ChatMessage {
|
||||||
|
message_id: Uuid::new_v4(),
|
||||||
|
user_id: None,
|
||||||
|
guest_session_id: None,
|
||||||
|
display_name: "[MOD]".to_string(),
|
||||||
|
content: format!("[{}] {}", status, info.message),
|
||||||
|
emotion: "neutral".to_string(),
|
||||||
|
x: 0.0,
|
||||||
|
y: 0.0,
|
||||||
|
timestamp: js_sys::Date::now() as i64,
|
||||||
|
is_whisper: false,
|
||||||
|
is_same_scene: true,
|
||||||
|
is_system: true,
|
||||||
|
};
|
||||||
|
message_log.update_value(|log| log.push(mod_msg));
|
||||||
|
|
||||||
set_mod_notification.set(Some((info.success, info.message)));
|
set_mod_notification.set(Some((info.success, info.message)));
|
||||||
|
|
||||||
// Auto-dismiss notification after 3 seconds
|
// Auto-dismiss notification after 3 seconds
|
||||||
|
|
@ -820,7 +871,6 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
|| keybindings_open.get_untracked()
|
|| keybindings_open.get_untracked()
|
||||||
|| avatar_editor_open.get_untracked()
|
|| avatar_editor_open.get_untracked()
|
||||||
|| register_modal_open.get_untracked()
|
|| register_modal_open.get_untracked()
|
||||||
|| history_modal_open.get_untracked()
|
|
||||||
|| conversation_modal_open.get_untracked()
|
|| conversation_modal_open.get_untracked()
|
||||||
{
|
{
|
||||||
*e_pressed_clone.borrow_mut() = false;
|
*e_pressed_clone.borrow_mut() = false;
|
||||||
|
|
@ -1301,6 +1351,13 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
on_close=Callback::new(move |_: ()| {
|
on_close=Callback::new(move |_: ()| {
|
||||||
set_log_open.set(false);
|
set_log_open.set(false);
|
||||||
})
|
})
|
||||||
|
on_reply=Callback::new(move |name: String| {
|
||||||
|
whisper_target.set(Some(name));
|
||||||
|
})
|
||||||
|
on_context=Callback::new(move |name: String| {
|
||||||
|
set_conversation_partner.set(name);
|
||||||
|
set_conversation_modal_open.set(true);
|
||||||
|
})
|
||||||
/>
|
/>
|
||||||
|
|
||||||
// Keybindings popup
|
// Keybindings popup
|
||||||
|
|
@ -1381,9 +1438,6 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
set_conversation_partner.set(name);
|
set_conversation_partner.set(name);
|
||||||
set_conversation_modal_open.set(true);
|
set_conversation_modal_open.set(true);
|
||||||
})
|
})
|
||||||
on_history=Callback::new(move |_: ()| {
|
|
||||||
set_history_modal_open.set(true);
|
|
||||||
})
|
|
||||||
on_dismiss=Callback::new(move |_: Uuid| {
|
on_dismiss=Callback::new(move |_: Uuid| {
|
||||||
set_current_notification.set(None);
|
set_current_notification.set(None);
|
||||||
})
|
})
|
||||||
|
|
@ -1443,19 +1497,6 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
}}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
// Notification history modal
|
|
||||||
<NotificationHistoryModal
|
|
||||||
open=Signal::derive(move || history_modal_open.get())
|
|
||||||
on_close=Callback::new(move |_: ()| set_history_modal_open.set(false))
|
|
||||||
on_reply=Callback::new(move |name: String| {
|
|
||||||
whisper_target.set(Some(name));
|
|
||||||
})
|
|
||||||
on_context=Callback::new(move |name: String| {
|
|
||||||
set_conversation_partner.set(name);
|
|
||||||
set_conversation_modal_open.set(true);
|
|
||||||
})
|
|
||||||
/>
|
|
||||||
|
|
||||||
// Conversation modal
|
// Conversation modal
|
||||||
{
|
{
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue