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::models::comment::PublicComment;
14use crate::utils::comment_storage::{self, PendingComment};
15use crate::utils::time::sleep_ms;
16
17const PENDING_POLL_INTERVAL_MS: u32 = 30_000;
22
23#[derive(Clone, Copy)]
32pub struct CommentContext {
33 pub active_reply: Signal<Option<i64>>,
35 pub refresh_trigger: Signal<bool>,
37 pub pending_comments: Signal<Vec<PendingComment>>,
39 pub current_user: Signal<Option<crate::models::user::PublicUser>>,
41 pub source: CommentSource,
43 pub id_scope: &'static str,
45}
46
47#[derive(Clone, Copy)]
48pub enum CommentSource {
49 Production,
50 Local {
51 approved: Signal<Vec<PublicComment>>,
52 },
53}
54
55#[component]
66pub fn CommentSection(post_id: i32) -> Element {
67 let mut ctx = use_context_provider(|| CommentContext {
68 active_reply: Signal::new(None),
69 refresh_trigger: Signal::new(false),
70 pending_comments: Signal::new(Vec::new()),
71 current_user: Signal::new(None),
72 source: CommentSource::Production,
73 id_scope: "post",
74 });
75
76 use_effect(move || {
78 let pending = comment_storage::load_pending_comments(post_id);
79 comment_storage::prune_all_expired();
80 ctx.pending_comments.set(pending);
81 });
82
83 use_effect(move || {
88 spawn(async move {
89 if let Ok(resp) = crate::api::auth::get_current_user().await {
90 if let Some(u) = resp.user {
91 ctx.current_user.set(Some(u));
92 }
93 }
94 });
95 });
96
97 let _pending_poll = use_resource(move || {
110 let mut pending_comments = ctx.pending_comments;
111 let mut refresh_trigger = ctx.refresh_trigger;
112 async move {
113 loop {
114 let ids: Vec<i64> = pending_comments.read().iter().map(|c| c.id).collect();
115 if ids.is_empty() {
116 return;
118 }
119
120 if let Ok(statuses) = check_pending_status(ids).await {
121 let to_remove: Vec<i64> = statuses
122 .into_iter()
123 .filter(|s| s.status != "pending")
124 .map(|s| s.id)
125 .collect();
126 if !to_remove.is_empty() {
127 comment_storage::remove_pending_ids(post_id, &to_remove);
128 let next = !*refresh_trigger.peek();
131 refresh_trigger.set(next);
132 pending_comments
133 .write()
134 .retain(|c| !to_remove.contains(&c.id));
135 }
136 }
137 sleep_ms(PENDING_POLL_INTERVAL_MS).await;
140 }
141 }
142 });
143
144 let comments_resource = use_resource(move || {
146 let _ = (ctx.refresh_trigger)();
147 async move { get_comments(post_id).await }
148 });
149
150 use_effect(move || {
155 let data = comments_resource.read();
156 if let Some(Ok(CommentTreeResponse { comments, .. })) = &*data {
157 let approved_ids: std::collections::HashSet<i64> =
158 comments.iter().map(|c| c.id).collect();
159 let to_remove: Vec<i64> = ctx
160 .pending_comments
161 .read()
162 .iter()
163 .filter(|p| approved_ids.contains(&p.id))
164 .map(|p| p.id)
165 .collect();
166 if !to_remove.is_empty() {
167 comment_storage::remove_pending_ids(post_id, &to_remove);
168 ctx.pending_comments
169 .write()
170 .retain(|p| !to_remove.contains(&p.id));
171 }
172 }
173 });
174
175 #[cfg(target_arch = "wasm32")]
179 use_effect(move || {
180 let data = comments_resource.read();
182 if !matches!(&*data, Some(Ok(_))) {
183 return;
184 }
185 let window = web_sys::window()
186 .expect("CommentSection use_effect 仅在 WASM 浏览器上下文执行:无 window");
187 let sel: wasm_bindgen::JsValue = ".comment-list".into();
188 let existing =
192 js_sys::Reflect::get(&window, &"__lightboxSelectors".into()).unwrap_or_default();
193 let arr = if existing.is_array() {
194 js_sys::Array::from(&existing)
195 } else {
196 js_sys::Array::new()
197 };
198 if !arr.includes(&sel, 0) {
199 arr.push(&sel);
200 }
201 let selectors_val = js_sys::Object::from(arr).into();
202 let _ = js_sys::Reflect::set(&window, &"__lightboxSelectors".into(), &selectors_val);
203 let call_arg = js_sys::Array::of1(&sel);
206 crate::utils::js::invoke_optional_global(&window, "__initLightbox", &[call_arg.into()]);
207 });
208
209 let data = comments_resource.read();
210 let (comments, approved_count, failed) = match &*data {
211 Some(Ok(CommentTreeResponse {
212 comments, count, ..
213 })) => (Some(comments.clone()), Some(*count), false),
214 Some(Err(_)) => (None, None, true),
215 None => (None, None, false),
216 };
217
218 rsx! { CommentSectionContent { post_id, comments, approved_count, failed } }
219}
220
221#[component]
223pub(crate) fn CommentSectionContent(
224 post_id: i32,
225 comments: Option<Vec<PublicComment>>,
226 approved_count: Option<i64>,
227 #[props(default)] failed: bool,
228) -> Element {
229 let ctx: CommentContext = use_context();
230 let pending = ctx.pending_comments.read().clone();
231 let total_count = approved_count.map(|count| count + pending.len() as i64);
232
233 rsx! {
234 div { class: "space-y-6",
235 div { class: "flex items-center justify-between",
237 div { class: "flex items-center gap-2.5",
238 svg {
239 class: "w-5 h-5 text-[var(--color-paper-accent)]",
240 fill: "none",
241 stroke: "currentColor",
242 stroke_width: "2",
243 view_box: "0 0 24 24",
244 path {
245 stroke_linecap: "round",
246 stroke_linejoin: "round",
247 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",
248 }
249 }
250 h2 { class: "text-lg font-bold text-paper-primary tracking-tight", "评论区" }
251 if let Some(count) = total_count {
252 span { class: "px-2 py-0.5 rounded-full text-xs font-semibold bg-[var(--color-paper-accent)]/15 text-[var(--color-paper-accent)]",
253 "{count}"
254 }
255 }
256 }
257 }
258
259 CommentForm { post_id, parent_id: None, parent_indent: None }
261
262 match comments {
264 Some(comments) => {
265 let approved_count = comments.len();
266 let pending_count = pending.len();
267 let has_any = approved_count > 0 || pending_count > 0;
268 if !has_any {
269 rsx! {
270 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",
271 p { class: "text-sm text-paper-secondary font-medium", "暂无评论" }
272 p { class: "text-xs text-paper-tertiary mt-1", "成为第一个分享想法的人吧!" }
273 }
274 }
275 } else {
276 rsx! {
277 CommentList {
278 comments: comments.clone(),
279 pending,
280 post_id,
281 }
282 }
283 }
284 }
285 None if failed => rsx! {
286 div { class: "text-center text-red-500 dark:text-red-400 py-8 text-sm", "评论加载失败,请刷新重试" }
287 },
288 None => rsx! {
289 DelayedSkeleton { CommentListSkeleton {} }
290 },
291 }
292 }
293 }
294}