VibeWeek
Home/Grow/Comments, Threading & @Mentions

Comments, Threading & @Mentions

⬅️ Day 6: Grow Overview

If you're building a collaborative B2B SaaS in 2026, you'll need comments — on documents (Notion / Google Docs), records (CRM / project tasks), assets (Figma / Loom), code (GitHub PRs), or content (blog moderation). The naive approach: a <textarea>, store strings, render them. The structured approach: rich text, threading, @mentions with notifications, edit/delete with history, soft-delete, real-time updates, moderation, and accessibility. Comments look simple from outside; they're easily a 3-week feature done right.

1. Decide the comment model — flat, threaded, or nested

Pick the right comment model.

Flat (linear list):
- Single timeline, no replies
- Examples: Slack channel, Twitter timeline
- Pro: simple, fast to ship
- Con: no conversation structure

Threaded (one-level reply):
- Comment with replies underneath
- Examples: Linear comments, Slack threads, GitHub PR comments
- Pro: conversation grouping; bounded depth
- Con: replies-of-replies break the model

Nested (deep tree):
- Comments with arbitrary nesting depth
- Examples: Reddit, Hacker News
- Pro: matches conversation flow
- Con: deeply nested becomes unreadable; harder to render

Inline / contextual:
- Comments anchored to specific UI elements (paragraph, line of code, image region)
- Examples: Google Docs (anchored), Figma (pinned), GitHub line comments
- Pro: context preserved
- Con: complex UX (resolving, archiving, moving with content)

For [USE CASE], pick:
- Document collaboration → inline + threaded (Google Docs style)
- Task / record discussion → threaded (Linear style)
- Public commenting → nested (Reddit) or threaded (most B2B)
- Activity log / log only → flat

Output:
1. Recommended model
2. Max depth (if nested) — typically 1-3 levels
3. Anchoring strategy (if inline)
4. Schema sketch
5. Example UI from a known product

The pragmatic default for B2B SaaS: threaded one-level. Comment + replies underneath. Limits depth, keeps UI scannable. Add inline/anchored later if needed.

2. Schema design — built to evolve

Design the comments database schema.

Core fields:
- id (uuid)
- author_user_id (fk to users)
- parent_resource_type (e.g., 'task', 'document', 'lead')
- parent_resource_id (the thing being commented on)
- parent_comment_id (nullable; null for top-level, set for replies)
- body_text (raw text)
- body_rich (rich-text JSON if applicable, e.g., Lexical / TipTap)
- created_at, updated_at, deleted_at (soft delete)
- edited (boolean; true if updated after creation)
- is_resolved (for threaded comments that can be resolved)
- thread_id (denormalized; same for parent + all replies for fast query)

Indexes:
- (parent_resource_type, parent_resource_id, created_at)
- (thread_id, created_at)
- (author_user_id, created_at)

Relations table for @mentions:
- comment_mentions
  - comment_id, mentioned_user_id, mentioned_at

Reactions table (optional):
- comment_reactions
  - comment_id, user_id, emoji

Anchoring (for inline comments):
- anchor_data (JSON: range / coordinates / element ID)

Multi-tenancy:
- tenant_id / org_id on every row (for SaaS)
- RLS policies (Postgres) or query-level filtering

Audit / moderation:
- flagged_count, hidden_at, hidden_by_user_id
- For UGC: report_count, last_reported_at

Output:
1. Postgres schema (DDL)
2. Indexes for common queries
3. RLS policies for multi-tenant
4. Soft-delete vs hard-delete decision
5. Migration path to threading if you ship flat first

The "thread_id" denormalization: makes "show me all comments on task X" + "expand thread" both fast. Without it, recursive CTEs get expensive.

3. Rich text vs plain text

Decide rich-text or plain-text for comments.

Plain text:
- Simple textarea
- Pro: easy; sanitize-once; no XSS surface
- Con: no formatting; users want bold/italic/links/code blocks

Markdown:
- User types **bold**, server renders to HTML
- Pro: lightweight; sanitize-after-render
- Con: not WYSIWYG; users have to know markdown

Rich text editor (TipTap / Lexical / Slate):
- WYSIWYG bold/italic/lists/links/mentions/embeds
- Pro: best UX; familiar to Google Docs / Notion users
- Con: complex; editor bugs; need sanitization
- Recommended for serious B2B SaaS

Hybrid:
- Markdown for input + WYSIWYG for display
- Used by: GitHub, Slack
- Best of both worlds with care

For 2026 default: TipTap or Lexical for new B2B SaaS.

Storage:
- Store as JSON (TipTap doc) + render to HTML on display
- Or: store HTML directly with sanitization (DOMPurify / sanitize-html)
- Always sanitize before render

Output:
1. Recommendation
2. Editor library choice
3. Storage format (JSON / HTML / Markdown)
4. Sanitization library + config
5. Allowed elements / attrs (for HTML mode)

The XSS rule: never render user-submitted HTML without sanitization. DOMPurify (client) + sanitize-html (server) are standard. See rich-text-editor-implementation-chat.md for editor details.

4. @Mentions — autocomplete + notifications

Implement @mentions.

Trigger:
- User types @
- Show dropdown with up to 10 people
- Filter as user types
- Up/down arrows + Enter to select; Escape to dismiss
- Tab to autocomplete first match

Search backend:
- /api/mentions/search?q=jo&context_id=task-123
- Return filtered users (those with access to context)
- Don't expose users from other orgs (multi-tenant)
- Include avatar + name + role

Mention storage:
- Mention rendered as a special node in rich-text JSON
- Or: rendered as @[user-id] in markdown
- Or: rendered as <mention data-id="..." /> in HTML

Mention notification:
- Server-side detect mentions on comment save
- Insert into notifications table
- Email + in-app notification per mention preferences
- Don't notify if author == mentioned user (don't ping yourself)

Edge cases:
- Mention removed by editor (after a save) → revoke notification (debatable)
- Mentioned user lost access to resource → don't link / hide
- @everyone / @channel — only for trusted authors; rate-limit

Search libraries:
- TipTap mention extension (built-in)
- Lexical mention plugin
- Roll-your-own with combobox

Output:
1. Dropdown UI
2. Search API with multi-tenant filter
3. Mention storage (in rich-text doc)
4. Notification dispatch
5. Permission check on read (don't link to inaccessible resources)

The multi-tenant @mentions trap: a search query "Jo" returns Joe across all orgs if you don't filter by tenant. That leaks user identity across customers.

5. Real-time updates

For collaborative tools, comments should appear without refresh.

Real-time comment updates.

Stack options:
- Supabase Realtime (Postgres LISTEN/NOTIFY auto-piped)
- Convex (live queries built-in)
- Pusher / Ably / Liveblocks (managed)
- WebSocket / SSE custom (Node + Redis)

Pattern:
1. User A posts comment → INSERT to DB → broadcast event
2. User B subscribed to channel → receives event → updates UI

Channel naming:
- comments:resource-type:resource-id
- e.g., comments:task:abc123

Subscriber filtering:
- Server-side: only broadcast to users with access to resource
- Or: client subscribes to specific channel + server checks on each event

Optimistic UI:
- User submits comment → appears immediately
- Server confirms via realtime event (de-dup by client-temp-id)
- On error: rollback + toast

Performance:
- Don't broadcast every keystroke (only on save / submit)
- Debounce typing-indicators if implemented (3 sec interval)
- Channel cleanup on unmount

Typing indicators (optional):
- "Joe is typing..."
- Debounced 3-sec window
- Multi-user merge ("Joe and Lisa are typing")

Output:
1. Realtime stack choice
2. Channel naming + subscriber permissions
3. Optimistic update pattern
4. Typing indicator (if implementing)
5. Error / reconnect handling

The typing-indicator gotcha: shows in 80% of comment products as polish. But it adds infrastructure (extra channel, debouncing, multi-user merge). Skip until v2 unless real-time collab is core.

6. Edit + delete + history

Implement edit + delete for comments.

Edit:
- Author can edit their own comment
- Other users can't edit
- Show "edited" indicator after edit
- Optional: edit history (audit trail)

Edit history (advanced):
- comment_edits table: comment_id, body, edited_at, edited_by
- "Show edit history" link reveals modal
- Most products skip this; some require for compliance

Delete:
- Soft delete (recommended): set deleted_at, body becomes "[deleted]"
- Replies to deleted comment still visible (otherwise tree breaks)
- Author can delete; admins / moderators can delete others
- Hard delete only after retention period (or never; audit logs)

Permissions:
- Author: edit + delete own
- Admin / org owner: edit + delete any
- Other users: read only
- RBAC at API level + UI hints

Time-based limits:
- Edit window: forever or 5 min (Slack uses 'no edit after few mins for spam'; Linear allows always)
- Delete window: same considerations

UI:
- Three-dot menu on each comment with Edit / Delete / Report / Copy link
- Hover or always visible (depends on density)

Output:
1. Permissions matrix
2. Soft-delete schema
3. Edit indicator UI
4. History feature (if required for compliance)
5. Time-window config

The "edited 5 min ago" subtlety: if you show "edited" forever, every old comment looks fresh-edited. Fade to "edited 5 min ago" on first hover. Don't surface forever.

7. Notification preferences

Comments generate noise. Let users tune.

Implement comment notification preferences.

Notification triggers:
- @mentioned in a comment
- Replied to a comment you authored
- New comment on a thread you're following
- New comment on a resource you're watching

User-level preferences:
- @mentions: always notify (most products force this)
- Replies: configurable
- Following / watching: opt-in
- Email frequency: instant / daily digest / off
- In-app vs email vs both

Per-resource preferences:
- "Watch this task" / "Mute this thread"
- Override default

Preference storage:
- user_notification_preferences (user_id, type, channel, enabled)
- thread_subscriptions (user_id, resource_type, resource_id, status)

UI:
- Settings page with toggles
- Per-thread "Mute" / "Watch" button

Email design:
- Subject: "Joe mentioned you in [task name]"
- Body: comment excerpt + link + reply-by-email (advanced)
- Reply-by-email parsing: complex but powerful

Push / mobile:
- Same prefs apply
- Service worker for browser push or native mobile

Output:
1. Preference schema
2. Default settings (sensible)
3. Email + push templates
4. Reply-by-email parsing (if enabled)
5. Unsubscribe link compliance

The default-settings rule: opt-out for @mentions (always notify), opt-in for thread subscriptions. Otherwise people get drowned.

8. Resolve threads

For comments that initiate work / discussion, resolution closes the loop.

Implement comment resolution.

Concept: a comment thread can be "open" or "resolved."
- Resolved threads can be hidden (filter)
- Resolved threads can be re-opened
- Often used for code review (GitHub), document feedback (Google Docs), task discussion

Schema:
- is_resolved boolean (or status enum)
- resolved_at, resolved_by_user_id

UI:
- "Resolve" button on threaded comment
- Visual: greyed-out style for resolved
- Filter toggle: "Show resolved" / "Hide resolved"

Permissions:
- Anyone with comment access can resolve
- Or: only author / mentioned can resolve
- Configurable per product

Re-opening:
- "Reopen" button on resolved comment
- Logged in audit

Notifications:
- Notify thread author + participants when resolved
- Skip for trivial threads

Common products:
- GitHub: resolve conversation in PRs
- Google Docs: resolve document comments
- Linear: resolve thread on issue
- Notion: resolve comment

Output:
1. Resolution schema
2. UI (resolve button + indicator)
3. Filter UX (default show open)
4. Permission rules
5. Notification trigger

The resolved-default rule: hide resolved by default. Show on demand. Otherwise resolved threads pollute the view.

9. Moderation — UGC concerns

For B2B internal use, moderation is light. For public / customer-facing comments, plan moderation.

Plan comment moderation (for customer-facing UGC).

Levels:

Level 1: B2B internal (most B2B SaaS)
- Trust internal users
- Retroactive moderation (admin can delete)
- Audit log of moderation actions
- Skip pre-moderation

Level 2: Customer + internal mixed
- Customer comments visible to other customers
- Internal users can moderate
- Report button → flagged comments queue
- Admins review + take action

Level 3: Public UGC (community / forum)
- Heavy moderation needed
- Pre-moderation queue (new users) or post-moderation
- Auto-detection: profanity filter, link spam, NSFW
- Trust scoring: long-tenured users moderate later

Tools:
- Profanity filter (e.g., bad-words npm; localized)
- Spam detection (Akismet, custom ML)
- NSFW image detection (cloud APIs)
- LLM-based moderation (OpenAI Moderation API; Anthropic)

Workflow:
- Flagged → moderator queue
- Moderator: keep / delete / ban user
- User notification on action
- Appeal process (for serious cases)

Legal:
- COPPA (under-13 users)
- DSA (EU Digital Services Act)
- Section 230 (US — platform protections)
- Region-specific UGC laws

Output:
1. Moderation level for [USE CASE]
2. Pre / post / no moderation
3. Tooling stack (profanity / spam / NSFW)
4. Moderator queue UI
5. Audit trail + legal compliance

The 2026 reality: LLM-based moderation (OpenAI Moderation API, Anthropic) outperforms regex / keyword filters for nuance. Layer them.

10. Accessibility — keyboard, screen reader, focus

Make comment UIs accessible.

Required:
- Comments in semantic HTML (article + author info)
- Time elements use <time datetime="..."> for screen readers
- Reply form: clearly labeled <textarea> + submit button
- Reply button has aria-label including comment ID
- @mention dropdown: combobox pattern with aria-expanded, aria-activedescendant
- Notifications: aria-live polite for new comments arriving

Keyboard:
- Tab through comments + reply buttons
- Enter to submit reply (if no shift, otherwise newline)
- Cmd/Ctrl+Enter to submit (alternative)
- Arrow keys in mention dropdown
- Escape to close mention dropdown / cancel reply

Focus management:
- After posting comment: focus moves to new comment + announces
- After deleting: focus moves to next comment or back to list
- Reply form opening: focus moves to textarea

Screen reader announcements:
- New comment via realtime: "[Author] commented at [time]: [excerpt]"
- Mention received: "[Author] mentioned you in [resource]"

Common failures:
- @mention dropdown using divs with click handlers (not keyboard)
- Reply form opens but focus doesn't move to it
- Real-time new comments appear silently (screen reader users miss)
- Time stamps as "5m ago" without underlying datetime attribute

Output:
1. Comment component HTML structure
2. Mention dropdown ARIA pattern (combobox)
3. Focus-management on actions
4. Live-region announcements
5. Test with VoiceOver + NVDA + keyboard-only

The mention dropdown is hard. WAI-ARIA combobox pattern is the spec. TipTap / Lexical mention extensions handle this; rolling your own requires careful implementation.

What Done Looks Like

A v1 commenting system for B2B SaaS in 2026:

  • Threaded one-level comments (parent + replies)
  • Rich text editor with mention support (TipTap / Lexical)
  • @mentions with autocomplete + notifications
  • Real-time updates via Supabase / Convex / Pusher
  • Edit / delete with author + admin permissions
  • Soft delete (deleted_at)
  • Notification preferences (email + in-app)
  • Thread resolution (open / resolved with filter)
  • XSS sanitization (DOMPurify / sanitize-html)
  • Multi-tenant scoping on @mention search
  • Accessibility: keyboard + screen reader support

Add later when product is mature:

  • Inline / anchored comments (Google-Docs-style)
  • Edit history with audit trail
  • Reactions / emoji
  • Reply-by-email
  • Typing indicators
  • LLM-based moderation
  • Comment search across resources

The mistake to avoid: rolling your own rich-text editor. TipTap and Lexical solved this. Use them.

The second mistake: deep nesting. >3 levels of nesting is unreadable. Stop at 1-2.

The third mistake: forgetting multi-tenant filter on @mention search. Cross-tenant user identity leak; potential GDPR violation.

See Also