chat_ui_components/components/
chat_layout.rs1use dioxus::prelude::*;
2use chat_core::history::types::{ChatMessage, ConnectionStats};
3
4use super::{MessageItem, PeerList, StatsPanel};
5
6#[component]
7pub fn ChatLayout(
8 room_name: String,
9 nickname: String,
10 messages: Vec<ChatMessage>,
11 is_online: bool,
12 stats: Option<Box<ConnectionStats>>,
13 system_messages: Vec<String>,
14 error_messages: Vec<String>,
15 show_stats: bool,
16 sidebar_open: bool,
17 sidebar_collapsed: bool,
18 on_send_message: EventHandler<String>,
19 on_set_nickname: EventHandler<String>,
20 on_dial_peer: EventHandler<String>,
21 on_leave_room: EventHandler<()>,
22 on_toggle_stats: EventHandler<()>,
23 on_toggle_sidebar: EventHandler<()>,
24 on_close_sidebar: EventHandler<()>,
25) -> Element {
26 let mut message_input = use_signal(|| "".to_string());
27 let mut nickname_input = use_signal(|| nickname.clone());
28 let mut should_auto_scroll = use_signal(|| true);
29 let mut last_scrolled_id = use_signal(|| String::new());
30 let stats_for_effect = stats.clone();
31
32 let mut send = move || {
33 let content = message_input.read().trim().to_string();
34 if !content.is_empty() {
35 on_send_message.call(content);
36 message_input.set("".to_string());
37 should_auto_scroll.set(true);
38 }
39 };
40
41 use_effect(use_reactive((&messages,), move |(messages,)| {
42 if messages.is_empty() {
43 return;
44 }
45 let last_id = messages.last().map(|m| m.id.clone()).unwrap_or_default();
46 if last_id == last_scrolled_id() {
47 return;
48 }
49 let local_peer_id = stats_for_effect.as_ref().map(|s| s.peer_id.clone()).unwrap_or_default();
50 let is_local = messages.last().map(|m| m.peer_id == local_peer_id).unwrap_or(false);
51 if is_local || should_auto_scroll() {
52 last_scrolled_id.set(last_id);
53 spawn(async move {
54 let _ = document::eval(r#"
55 let el = document.getElementById("messages");
56 if (el) el.scrollTop = el.scrollHeight;
57 "#).await;
58 });
59 }
60 }));
61
62 let status_text = if is_online { "● online" } else { "● offline" };
63 let status_class = if is_online { "status-badge online" } else { "status-badge offline" };
64
65 let peer_count = stats.as_ref().map(|s| s.connected_peers.len()).unwrap_or(0);
66 let conn_count = stats.as_ref().map(|s| s.direct_peers + s.bootstrap_connections).unwrap_or(0);
67
68 let sidebar_class = {
69 let mut c = "sidebar".to_string();
70 if sidebar_open { c.push_str(" open"); }
71 if sidebar_collapsed { c.push_str(" collapsed"); }
72 c
73 };
74
75 rsx! {
76 div {
77 id: "app",
78
79 div {
81 class: "main-content",
82
83 div {
85 class: "chat-header",
86 div {
87 class: "logo",
88 span { class: "logo-icon", "🔥" }
89 span { class: "logo-text", "P2Pandemonium" }
90 }
91 div {
92 class: "header-stats",
93 div {
94 class: "stat-badge",
95 "Room: {room_name}"
96 }
97 div {
98 class: "stat-badge",
99 "Peers: {peer_count} ({conn_count} conns)"
100 }
101 div {
102 class: status_class,
103 "{status_text}"
104 }
105 button {
106 class: "sidebar-toggle",
107 onclick: move |_| on_toggle_sidebar.call(()),
108 "👥"
109 }
110 }
111 }
112
113 if show_stats {
115 StatsPanel {
116 stats: stats.clone(),
117 local_peer_id: stats.as_ref().map(|s| s.peer_id.clone()).unwrap_or_default(),
118 on_close: move || on_toggle_stats.call(()),
119 on_dial_peer,
120 }
121 }
122
123 div {
125 class: "chat-container",
126 div {
127 class: "messages-list",
128 id: "messages",
129 onscroll: move |evt| {
130 let data = evt.data();
131 let scroll_top = data.scroll_top();
132 let client_height = data.client_height();
133 let scroll_height = data.scroll_height();
134 let at_bottom = (scroll_top + client_height as f64) >= (scroll_height as f64 - 50.0);
135 should_auto_scroll.set(at_bottom);
136 },
137
138 for (idx, msg) in system_messages.iter().enumerate() {
140 div {
141 key: "sys-{idx}",
142 class: "system-message",
143 "{msg}"
144 }
145 }
146
147 for (idx, msg) in error_messages.iter().enumerate() {
149 div {
150 key: "err-{idx}",
151 class: "error-message",
152 "{msg}"
153 }
154 }
155
156 for msg in messages {
158 MessageItem {
159 key: "{msg.id}",
160 msg: msg.clone(),
161 is_local: msg.peer_id == stats.as_ref().map(|s| s.peer_id.clone()).unwrap_or_default(),
162 }
163 }
164 }
165 }
166
167 div {
169 class: "chat-footer",
170
171 div {
173 class: "input-row",
174 div {
175 class: "nickname-section",
176 label { "Nick:" }
177 input {
178 id: "nickname",
179 value: "{nickname_input}",
180 oninput: move |evt| nickname_input.set(evt.value().clone()),
181 onchange: move |_| {
182 let nick = nickname_input.read().trim().to_string();
183 if !nick.is_empty() {
184 on_set_nickname.call(nick);
185 }
186 }
187 }
188 }
189 button {
190 class: "stats-btn",
191 onclick: move |_| on_toggle_stats.call(()),
192 "📊 Stats"
193 }
194 }
195
196 div {
198 class: "message-input-section",
199 input {
200 id: "message-input",
201 value: "{message_input}",
202 placeholder: "Type a message...",
203 disabled: !is_online,
204 oninput: move |evt| message_input.set(evt.value().clone()),
205 onkeypress: move |evt| {
206 if evt.key().to_string() == "Enter" {
207 send();
208 }
209 }
210 }
211 button {
212 id: "send-btn",
213 class: "btn-primary",
214 disabled: !is_online || message_input.read().trim().is_empty(),
215 onclick: move |_| send(),
216 "Send"
217 }
218 }
219 }
220 }
221
222 div {
224 class: "{sidebar_class}",
225 div {
226 class: "sidebar-header",
227 h3 { "Peers" }
228 button {
229 class: "close-btn",
230 onclick: move |_| on_close_sidebar.call(()),
231 "×"
232 }
233 }
234 div {
235 class: "peer-list",
236 PeerList { stats: stats.clone(), sidebar_open: sidebar_open }
237
238 div {
240 style: "padding: 1rem; border-top: 1px solid var(--border-color); margin-top: auto;",
241 button {
242 class: "leave-btn",
243 onclick: move |_| on_leave_room.call(()),
244 "Leave Room"
245 }
246 }
247 }
248 }
249
250 if sidebar_open {
252 div {
253 class: "sidebar-backdrop",
254 onclick: move |_| on_close_sidebar.call(()),
255 }
256 }
257 }
258 }
259}