yggdrasil/components/comments/
section.rs1use std::future::Future;
7use std::pin::Pin;
8
9use leptos::prelude::*;
10use leptos::task::spawn_local;
11
12use crate::api::comments::{
13 check_pending_status, create_comment, get_comments, CommentTreeResponse,
14};
15use crate::api::error::ApiError;
16use crate::components::comments::form::CommentForm;
17use crate::components::comments::list::CommentList;
18use crate::components::enhance_assets::LightboxAssets;
19use crate::components::mascot::Mascot;
20use crate::components::skeletons::comment_skeleton::CommentListSkeleton;
21use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
22
23use crate::models::comment::PublicComment;
24use crate::models::user::PublicUser;
25use crate::utils::comment_storage::{self, AuthorInfo, PendingComment};
26use crate::utils::time::sleep_ms;
27
28const PENDING_POLL_INTERVAL_MS: u32 = 30_000;
33
34#[derive(Clone, Copy)]
43pub struct CommentContext {
44 pub active_reply: RwSignal<Option<i64>>,
46 pub refresh_trigger: RwSignal<bool>,
48 pub pending_comments: RwSignal<Vec<PendingComment>>,
50 pub current_user: RwSignal<Option<PublicUser>>,
52 pub submit: Callback<CommentSubmission, CommentSubmitFuture>,
54 pub image_upload: bool,
56 pub id_scope: &'static str,
58}
59
60pub struct CommentSubmission {
62 pub post_id: i32,
63 pub parent_id: Option<i64>,
64 pub anonymous: bool,
66 pub author_name: String,
67 pub author_email: String,
68 pub author_url: String,
69 pub content_md: String,
70 pub honeypot: String,
71}
72
73pub type CommentSubmitFuture = Pin<Box<dyn Future<Output = Result<String, String>>>>;
75
76fn submit_to_server(
78 sub: CommentSubmission,
79 pending_comments: RwSignal<Vec<PendingComment>>,
80) -> CommentSubmitFuture {
81 Box::pin(async move {
82 let author_url = (!sub.author_url.trim().is_empty()).then(|| sub.author_url.clone());
83 let resp = create_comment(
84 sub.post_id,
85 sub.parent_id,
86 sub.author_name.clone(),
87 sub.author_email.clone(),
88 author_url.clone(),
89 sub.content_md.clone(),
90 sub.honeypot,
91 )
92 .await
93 .map_err(|_| "提交失败,请稍后重试".to_string())?;
94 if !resp.success {
95 return Err(resp.message);
96 }
97 if sub.anonymous {
99 comment_storage::save_author(&AuthorInfo {
100 name: sub.author_name.clone(),
101 email: sub.author_email,
102 url: sub.author_url,
103 });
104 if let Some(id) = resp.comment_id {
105 let now = chrono::Utc::now().to_rfc3339();
106 let pending = PendingComment {
107 id,
108 parent_id: sub.parent_id,
109 depth: resp.depth.unwrap_or(0),
110 author_name: sub.author_name,
111 author_url,
112 avatar_url: resp.avatar_url.unwrap_or_default(),
113 content_md: sub.content_md,
114 created_at: now.clone(),
115 stored_at: now,
116 };
117 comment_storage::save_pending_comment(sub.post_id, pending.clone());
118 pending_comments.update(|list| list.push(pending));
119 }
120 }
121 Ok(resp.message)
122 })
123}
124
125async fn poll_pending(
131 post_id: i32,
132 pending_comments: RwSignal<Vec<PendingComment>>,
133 refresh_trigger: RwSignal<bool>,
134 polling: RwSignal<bool>,
135) {
136 loop {
137 let Some(ids) = pending_comments
138 .try_with_untracked(|list| list.iter().map(|c| c.id).collect::<Vec<i64>>())
139 else {
140 return;
142 };
143 if ids.is_empty() {
144 let _ = polling.try_set(false);
146 return;
147 }
148
149 if let Ok(statuses) = check_pending_status(ids).await {
150 let to_remove: Vec<i64> = statuses
151 .into_iter()
152 .filter(|s| s.status != "pending")
153 .map(|s| s.id)
154 .collect();
155 if !to_remove.is_empty() {
156 comment_storage::remove_pending_ids(post_id, &to_remove);
157 let _ = refresh_trigger.try_update(|v| *v = !*v);
159 let _ =
160 pending_comments.try_update(|list| list.retain(|c| !to_remove.contains(&c.id)));
161 }
162 }
163 sleep_ms(PENDING_POLL_INTERVAL_MS).await;
166 }
167}
168
169#[component]
180pub fn CommentSection(post_id: i32) -> impl IntoView {
181 let pending_comments = RwSignal::new(Vec::<PendingComment>::new());
182 let ctx = CommentContext {
183 active_reply: RwSignal::new(None),
184 refresh_trigger: RwSignal::new(false),
185 pending_comments,
186 current_user: RwSignal::new(None),
187 submit: Callback::new(move |sub: CommentSubmission| {
188 submit_to_server(sub, pending_comments)
189 }),
190 image_upload: true,
191 id_scope: "post",
192 };
193 provide_context(ctx);
194
195 Effect::new(move |_| {
197 let pending = comment_storage::load_pending_comments(post_id);
198 comment_storage::prune_all_expired();
199 ctx.pending_comments.set(pending);
200 });
201
202 Effect::new(move |_| {
205 spawn_local(async move {
206 if let Ok(resp) = crate::api::auth::get_current_user().await {
207 if let Some(u) = resp.user {
208 let _ = ctx.current_user.try_set(Some(u));
209 }
210 }
211 });
212 });
213
214 let polling = RwSignal::new(false);
217 Effect::new(move |_| {
218 let has_pending = ctx.pending_comments.with(|list| !list.is_empty());
219 if has_pending && !polling.get_untracked() {
220 polling.set(true);
221 spawn_local(poll_pending(
222 post_id,
223 ctx.pending_comments,
224 ctx.refresh_trigger,
225 polling,
226 ));
227 }
228 });
229
230 let comments_resource: RwSignal<Option<Result<CommentTreeResponse, ApiError>>> =
235 RwSignal::new(None);
236 let fetch_generation = StoredValue::new(0u64);
237 Effect::new(move |_| {
238 ctx.refresh_trigger.track();
239 let Some(current) = fetch_generation.try_update_value(|g| {
240 *g += 1;
241 *g
242 }) else {
243 return;
244 };
245 spawn_local(async move {
246 let value = get_comments(post_id).await;
247 if fetch_generation.try_get_value() == Some(current) {
248 let _ = comments_resource.try_set(Some(value));
249 }
250 });
251 });
252
253 Effect::new(move |_| {
257 let approved_ids: Option<std::collections::HashSet<i64>> =
258 comments_resource.with(|data| match data {
259 Some(Ok(resp)) => Some(resp.comments.iter().map(|c| c.id).collect()),
260 _ => None,
261 });
262 let Some(approved_ids) = approved_ids else {
263 return;
264 };
265 let to_remove: Vec<i64> = ctx.pending_comments.with(|list| {
266 list.iter()
267 .filter(|p| approved_ids.contains(&p.id))
268 .map(|p| p.id)
269 .collect()
270 });
271 if !to_remove.is_empty() {
272 comment_storage::remove_pending_ids(post_id, &to_remove);
273 ctx.pending_comments
274 .update(|list| list.retain(|p| !to_remove.contains(&p.id)));
275 }
276 });
277
278 #[cfg(target_arch = "wasm32")]
283 Effect::new(move |_| {
284 let loaded = comments_resource.with(|data| matches!(data, Some(Ok(_))));
285 if !loaded {
286 return;
287 }
288 request_animation_frame(|| {
289 crate::bridges::globals::init_lightbox(&[".comment-list .comment-body"]);
291 });
292 });
293
294 let comments = Memo::new(move |_| {
295 comments_resource.with(|data| match data {
296 Some(Ok(CommentTreeResponse { comments, .. })) => Some(comments.clone()),
297 _ => None,
298 })
299 });
300 let approved_count = Memo::new(move |_| {
301 comments_resource.with(|data| match data {
302 Some(Ok(CommentTreeResponse { count, .. })) => Some(*count),
303 _ => None,
304 })
305 });
306 let failed = Memo::new(move |_| comments_resource.with(|data| matches!(data, Some(Err(_)))));
307
308 view! {
309 <LightboxAssets/>
311 <CommentSectionContent
312 post_id=post_id
313 comments=comments
314 approved_count=approved_count
315 failed=failed
316 />
317 }
318}
319
320#[derive(Clone, Copy, PartialEq)]
322enum ListState {
323 Loading,
325 Failed,
327 Empty,
329 List,
331}
332
333#[component]
335pub(crate) fn CommentSectionContent(
336 post_id: i32,
337 #[prop(into)] comments: Signal<Option<Vec<PublicComment>>>,
338 #[prop(into)] approved_count: Signal<Option<i64>>,
339 #[prop(into)] failed: Signal<bool>,
340) -> impl IntoView {
341 let ctx = expect_context::<CommentContext>();
342 let pending = ctx.pending_comments;
343 let total_count = move || {
344 approved_count
345 .get()
346 .map(|count| count + pending.with(|list| list.len()) as i64)
347 };
348
349 let list_state =
350 Memo::new(
351 move |_| match comments.with(|list| list.as_ref().map(|list| list.is_empty())) {
352 Some(true) if pending.with(|list| list.is_empty()) => ListState::Empty,
353 Some(_) => ListState::List,
354 None if failed.get() => ListState::Failed,
355 None => ListState::Loading,
356 },
357 );
358 let approved = Signal::derive(move || comments.get().unwrap_or_default());
359
360 view! {
361 <section class="comment-section" aria-labelledby="comments-title">
362 <div class="comment-section-head">
363 <h2 id="comments-title">
364 "评论"
365 {move || {
366 total_count()
367 .map(|count| {
368 view! { <span class="comment-count tabular-nums">{count}</span> }
369 })
370 }}
371 </h2>
372 <span class="comment-order">"按时间排序"</span>
373 </div>
374
375 <CommentForm post_id=post_id parent_id=None parent_indent=None/>
377
378 {move || match list_state.get() {
380 ListState::Empty => {
381 view! {
382 <p class="comment-empty has-mascot">
383 {Mascot::Hearts.img("mascot comment-empty-mascot")}
384 "还没有评论,来写下第一条吧。"
385 </p>
386 }
387 .into_any()
388 }
389 ListState::List => {
390 view! { <CommentList comments=approved pending=pending post_id=post_id/> }
391 .into_any()
392 }
393 ListState::Failed => {
394 view! {
395 <p class="comment-empty is-error" role="alert">
396 "评论暂时没能加载,刷新页面再试一次。"
397 </p>
398 }
399 .into_any()
400 }
401 ListState::Loading => {
402 view! {
403 <DelayedSkeleton>
404 <CommentListSkeleton/>
405 </DelayedSkeleton>
406 }
407 .into_any()
408 }
409 }}
410 </section>
411 }
412}