Two features: - Auto-fill comment form (name/email/url) from localStorage - Show pending comments with '审核中' badge to the submitting user Key decisions: - Two separate localStorage keys (author info + pending comments) - Pending comments stored by server-returned ID, not user identity - 7-day TTL with cleanup on page load via check_pending_status API - Separate PendingCommentItem component (no PublicComment pollution) - content_md only (no HTML in localStorage for XSS safety)
7.8 KiB
Comment localStorage Persistence + Pending Visibility
Problem
After submitting a comment, users see only a green "评论已提交,等待审核" alert. The comment is invisible to everyone (including the author) until admin approval. Additionally, returning users must re-enter their name/email/website every time.
Solution
Two localStorage-backed features:
- Form auto-fill: Save author info (name/email/url) to localStorage, pre-fill on subsequent visits.
- Pending comment visibility: Store pending comments locally by server-returned ID. Display them with a "审核中" badge, visible only to the submitting browser.
localStorage Keys
yggdrasil-comment-author
Written on every successful comment submission. Read on CommentForm mount.
{ "name": "张三", "email": "zhang@example.com", "url": "https://example.com" }
yggdrasil-pending-comments
Written on successful submission with server-returned ID. Read on CommentSection mount. Keyed by post_id (string).
{
"42": [
{
"id": 123,
"parent_id": null,
"depth": 0,
"author_name": "张三",
"author_url": null,
"avatar_url": "https://cravatar.cn/avatar/xxx?d=mp&s=80",
"content_md": "评论内容",
"created_at": "2026-06-11T10:00:00Z",
"stored_at": "2026-06-11T10:00:00Z"
}
]
}
Design decisions:
- No
content_html— XSS safety. Rendercontent_mdclient-side with HTML escaping + newline→<br>. - No
author_email— Already inyggdrasil-comment-author. Avoid PII duplication. avatar_urlcomputed at save time from the stored author email.stored_atfor 7-day TTL. Pruned on every read.- Empty post_id arrays removed on cleanup.
Server-Side Changes
1. CommentResponse — add comment_id
pub struct CommentResponse {
pub success: bool,
pub message: String,
pub error_code: Option<String>,
pub comment_id: Option<i64>, // NEW: set only on success
}
Backward-compatible: Option<i64> serde defaults to None for old callers.
2. create_comment — extract and return ID
The existing INSERT already uses RETURNING id but discards the row. Change to:
let row = client
.query_one("INSERT INTO comments ... RETURNING id", &[...])
.await
.map_err(AppError::query)?;
let comment_id: i64 = row.get(0);
All existing error paths add comment_id: None. Only the final success path sets comment_id: Some(comment_id).
3. New server function: CheckPendingStatus
#[server(CheckPendingStatus, "/api")]
pub async fn check_pending_status(ids: Vec<i64>) -> Result<Vec<(i64, String)>, ServerFnError>
- Early return empty vec if
ids.is_empty(). - Query:
SELECT id, status FROM comments WHERE id = ANY($1) - IDs not found in result → status
"gone"(soft-deleted or hard-deleted). - Client uses this to prune localStorage entries that are no longer pending.
Client-Side Changes
New module: src/hooks/comment_storage.rs
Provides use_comment_storage() hook with:
| Function | Purpose |
|---|---|
save_author(name, email, url) |
Write yggdrasil-comment-author |
load_author() -> Option<AuthorInfo> |
Read author info |
save_pending_comment(post_id, comment) |
Append to yggdrasil-pending-comments |
load_pending_comments(post_id) -> Vec<PendingComment> |
Read + prune expired (7-day TTL) |
remove_pending_ids(post_id, ids) |
Remove specific IDs, clean empty post entries |
prune_expired() |
Remove all entries across all posts older than 7 days |
All web_sys calls behind #[cfg(target_arch = "wasm32")]. Non-WASM returns defaults (empty/None).
Serialization via serde_json (already a project dependency).
CommentContext — extend with pending state
#[derive(Clone, Copy)]
pub struct CommentContext {
pub active_reply: Signal<Option<i64>>,
pub refresh_trigger: Signal<bool>>,
pub pending_comments: Signal<Vec<PendingComment>>, // NEW
}
Placing pending_comments in CommentContext ensures it survives refresh_trigger re-renders (blocker B2 from review).
CommentForm changes
- On mount: call
load_author()→ setauthor_name,author_email,author_urlsignals if localStorage has saved values. - On successful submit:
save_author(name, email, url)— persist form fields- Construct
PendingCommentfrom form data + returnedcomment_id - Compute
avatar_urlfrom stored email (same gravatar formula as server) save_pending_comment(post_id, pending)— persist pending comment- Push to
ctx.pending_commentssignal — immediate UI update
CommentSection changes
- On mount:
load_pending_comments(post_id)→ populatectx.pending_commentsprune_expired()— clean 7-day old entries- If pending IDs exist, call
check_pending_status(ids)→remove_pending_ids()for non-pending
- Rendering: merge approved + pending comments sorted by
created_at, interleaving them chronologically.
New component: PendingCommentItem
Renders pending comments distinctly from approved ones:
- Semi-transparent card style (e.g.,
opacity-70) - Amber badge: "审核中"
content_mdrendered with HTML escaping +\n→<br>- No reply button — server rejects replies to non-approved parents
- No admin actions — pending comments from localStorage are visitor-submitted
CommentList changes
Accept both comments: Vec<PublicComment> and pending: Vec<PendingComment>. Merge into a sorted iterator by created_at and render either CommentItem or PendingCommentItem per item.
For threaded display: pending replies appear under their parent_id parent, using the same depth indentation logic.
Data Flow
Submit comment
→ server returns { success: true, comment_id: 123 }
→ save_author() to localStorage
→ save_pending_comment() to localStorage
→ push to pending_comments Signal
→ UI shows comment with "审核中" badge
Page load / CommentSection mount
→ load_pending_comments(post_id) from localStorage
→ prune_expired() (7-day TTL)
→ check_pending_status(pending_ids) via server
→ remove_pending_ids() for non-pending (approved/spam/trash/gone)
→ merge approved + pending → render chronologically
Admin approves comment
→ next page load: check_pending_status() returns "approved"
→ remove_pending_ids() removes it from localStorage
→ comment appears normally via get_comments() API
Files Changed
| File | Change |
|---|---|
src/api/comments/types.rs |
Add comment_id: Option<i64> to CommentResponse |
src/api/comments/create.rs |
Extract returned ID, populate comment_id in response |
src/api/comments/mod.rs |
Export new check_pending_status |
src/api/comments/check.rs |
NEW: CheckPendingStatus server function |
src/hooks/comment_storage.rs |
NEW: localStorage hook + AuthorInfo/PendingComment structs |
src/hooks/mod.rs |
Export comment_storage module |
src/components/comments/section.rs |
Load pending, call check, merge for rendering |
src/components/comments/form.rs |
Pre-fill from localStorage, save on submit |
src/components/comments/list.rs |
Accept both comment types, merge sorted |
src/components/comments/item.rs |
No change (approved comments unchanged) |
src/components/comments/pending_item.rs |
NEW: PendingCommentItem component |
src/components/comments/mod.rs |
Export pending_item |
Testing
- Unit test:
CommentResponsedeserialization withoutcomment_id→None - Unit test:
PendingCommentserde roundtrip - Unit test:
check_pending_statuswith empty vec → empty result - Unit test:
check_pending_statuswith mix of pending/approved/gone IDs - Integration: submit comment → verify localStorage written → verify pending visible → verify cleanup after admin approval