feat: private messages.
This commit is contained in:
parent
0492043625
commit
22cc0fdc38
11 changed files with 1135 additions and 44 deletions
173
crates/chattyness-user-ui/src/components/conversation_modal.rs
Normal file
173
crates/chattyness-user-ui/src/components/conversation_modal.rs
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
//! Conversation modal for viewing whisper history with a specific user.
|
||||
|
||||
use leptos::prelude::*;
|
||||
use leptos::reactive::owner::LocalStorage;
|
||||
#[cfg(feature = "hydrate")]
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
use chattyness_db::ws_messages::ClientMessage;
|
||||
|
||||
use super::chat_types::ChatMessage;
|
||||
use super::modals::Modal;
|
||||
use super::ws_client::WsSender;
|
||||
|
||||
/// Conversation modal component for viewing/replying to whispers.
|
||||
#[component]
|
||||
pub fn ConversationModal(
|
||||
#[prop(into)] open: Signal<bool>,
|
||||
on_close: Callback<()>,
|
||||
/// The display name of the conversation partner.
|
||||
#[prop(into)]
|
||||
partner_name: Signal<String>,
|
||||
/// All whisper messages involving this partner.
|
||||
#[prop(into)]
|
||||
messages: Signal<Vec<ChatMessage>>,
|
||||
/// Current user's display name for determining message direction.
|
||||
#[prop(into)]
|
||||
current_user_name: Signal<String>,
|
||||
/// WebSocket sender for sending replies.
|
||||
ws_sender: StoredValue<Option<WsSender>, LocalStorage>,
|
||||
) -> impl IntoView {
|
||||
let (reply_text, set_reply_text) = signal(String::new());
|
||||
let input_ref = NodeRef::<leptos::html::Input>::new();
|
||||
|
||||
// Focus input when modal opens
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
Effect::new(move |_| {
|
||||
if open.get() {
|
||||
// Small delay to ensure modal is rendered
|
||||
let input_ref = input_ref;
|
||||
let closure = wasm_bindgen::closure::Closure::once(Box::new(move || {
|
||||
if let Some(input) = input_ref.get() {
|
||||
let _ = input.focus();
|
||||
}
|
||||
}) as Box<dyn FnOnce()>);
|
||||
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||
closure.as_ref().unchecked_ref(),
|
||||
100,
|
||||
);
|
||||
}
|
||||
closure.forget();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let send_reply = {
|
||||
let on_close = on_close.clone();
|
||||
move || {
|
||||
let text = reply_text.get();
|
||||
let target = partner_name.get();
|
||||
if !text.trim().is_empty() && !target.is_empty() {
|
||||
ws_sender.with_value(|sender| {
|
||||
if let Some(send_fn) = sender {
|
||||
send_fn(ClientMessage::SendChatMessage {
|
||||
content: text.trim().to_string(),
|
||||
target_display_name: Some(target.clone()),
|
||||
});
|
||||
}
|
||||
});
|
||||
set_reply_text.set(String::new());
|
||||
on_close.run(());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
let on_keydown = {
|
||||
let send_reply = send_reply.clone();
|
||||
move |ev: web_sys::KeyboardEvent| {
|
||||
if ev.key() == "Enter" && !ev.shift_key() {
|
||||
ev.prevent_default();
|
||||
send_reply();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
let on_keydown = move |_ev| {};
|
||||
|
||||
view! {
|
||||
<Modal
|
||||
open=open
|
||||
on_close=on_close.clone()
|
||||
title="Conversation"
|
||||
title_id="conversation-modal-title"
|
||||
max_width="max-w-lg"
|
||||
>
|
||||
// Partner name header
|
||||
<div class="mb-4 pb-2 border-b border-gray-600">
|
||||
<h3 class="text-lg text-purple-300">
|
||||
"with " {move || partner_name.get()}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
// Messages list
|
||||
<div class="max-h-64 overflow-y-auto mb-4 space-y-2">
|
||||
<Show
|
||||
when=move || !messages.get().is_empty()
|
||||
fallback=|| view! {
|
||||
<p class="text-gray-400 text-center py-4">
|
||||
"No messages yet"
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<For
|
||||
each=move || messages.get()
|
||||
key=|msg| msg.message_id
|
||||
children=move |msg: ChatMessage| {
|
||||
let is_from_me = msg.display_name == current_user_name.get();
|
||||
let align_class = if is_from_me { "text-right" } else { "text-left" };
|
||||
let bubble_class = if is_from_me {
|
||||
"bg-purple-600/50 ml-8"
|
||||
} else {
|
||||
"bg-gray-700 mr-8"
|
||||
};
|
||||
let sender = if is_from_me { "you" } else { &msg.display_name };
|
||||
|
||||
view! {
|
||||
<div class=align_class>
|
||||
<div class=format!("inline-block p-2 rounded-lg {}", bubble_class)>
|
||||
<span class="text-xs text-gray-400">
|
||||
{sender.to_string()} ": "
|
||||
</span>
|
||||
<span class="text-gray-200 italic">
|
||||
{msg.content.clone()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
// Reply input
|
||||
<div class="border-t border-gray-600 pt-4">
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
node_ref=input_ref
|
||||
class="flex-1 bg-gray-700 text-white rounded px-3 py-2 outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder=move || format!("/whisper {} ", partner_name.get())
|
||||
prop:value=move || reply_text.get()
|
||||
on:input=move |ev| set_reply_text.set(event_target_value(&ev))
|
||||
on:keydown=on_keydown
|
||||
/>
|
||||
<button
|
||||
class="px-4 py-2 bg-purple-600 hover:bg-purple-500 rounded text-white"
|
||||
on:click=move |_| send_reply()
|
||||
>
|
||||
"Send"
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
"Press " <kbd class="px-1 py-0.5 bg-gray-700 rounded">"Enter"</kbd> " to send, "
|
||||
<kbd class="px-1 py-0.5 bg-gray-700 rounded">"Esc"</kbd> " to close"
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue