yggdrasil/components/comments/
section.rs1use dioxus::prelude::*;
7
8use crate::api::comments::{check_pending_status, get_comments, CommentTreeResponse};
9use crate::components::comments::form::CommentForm;
10use crate::components::comments::list::CommentList;
11use crate::components::skeletons::comment_skeleton::CommentListSkeleton;
12use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
13use crate::utils::comment_storage::{self, PendingComment};
14use crate::utils::time::sleep_ms;
15
16const PENDING_POLL_INTERVAL_MS: u32 = 30_000;
21
22#[derive(Clone, Copy)]
31pub struct CommentContext {
32 pub active_reply: Signal<Option<i64>>,
34 pub refresh_trigger: Signal<bool>,
36 pub pending_comments: Signal<Vec<PendingComment>>,
38 pub current_user: Signal<Option<crate::models::user::PublicUser>>,
40}
41
42#[component]
53pub fn CommentSection(post_id: i32) -> Element {
54 let mut ctx = use_context_provider(|| CommentContext {
55 active_reply: Signal::new(None),
56 refresh_trigger: Signal::new(false),
57 pending_comments: Signal::new(Vec::new()),
58 current_user: Signal::new(None),
59 });
60
61 use_effect(move || {
63 let pending = comment_storage::load_pending_comments(post_id);
64 comment_storage::prune_all_expired();
65 ctx.pending_comments.set(pending);
66 });
67
68 use_effect(move || {
73 spawn(async move {
74 if let Ok(resp) = crate::api::auth::get_current_user().await {
75 if let Some(u) = resp.user {
76 ctx.current_user.set(Some(u));
77 }
78 }
79 });
80 });
81
82 let _pending_poll = use_resource(move || {
95 let mut pending_comments = ctx.pending_comments;
96 let mut refresh_trigger = ctx.refresh_trigger;
97 async move {
98 loop {
99 let ids: Vec<i64> = pending_comments.read().iter().map(|c| c.id).collect();
100 if ids.is_empty() {
101 return;
103 }
104
105 if let Ok(statuses) = check_pending_status(ids).await {
106 let to_remove: Vec<i64> = statuses
107 .into_iter()
108 .filter(|s| s.status != "pending")
109 .map(|s| s.id)
110 .collect();
111 if !to_remove.is_empty() {
112 comment_storage::remove_pending_ids(post_id, &to_remove);
113 let next = !*refresh_trigger.peek();
116 refresh_trigger.set(next);
117 pending_comments
118 .write()
119 .retain(|c| !to_remove.contains(&c.id));
120 }
121 }
122 sleep_ms(PENDING_POLL_INTERVAL_MS).await;
125 }
126 }
127 });
128
129 let comments_resource = use_resource(move || {
131 let _ = (ctx.refresh_trigger)();
132 async move { get_comments(post_id).await }
133 });
134
135 use_effect(move || {
140 let data = comments_resource.read();
141 if let Some(Ok(CommentTreeResponse { comments, .. })) = &*data {
142 let approved_ids: std::collections::HashSet<i64> =
143 comments.iter().map(|c| c.id).collect();
144 let to_remove: Vec<i64> = ctx
145 .pending_comments
146 .read()
147 .iter()
148 .filter(|p| approved_ids.contains(&p.id))
149 .map(|p| p.id)
150 .collect();
151 if !to_remove.is_empty() {
152 comment_storage::remove_pending_ids(post_id, &to_remove);
153 ctx.pending_comments
154 .write()
155 .retain(|p| !to_remove.contains(&p.id));
156 }
157 }
158 });
159
160 #[cfg(target_arch = "wasm32")]
164 use_effect(move || {
165 let data = comments_resource.read();
167 if !matches!(&*data, Some(Ok(_))) {
168 return;
169 }
170 let window = web_sys::window()
171 .expect("CommentSection use_effect 仅在 WASM 浏览器上下文执行:无 window");
172 let sel: wasm_bindgen::JsValue = ".comment-list".into();
173 let existing =
177 js_sys::Reflect::get(&window, &"__lightboxSelectors".into()).unwrap_or_default();
178 let arr = if existing.is_array() {
179 js_sys::Array::from(&existing)
180 } else {
181 js_sys::Array::new()
182 };
183 if !arr.includes(&sel, 0) {
184 arr.push(&sel);
185 }
186 let selectors_val = js_sys::Object::from(arr).into();
187 let _ = js_sys::Reflect::set(&window, &"__lightboxSelectors".into(), &selectors_val);
188 let call_arg = js_sys::Array::of1(&sel);
191 crate::utils::js::invoke_optional_global(&window, "__initLightbox", &[call_arg.into()]);
192 });
193
194 let data = comments_resource.read();
195
196 let total_count = if let Some(Ok(CommentTreeResponse { count, .. })) = &*data {
198 let approved_count = *count;
199 let pending_count = ctx.pending_comments.read().len() as i64;
200 Some(approved_count + pending_count)
201 } else {
202 None
203 };
204
205 rsx! {
206 div { class: "space-y-6",
207 div { class: "flex items-center justify-between",
209 div { class: "flex items-center gap-2.5",
210 svg {
211 class: "w-5 h-5 text-[var(--color-paper-accent)]",
212 fill: "none",
213 stroke: "currentColor",
214 stroke_width: "2",
215 view_box: "0 0 24 24",
216 path {
217 stroke_linecap: "round",
218 stroke_linejoin: "round",
219 d: "M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z",
220 }
221 }
222 h2 { class: "text-lg font-bold text-paper-primary tracking-tight", "评论区" }
223 if let Some(count) = total_count {
224 span { class: "px-2 py-0.5 rounded-full text-xs font-semibold bg-[var(--color-paper-accent)]/15 text-[var(--color-paper-accent)]",
225 "{count}"
226 }
227 }
228 }
229 }
230
231 CommentForm { post_id, parent_id: None, parent_indent: None }
233
234 match &*data {
236 Some(Ok(CommentTreeResponse { comments, .. })) => {
237 let approved_count = comments.len();
238 let pending_count = ctx.pending_comments.read().len();
239 let has_any = approved_count > 0 || pending_count > 0;
240 if !has_any {
241 rsx! {
242 div { class: "text-center py-10 px-4 rounded-2xl bg-[var(--color-paper-entry)]/40 border border-dashed border-[var(--color-paper-border)]/60 my-4",
243 p { class: "text-sm text-paper-secondary font-medium", "暂无评论" }
244 p { class: "text-xs text-paper-tertiary mt-1", "成为第一个分享想法的人吧!" }
245 }
246 }
247 } else {
248 rsx! {
249 CommentList {
250 comments: comments.clone(),
251 pending: ctx.pending_comments.read().clone(),
252 post_id,
253 }
254 }
255 }
256 }
257 Some(Err(_)) => rsx! {
258 div { class: "text-center text-red-500 dark:text-red-400 py-8 text-sm", "评论加载失败,请刷新重试" }
259 },
260 None => rsx! {
261 DelayedSkeleton { CommentListSkeleton {} }
262 },
263 }
264 }
265 }
266}