241 lines
9.7 KiB
Rust
241 lines
9.7 KiB
Rust
//! 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()
|
|
}
|
|
}
|