chattyness/crates/chattyness-user-ui/src/components/notification_history.rs

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()
}
}