feat: private messages.
This commit is contained in:
parent
0492043625
commit
22cc0fdc38
11 changed files with 1135 additions and 44 deletions
241
crates/chattyness-user-ui/src/components/notification_history.rs
Normal file
241
crates/chattyness-user-ui/src/components/notification_history.rs
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
//! 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()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue