docs: add comment localStorage persistence + pending visibility design spec

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)
This commit is contained in:
xfy 2026-06-11 13:52:28 +08:00
parent 880c53d2a4
commit fbda6373cc

View File

@ -0,0 +1,205 @@
# 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:
1. **Form auto-fill**: Save author info (name/email/url) to localStorage, pre-fill on subsequent visits.
2. **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.
```json
{ "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).
```json
{
"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. Render `content_md` client-side with HTML escaping + newline→`<br>`.
- **No `author_email`** — Already in `yggdrasil-comment-author`. Avoid PII duplication.
- **`avatar_url`** computed at save time from the stored author email.
- **`stored_at`** for 7-day TTL. Pruned on every read.
- Empty post_id arrays removed on cleanup.
## Server-Side Changes
### 1. `CommentResponse` — add `comment_id`
```rust
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:
```rust
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`
```rust
#[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
```rust
#[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()` → set `author_name`, `author_email`, `author_url` signals if localStorage has saved values.
- **On successful submit**:
1. `save_author(name, email, url)` — persist form fields
2. Construct `PendingComment` from form data + returned `comment_id`
3. Compute `avatar_url` from stored email (same gravatar formula as server)
4. `save_pending_comment(post_id, pending)` — persist pending comment
5. Push to `ctx.pending_comments` signal — immediate UI update
### `CommentSection` changes
- **On mount**:
1. `load_pending_comments(post_id)` → populate `ctx.pending_comments`
2. `prune_expired()` — clean 7-day old entries
3. 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_md` rendered 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: `CommentResponse` deserialization without `comment_id``None`
- Unit test: `PendingComment` serde roundtrip
- Unit test: `check_pending_status` with empty vec → empty result
- Unit test: `check_pending_status` with mix of pending/approved/gone IDs
- Integration: submit comment → verify localStorage written → verify pending visible → verify cleanup after admin approval