Reddipp Features
Everything you'd expect from a modern community platform — and more.
Built with
Content & Publishing
13 featuresRich Text EditorWYSIWYG + Markdown toggle, spoiler blocks, @mentions, code blocks, tables, image resize, video embed, community emojis.
WYSIWYG + Markdown toggle, spoiler blocks, @mentions, code blocks, tables, image resize, video embed, community emojis.
Built on TipTap (a headless wrapper around ProseMirror) with a modular extension architecture. Each capability — spoiler blocks, @mentions, tables, code blocks — is a standalone TipTap extension that can be independently enabled or disabled.
The Markdown paste extension intercepts clipboard events, detects Markdown syntax, and converts it to ProseMirror nodes on the fly. Users can paste from GitHub, StackOverflow, or any Markdown source and get rich formatting instantly — no manual conversion needed.
Image resize uses a custom NodeView with drag handles at the corners. The width is persisted as a node attribute and respected during server-side rendering. Memory leaks from event listeners are prevented by proper cleanup in the NodeView's destroy() lifecycle method.
Code blocks support syntax highlighting via the lowlight library (based on highlight.js) with automatic language detection. Users can also manually select from 40+ supported languages via a dropdown in the code block toolbar.
Community emojis are inline ProseMirror nodes with custom rendering — the editor resolves emoji IDs to image URLs and renders them at text-line height. Emoji autocomplete triggers on : prefix with fuzzy matching against both Unicode and custom community emojis.
The link dialog uses a Radix UI Dialog component with URL sanitization against XSS attacks (javascript: protocol, data: URIs are blocked). Link previews with title/favicon fetching are planned but not yet implemented.
Post TypesText, Link, Image, Gallery, Poll, Live Thread, and Event — each with type-specific validation and rendering.
Text, Link, Image, Gallery, Poll, Live Thread, and Event — each with type-specific validation and rendering.
Text posts use the full TipTap editor with all extensions. Link posts validate the URL format and will eventually fetch Open Graph metadata (title, description, thumbnail) for automatic link previews. Image posts go through the full media pipeline: upload to MinIO via presigned URL → MIME type validation → sharp thumbnail generation.
Gallery posts support multi-image uploads with drag-to-reorder. Each gallery item has an optional caption. Images are stored as individual media entries linked to the post via a postGalleryItems join table with a position column for ordering.
Polls support both single-choice and multiple-choice modes with configurable expiration (in hours). Vote counts are denormalized (pre-calculated and stored directly) on the poll option rows for fast rendering. Expired polls show results but no longer accept new votes.
Live Threads provide a continuously updating feed of short contributions from multiple authorized contributors — similar to a live blog during a breaking news event. Live threads have two states: active (accepting updates, pushed via SSE in real time) and ended (read-only archive).
Event Posts add structured event data — start/end date-time, location, timezone, online/offline toggle — as a 1:1 extension table (post_event) on regular posts. Events appear in the feed with a date/location preview, and each community has a calendar view (/r/{name}/calendar) with a month grid and upcoming event list. An iCal export (calendar.ics) lets users subscribe to community events in their calendar app.
Post CollectionsCurated post series with a navigation banner linking entries together.
Curated post series with a navigation banner linking entries together.
Collections let community moderators group related posts into an ordered series — ideal for multi-part tutorials, FAQ compilations, or 'Best Of' anthologies. Think of them as playlists for posts.
A persistent navigation banner appears on each collected post showing: collection title, current position (e.g., 'Part 3 of 7'), and prev/next links. The banner is rendered server-side for SEO and cached by Varnish.
Collections are scoped to a community and created by moderators. Each collection has its own slug + shortId for shareable URLs: /r/{community}/collection/{slug}-{shortId}.
CrosspostingShare posts across communities with full attribution to the original.
Share posts across communities with full attribution to the original.
Crossposts maintain a DB-level reference (originalPostId + originalCommunityName) to the source post. The UI renders a clear 'crossposted from r/{community}' attribution link, so the original author always gets credit.
Votes on a crosspost are tracked independently — upvoting a crosspost does not affect the original's score. This prevents vote manipulation via crosspost spam.
The crosspost URL resolves to the target community, but includes a link back to the original for context. Authors of the original are notified when their post is crossposted.
Post FlairsCommunity-defined flair badges with custom colors, text colors, and ordering.
Community-defined flair badges with custom colors, text colors, and ordering.
Each community defines its own set of post flairs — colored label badges that categorize content. Flairs have a name, background color, text color, and display position. They're stored in a dedicated community_flair table with a unique constraint on (communityId, name).
When creating or editing a post, users select from the community's available flairs via a dropdown. Flairs appear as colored badges next to post titles in feeds, search results, and the post detail view. Examples: Discussion, Tutorial, Question, News, Show & Tell.
Moderators can add, edit, reorder, and delete flairs. Some communities use flairs for content categorization, others for status tracking (Resolved, Official, Outdated).
Edit HistoryFull revision tracking for posts and comments with previous version snapshots.
Full revision tracking for posts and comments with previous version snapshots.
Every edit creates a revision entry in post_edit or comment_edit tables, storing the previous body as a TipTap JSON document along with the editor's user ID and timestamp. This means the full history of changes is preserved.
Posts and comments that have been edited display an 'edited' indicator with the edit timestamp. Users can click through to view the complete edit history and see exactly what changed between revisions.
Edit history is immutable — once a revision is saved, it cannot be modified or deleted, even by the original author. This ensures transparency in discussions, especially when comments are edited after receiving replies that reference the original text.
Post ComparisonSide-by-side split view to compare two posts — useful for duplicate detection or before/after analysis.
Side-by-side split view to compare two posts — useful for duplicate detection or before/after analysis.
The split view (/compare) renders two posts side by side with independent scroll. Users search and select posts via a dialog with Meilisearch-powered typeahead — no need to copy-paste URLs or IDs.
Use cases: comparing duplicate submissions across communities, reviewing before/after versions of updated content, or moderators verifying whether a repost adds value beyond the original.
Post ArchivingAuto-archive posts after a configurable period — no new votes or comments on archived content.
Auto-archive posts after a configurable period — no new votes or comments on archived content.
Communities can set an archive threshold (in days) after which posts become read-only. Archived posts display a lock icon and a banner explaining that new interactions are closed. Existing votes and comments remain visible.
Archiving prevents vote manipulation on old content and keeps discussions focused on recent activity. The threshold is configurable per community — a news community might archive after 30 days, while a reference community might set 365 days or disable archiving entirely.
Ephemeral PostsPosts that auto-archive after a user-defined time window — perfect for flash discussions, time-limited AMAs, and temporary announcements.
Posts that auto-archive after a user-defined time window — perfect for flash discussions, time-limited AMAs, and temporary announcements.
When creating a post, users can enable an expiration timer and choose a duration (1h, 6h, 12h, 1d, 3d, or 7d). The post automatically archives when the timer runs out — no more votes, no more comments.
A countdown badge appears on the post card and detail view, showing the remaining time in a human-readable format (e.g., "Expires in 5h 23m"). The badge turns red when less than 1 hour remains.
A background cron job (expire-ephemeral) runs periodically and archives all posts where expiresAt <= now(). Uses the Drizzle query builder with a partial index on expiresAt WHERE archivedAt IS NULL AND deletedAt IS NULL for efficient scans.
Discussion ForkFork a deep comment thread into its own standalone post — with bidirectional linking between the original comment and the new discussion.
Fork a deep comment thread into its own standalone post — with bidirectional linking between the original comment and the new discussion.
When a comment sub-thread grows beyond what the parent post can contain, any community member can fork it into a new post. The fork inherits the comment body as a blockquote and links back to the original context.
A backlink comment is automatically posted under the original comment, directing readers to the new forked discussion. The forked post displays a purple "Forked Discussion" badge on all post card variants (Card, Classic, Compact) and a banner on the detail page linking to the source.
The fork respects community rules: locked and archived posts cannot be forked, and only community members can initiate a fork. Users can optionally provide a custom title or let the system generate one from the comment text.
Scheduled & Recurring PostsSchedule posts for future publication or set up recurring auto-posts on a daily, weekly, biweekly, or monthly cadence.
Schedule posts for future publication or set up recurring auto-posts on a daily, weekly, biweekly, or monthly cadence.
Scheduled Posts let any user choose a future date/time when creating a post. The post stays invisible until the scheduled time — a background worker publishes it automatically. Users can manage, publish immediately, or cancel their scheduled posts from the Settings page.
Recurring Post Templates are a moderator tool for auto-generating posts on a repeating schedule — perfect for weekly discussion threads, daily Q&A threads, or monthly feedback rounds. Templates support daily, weekly, biweekly, and monthly frequencies with timezone-aware scheduling.
Scheduled posts use two DB columns: scheduledAt (when to publish) and publishedAt (when actually published). All feed queries filter on publishedAt IS NOT NULL, so unpublished posts never leak into public feeds. A partial index on scheduledAt WHERE publishedAt IS NULL makes the worker query efficient even with millions of posts.
The publishing worker runs every 30 seconds and uses FOR UPDATE SKIP LOCKED for safe multi-instance operation. Recurring templates are evaluated every 60 seconds, with computeNextRun() handling timezone-aware date math including DST transitions.
Post TemplatesStructured post forms with custom fields — moderators define templates, users fill in the fields.
Structured post forms with custom fields — moderators define templates, users fill in the fields.
Post Templates allow community moderators to define structured forms (e.g., Bug Report, Feature Request, Question) with custom fields: text, textarea, select dropdowns, and checkboxes. Each field can be required and have placeholder text or predefined options.
When creating a post, users choose a template and fill in the fields. The content is automatically formatted into a structured TipTap document with bold labels and field values — ensuring consistent, high-quality posts.
Templates are managed via drag-and-drop reordering in community settings (requires config mod permission). The backend enforces unique template names per community and supports full CRUD plus batch reordering.
NSFW / Spoiler TagsContent warnings with blur overlays and click-to-reveal toggles.
Content warnings with blur overlays and click-to-reveal toggles.
NSFW (Not Safe For Work) and Spoiler are independent boolean flags on posts. Both trigger a CSS blur overlay on thumbnails and inline images. The overlay includes a label and a click-to-reveal interaction.
Users can configure their preferences: auto-reveal NSFW content, permanently hide NSFW, or keep the default blur. Spoiler content always requires explicit reveal regardless of user settings — this protects users from accidental spoilers.
Moderators can toggle NSFW/Spoiler flags on any post in their community. The change is logged in the mod actions log with the moderator's identity and timestamp.
Communities
13 featuresCommunity Creation & ManagementCustom theme colors, community icon, banner image, and custom vote icons.
Custom theme colors, community icon, banner image, and custom vote icons.
Communities store their visual identity in a JSONB settings column: primary color, accent color, icon URL, banner URL, and optional custom upvote/downvote icon URLs. All assets are uploaded to MinIO via presigned URLs and processed by the media pipeline.
The theme system applies community colors to the header, buttons, and accent elements when viewing that community. Colors are injected as CSS custom properties, allowing the existing Tailwind utilities to adapt automatically — no per-community stylesheet needed.
Custom vote icons replace the default up/down arrows with community-specific graphics (e.g., a flame/water drop for a cooking community, a rocket/crash for a space community). Icons are SVGs uploaded by moderators and served from the media CDN.
Visibility ModesPublic, Restricted, and Private communities with a join-request workflow.
Public, Restricted, and Private communities with a join-request workflow.
Three visibility modes: Public (default — anyone can view, post, and comment), Restricted (anyone can view, but only approved members can post), and Private (only members can view content; outsiders see a locked landing page with a 'Request to Join' button).
Private communities feature a join-request workflow: users submit a request with an optional message explaining why they want to join (e.g., 'I run a homelab and want to share my Docker setups!'). Community admins see a queue of pending requests and can approve or reject.
The join request stores: status (pending/approved/rejected), reviewedBy (who decided), reviewedAt, and the applicant's message. Approved users are automatically added as community members. Rejected users receive a notification.
Community RulesNumbered rule list integrated with the reporting and moderation system.
Numbered rule list integrated with the reporting and moderation system.
Rules are stored per community with a title, body (rich text), and position. They appear in the community sidebar and on the post submission page as a reminder.
When users report content, they select which community rule was violated. This ties reports directly to enforceable policies and helps moderators quickly assess whether a report is valid.
Rules are managed by community moderators via a drag-to-reorder interface. Changes are logged in the audit trail. The rule system is separate from site-wide content policies managed by platform admins.
Sidebar WidgetsText blocks, rule summaries, and custom button widgets — fully customizable.
Text blocks, rule summaries, and custom button widgets — fully customizable.
The sidebar supports multiple widget types: rich text (arbitrary formatted content), rules (auto-generated from community rules), and button widgets (links styled as action buttons with icons).
Widgets are stored as a JSONB array in community settings. Moderators can add, remove, reorder, and configure widgets via the community settings panel. Each widget type has its own configuration schema validated by Zod.
The widget system is extensible — new widget types can be added by defining a Zod schema, a settings component, and a rendering component. Planned widget types include: upcoming events, related communities, and pinned links.
Menu LinksHierarchical navigation structure for community-specific pages and resources.
Hierarchical navigation structure for community-specific pages and resources.
Communities can define custom menu links organized in a tree structure (parent/child relationships via parentId). Links appear in the community header navigation bar.
Each link has: title, url (internal or external), position, and optional parentId. External links open in a new tab. Internal links navigate within the community context.
Use cases: linking to community wikis, Discord servers, GitHub repos, filtered views, or curated resource lists that moderators want to highlight.
Community EmojisCustom emoji images usable in posts and comments within the community.
Custom emoji images usable in posts and comments within the community.
Moderators upload custom emoji images (PNG/GIF, max 256KB) with a unique name. Emojis become available as inline elements in the TipTap editor and as reaction options within that community's context.
Emojis are stored as media files in MinIO and referenced by a communityEmoji table linking: communityId, name, and imageUrl. The seed includes thematic emojis for 10 communities (e.g., :shipit: for programming, :flame: for gaming).
In the editor, typing : triggers emoji autocomplete with fuzzy matching across both Unicode emojis and community-specific custom emojis. Custom emojis render at text-line height in posts and at 20×20px in reaction badges.
User FlairsFlair templates with custom colors, editable text, and mod-only options.
Flair templates with custom colors, editable text, and mod-only options.
Each community defines user flair templates specifying: display text (optionally user-editable), background color, text color, and whether the flair is restricted to moderators. Unlike post flairs (which categorize content), user flairs categorize people.
Users assign themselves a flair from the community's available templates. If the template allows text editing, users can customize the display text (e.g., change 'Developer' to 'Senior Developer — Python'). Flairs appear next to usernames in posts and comments within that community.
Mod-only flairs (e.g., 'Official', 'Verified Expert') can only be assigned by moderators, providing a way to visually identify trusted contributors without giving them moderation privileges.
Quarantine SystemCommunity quarantine with a consent interstitial before viewing content.
Community quarantine with a consent interstitial before viewing content.
Quarantine is a containment mechanism for communities that violate platform policies but aren't severe enough to be removed entirely. Quarantined communities are hidden from search, trending lists, and recommendations.
Users attempting to access a quarantined community see an interstitial consent page explaining the quarantine reason and requiring an explicit opt-in before viewing content. Consent is stored per-user in quarantineConsents and persists across sessions.
Quarantine decisions are recorded in the site-wide audit log with the admin's identity and reasoning. The quarantine reason is displayed on the interstitial page and in the community header for transparency.
Traffic Analytics30-day metrics dashboard with page views, unique visitors, and growth trends.
30-day metrics dashboard with page views, unique visitors, and growth trends.
Community moderators access a traffic analytics panel showing daily breakdowns of: pageViews, uniqueVisitors, newPosts, newComments, subscriptions, and unsubscriptions.
Metrics are stored in a community_traffic table with a composite primary key of (communityId, date). Charts visualize trends over the trailing 30-day window with weekday/weekend patterns clearly visible.
Traffic data reveals actionable patterns: weekday vs. weekend activity, growth spikes from viral content, and subscriber churn. Moderators use this to time announcements, events, and content initiatives for maximum reach.
Community SettingsDedicated settings panel for editing display name, description, and community configuration.
Dedicated settings panel for editing display name, description, and community configuration.
Community moderators and admins access a General tab in community settings to edit the community's display name (up to 100 characters) and description (up to 5000 characters). Both fields include real-time character counters and trim validation.
Uses useSuspenseQuery for loading current community data and useMutation for save operations with optimistic UI feedback. Successful saves show a 3-second confirmation message; failures display error state.
Changes are persisted via the updateCommunity server function with permission checks and immediately reflected across all views via React Query cache invalidation.
Content Aging IndicatorConfigurable warning banner on posts that may contain outdated information.
Configurable warning banner on posts that may contain outdated information.
Communities can set a content age threshold (in days) via community settings. Posts older than the threshold display a prominent amber banner: 'This post is X months old. Information may be outdated.'
The banner includes a 'Start new discussion about this topic' button that navigates to the post creation form pre-filled with: the same community, a title referencing the original post, and a TipTap body containing a backlink to the original discussion.
The feature is purely frontend-based — it uses the post's createdAt timestamp and the community's contentAgeThresholdDays setting (stored in the JSONB settings column). No schema migration needed. Setting the threshold to 0 disables the feature.
Welcome & OnboardingCustomizable welcome message and onboarding flow for new community members.
Customizable welcome message and onboarding flow for new community members.
When a user joins a community for the first time, they see a welcome message configured by the moderators. The message can include community guidelines, introductory resources, and links to important threads.
Moderators configure the onboarding experience via community settings — including a custom welcome text, suggested first actions, and highlighted posts. This helps new members integrate into the community culture from day one.
Community WikiCollaboratively maintained knowledge base per community with full revision history and diff view.
Collaboratively maintained knowledge base per community with full revision history and diff view.
Each community has a wiki at /r/{name}/wiki — a collection of pages maintained collectively by members. Wiki pages use the full TipTap rich text editor, supporting headings, lists, code blocks, and all other formatting options.
Edit permissions are configurable per community: all visitors, members only, or moderators only. The setting is stored in the community JSONB settings column (wiki.editPermission) and enforced server-side on every write.
Every edit creates a revision entry in wiki_page_revision, storing the previous body as TipTap JSON. The revision history page (/r/{name}/wiki/{slug}/revisions) shows all past versions with an expandable inline diff for each revision.
The diff view uses a LCS (Longest Common Subsequence) algorithm on markdown-serialized versions of the TipTap document. Lines are classified as added, removed, or unchanged and rendered with green/red background coloring — similar to a git diff view.
Wiki pages are accessible to all visitors but creation and editing are permission-gated. The sidebar shows a Wiki link in the community info widget for easy discovery. Wiki pages are soft-linked from the community info panel.
Voting & Ranking
6 featuresUpvotes & DownvotesInstant optimistic updates with custom vote icons per community.
Instant optimistic updates with custom vote icons per community.
Votes are stored in dedicated post_votes and comment_votes tables with composite primary keys (userId, targetId). This prevents double-voting at the DB level — a user can only have one vote per post/comment, enforced by the primary key constraint.
The frontend applies optimistic updates immediately on click: the vote count changes, the button state updates, and a mutation is fired in the background. If the server request fails, the optimistic state is rolled back with a toast notification. This makes voting feel instant even on slow connections.
Vote scores (upvotes, downvotes, score) are denormalized (pre-calculated and stored) on the post/comment row for fast feed rendering. The user's own vote state is loaded via a separate /me/votes endpoint — this pattern ensures public feed data can be aggressively cached by Varnish while user-specific data is always fresh.
Communities can override the default arrow icons with custom SVGs (e.g., a rocket/bomb, flame/water drop). Custom vote icon URLs are stored in the community settings JSONB column.
Hot RankingTime-decay algorithm that surfaces trending content.
Time-decay algorithm that surfaces trending content.
Formula: sign(score) * log10(max(|score|, 1)) + age_in_hours / 12.5. This is the classic Reddit Hot algorithm — a post with 10 upvotes posted now ranks higher than a post with 1000 upvotes posted 2 days ago.
Hot scores are pre-computed in Redis sorted sets by the background worker. The feed endpoint reads directly from Redis for sub-millisecond response times. Scores are refreshed periodically (every 5-10 minutes for active posts).
The time decay ensures fresh content always surfaces, while the logarithmic score component means each additional order of magnitude of votes has the same ranking impact (going from 1 → 10 votes gives the same boost as going from 100 → 1000).
Best Ranking (Wilson Score)Statistically robust sorting that accounts for sample size.
Statistically robust sorting that accounts for sample size.
Implements Wilson Score Lower Bound — a statistical method from the field of survey sampling. The formula gives a lower bound estimate of the true positive ratio, accounting for the uncertainty that comes with small sample sizes.
Why this matters: a comment with 5 upvotes / 0 downvotes (100% positive, but only 5 data points) ranks higher than one with 100 upvotes / 80 downvotes (55% positive, but 180 data points). The small-sample comment is *probably* good; the large-sample comment is *definitely* controversial.
Wilson Score is the default sort for comments because it naturally handles the cold-start problem — new comments with a few early upvotes get a fair ranking instead of being buried by older comments with more total votes. The confidence parameter z=1.96 corresponds to a 95% confidence interval.
Controversial RankingSurfaces content with a balanced mix of upvotes and downvotes.
Surfaces content with a balanced mix of upvotes and downvotes.
Formula: (ups + downs) / max(abs(ups - downs), 1). Content where the community is most divided appears first. A post with 500 up and 500 down ranks higher than one with 1000 up and 0 down.
The algorithm rewards engagement volume while penalizing consensus. This is useful for discovering genuinely polarizing discussions, unpopular opinions that have merit, and topics where the community is split 50/50.
Controversial sort is available as a feed sorting option alongside Hot, New, Top, and Best. It's intentionally not the default to avoid promoting divisive content in the primary feed — users opt into it explicitly.
Top Ranking with Time FiltersSimple score sorting filterable by hour, day, week, month, year, or all time.
Simple score sorting filterable by hour, day, week, month, year, or all time.
Sorts by raw score (ups - downs) within a selected time window. No complex scoring formula — just the highest-voted content within the chosen timeframe.
Available windows: Past Hour, Today, This Week, This Month, This Year, All Time. The time filter constrains the query to posts created within the chosen period via a simple date comparison.
Top + All Time is useful for discovering the best content ever posted to a community. Top + Today is useful for catching up on what happened today. The time filter persists in the URL query string for shareable filtered views.
RisingVelocity-based ranking that detects posts gaining traction right now.
Velocity-based ranking that detects posts gaining traction right now.
Rising tracks the rate of upvotes over time, not just total score. A post with 20 votes in the last 30 minutes ranks higher than one with 200 votes accumulated over two days — it surfaces content that is actively gaining momentum.
The algorithm compares recent vote velocity against a baseline, highlighting posts that are outperforming their expected engagement curve. This fills the gap between New (too noisy) and Hot (already established) — Rising catches posts in the critical early-traction phase.
User Features
12 featuresSocial LoginOne-click sign-in via Google, GitHub, and Discord — or classic email/password.
One-click sign-in via Google, GitHub, and Discord — or classic email/password.
Three OAuth providers are supported out of the box: Google, GitHub, and Discord. Each provider is conditionally enabled — buttons only appear in the UI when the corresponding client credentials are configured. No dead buttons, no confusing error messages.
Account linking allows users to connect multiple OAuth providers to a single account. A user who registered with email/password can later link their GitHub account for faster login — or vice versa. All three providers are marked as trusted, so linking is seamless without additional verification.
The OAuth flow is handled by Better Auth with session-based authentication (no JWT). After a successful OAuth handshake, a server-side session is created in PostgreSQL and cached in Redis. The user experience is identical regardless of login method — OAuth users get the same session, permissions, and features as email/password users.
Passkeys (WebAuthn)Passwordless login via biometrics (Face ID, Touch ID, Windows Hello) or hardware security keys.
Passwordless login via biometrics (Face ID, Touch ID, Windows Hello) or hardware security keys.
Passkeys use the WebAuthn standard to authenticate users without passwords. The browser creates a public-key credential bound to the device — the private key never leaves the hardware. Authentication is a single tap or glance: Touch ID, Face ID, Windows Hello, or a YubiKey.
Built on the `@better-auth/passkey` plugin (wrapping @simplewebauthn/server and @simplewebauthn/browser). The login form shows a "Sign in with Passkey" button when WebAuthn is available, and the username input includes autocomplete="username webauthn" for Conditional UI — the browser can suggest passkeys directly in the form field.
Users manage their passkeys in Settings > Account: register new passkeys with optional names (e.g. "MacBook Touch ID"), rename them, or delete them. Multiple passkeys per account are supported for cross-device access.
User ProfilesBio, avatar, karma, level, XP, streaks, and achievement showcase.
Bio, avatar, karma, level, XP, streaks, and achievement showcase.
Profiles display: username, display name, bio, avatar, join date, aggregated karma (post + comment), current level and XP progress bar, active streak count, and up to 5 pinned achievements. Karma is a reputation score calculated from all net votes (upvotes minus downvotes) received on your content.
Avatars are processed through the media pipeline: uploaded to MinIO, validated (size, MIME type), and thumbnailed via sharp to multiple sizes (32px, 64px, 128px). A fallback DiceBear SVG avatar is automatically generated from the username hash for users without uploads.
`postKarma` and `commentKarma` are tracked separately and recalculated by the background worker. Profile pages also show: recent posts, recent comments, communities the user moderates, and user flair assignments.
Follows & BlocksFollow users to see their content, or block to hide them entirely.
Follow users to see their content, or block to hide them entirely.
Following a user adds their posts to your home feed's 'Following' tab. Follows are stored in a user_follow association table with a composite PK (followerId, followedId) and a CHECK constraint preventing self-follows.
Blocking hides all content from the blocked user: their posts, comments, and chat messages become invisible to you. Blocked users also cannot see your content or send you messages — the block is bidirectional in effect but only the blocker can undo it.
The system enforces no overlap: you cannot follow and block the same user simultaneously. Both are stored as lightweight association tables with composite primary keys.
Saved ContentBookmark posts and comments for later reading.
Bookmark posts and comments for later reading.
Users can save (bookmark) any post or comment with a single click. Saved items appear in a dedicated /saved page accessible from the user menu. Think of it as a personal reading list.
Saves are private — no other user can see what you've bookmarked. Saved content is stored in saved_post and saved_comment association tables with composite primary keys (userId, postId/commentId).
The saved page supports filtering by type (posts/comments) and sorting by save date. Saves persist even if the original content is later deleted (a [deleted] placeholder is shown).
Hidden PostsHide individual posts from your feed without blocking the author.
Hide individual posts from your feed without blocking the author.
Hidden posts are filtered out of all feed views for that user. Unlike blocking (which hides all content from a user), hiding is per-post: the author's other content remains visible.
Hidden posts are stored in a hidden_post table (userId, postId composite PK). A dedicated /hidden page lets users review and unhide previously hidden content.
The hide action is available on every post via the overflow menu (⋯). Hidden posts are excluded from feed queries via a NOT EXISTS subquery — this means the filtering happens at the database level, not in the frontend.
Custom FeedsMultireddit-style aggregated feeds combining multiple communities.
Multireddit-style aggregated feeds combining multiple communities.
Users create named custom feeds (e.g., 'Tech News', 'Creative Hobbies') that aggregate posts from a curated set of communities. Custom feeds appear in the sidebar for quick access — like a personal homepage combining your favorite communities.
Each custom feed supports the same sorting options as the main feed: Hot, New, Top, Best, Controversial. The feed is computed by querying posts from all member communities in a single SQL query.
Custom feeds are stored in custom_feed and custom_feed_communities tables. The URL pattern /feed/{username}/{slug} makes custom feeds shareable — other users can view (but not modify) your custom feeds.
Real-time NotificationsReply, mention, award, chat, and moderation events via SSE.
Reply, mention, award, chat, and moderation events via SSE.
Notifications are pushed to the client via Server-Sent Events (SSE) — a lightweight, unidirectional protocol where the server pushes events to the client over a persistent HTTP connection. No polling needed. The SSE connection is established on page load and auto-reconnects on disconnection.
Notification types: reply to post, reply to comment, @mention, award received, chat message, moderator action (post removed, ban issued), and system announcements. Each type has its own icon, copy, and deep-link to the relevant content.
Unread counts update in real time in the navigation bell icon. Notifications are persisted in the database for offline users and marked as read individually or in bulk.
Cross-instance delivery uses Redis Pub/Sub: when any API instance creates a notification, it publishes to a Redis channel. All SSE-connected instances receive the event and push to relevant connected clients — this enables horizontal scaling.
Yearly RecapSpotify Wrapped-style personalized annual summary with animated slides and shareable links.
Spotify Wrapped-style personalized annual summary with animated slides and shareable links.
On-demand data generation via 7 parallel SQL queries using PERCENT_RANK (community percentiles), Gaps-and-Islands algorithm (activity streaks), and window functions. Produces 9 recap types: top 10 posts/comments (by score), community breakdown with percentile badges, vote statistics, longest consecutive streak, monthly activity heatmap, and fun facts. Stored as JSONB in user_recaps with composite PK (userId, year).
10 animated slides with smooth fade/slide transitions: Intro, Summary (CountUp animation), Top Posts & Comments (staggered reveal with links), Communities (animated bar chart + percentile badges), Vote Stats, Streak (flame icon), Activity Heatmap (monthly bars), Fun Facts (typewriter animation), and Share (confetti particles). Supports keyboard, touch, and swipe navigation with a progress bar.
Sharing via createShareToken generates a 24-char NanoID, stored idempotently. Shared recaps are fetched via a public cached endpoint. The share slide includes confetti animation, copy-to-clipboard, and token revocation. Recaps are generated on first access and cached as JSONB — subsequent requests skip recomputation.
Feed View ModesThree layout options — Card, Classic, and Compact — for different browsing styles.
Three layout options — Card, Classic, and Compact — for different browsing styles.
Card view shows large thumbnails, full preview text, and generous spacing — ideal for image-heavy communities and casual browsing. Classic view mimics the traditional Reddit layout with compact rows and small thumbnails. Compact view maximizes information density with minimal spacing — suited for power users who scan headlines quickly.
The selected view mode persists in the URL query string and in user preferences. Each community can set a default view mode, but users can always override it. The layout switch is instant — no page reload, just a CSS/component swap.
Light / Dark ModeFull theme support with system preference detection, manual toggle, and semantic design tokens.
Full theme support with system preference detection, manual toggle, and semantic design tokens.
Semantic design tokens (surface, ink, edge) via CSS custom properties switch automatically between light and dark mode. Components use bg-surface, text-ink, border-edge instead of hardcoded color values.
Three modes: Dark (default), Light, and System (follows prefers-color-scheme). The preference is stored in localStorage and can be toggled via Command Palette (Cmd+K) or Settings.
All UI components, including third-party integrations (TipTap editor, Radix UI primitives, syntax highlighting), are themed consistently through Tailwind CSS v4's @theme inline and @custom-variant dark.
User SettingsPreferences, account management, email, and privacy controls.
Preferences, account management, email, and privacy controls.
Settings are organized into sections: Profile (bio, avatar, display name), Account (email, password, 2FA), Feed (default sort, content preferences, NSFW visibility), Notifications (per-type opt-in/out), and Privacy (profile visibility, activity history).
Authentication is session-based via Better Auth with scrypt password hashing (from @noble/hashes). Sessions are stored in PostgreSQL and cached in Redis for fast validation on every request.
Account management includes: email change (with verification email), password change, OAuth account linking (see Social Login), account deletion with a 30-day grace period and cancellation option, and active session management — view all signed-in devices (browser, OS, IP, last active), revoke individual sessions, or sign out all other devices at once.
Gamification
6 featuresAchievementsBronze → Silver → Gold → Platinum tier milestones, auto-evaluated from activity.
Bronze → Silver → Gold → Platinum tier milestones, auto-evaluated from activity.
Achievements are defined as rule-based milestones with four tiers: Bronze (easy, e.g., 'First Post'), Silver (moderate, e.g., '100 Upvotes Received'), Gold (hard, e.g., '1000 Comment Karma'), and Platinum (exceptional, e.g., 'Top 10 All-Time Post').
The background worker evaluates achievement criteria periodically against user activity. Newly earned achievements trigger a notification and an XP bonus. The evaluation is idempotent — re-running doesn't duplicate awards.
Users can pin up to 5 achievements to their profile showcase. Each tier has distinct visual styling: bronze circle, silver shield, gold star, platinum diamond. Achievement cards show: name, description, tier badge, and earn date.
Achievement definitions are stored in the achievements table, making it easy to add new achievements without code changes. The evaluation logic uses SQL queries against activity tables (posts, comments, votes, karma).
XP & LevelsActivity-based experience points with level progression.
Activity-based experience points with level progression.
XP is awarded for various activities: posting (+10 XP), commenting (+5 XP), receiving upvotes (+1 XP per vote), earning achievements (+50/100/200/500 XP by tier), and maintaining streaks (+5 XP per streak day). All XP grants are logged in the user_xp_log table for auditability.
Level thresholds follow a quadratic curve: Level 1 = 0 XP, Level 2 = 100 XP, Level 3 = 400 XP, Level 10 = 10,000 XP. Early levels are quick to achieve; later levels require sustained engagement over weeks or months.
The profile displays: current level, total XP, XP needed for next level, and a visual progress bar. Level badges appear next to usernames in comments and posts (optional, configurable in user settings).
StreaksDaily activity tracking with consecutive-day counters.
Daily activity tracking with consecutive-day counters.
A streak increments each calendar day a user performs a qualifying action (post, comment, or vote). The streak resets to 0 after a missed day. Both currentStreak and longestStreak (all-time record) are tracked independently.
Streaks are stored in the user_streaks table with: userId, currentStreak, longestStreak, lastActivityDate. The background worker checks daily for missed days and resets expired streaks.
Active streaks earn bonus XP multipliers: 7-day streak = 1.5x XP, 30-day streak = 2x XP, 100-day streak = 3x XP. Streak badges (flame icons with day count) appear on user profiles as a visual incentive.
AwardsGold, Silver, Platinum, Helpful, and Wholesome awards for posts and comments.
Gold, Silver, Platinum, Helpful, and Wholesome awards for posts and comments.
Users spend coins to give awards to content they appreciate. Award types with escalating value:
- Silver (100 coins): Cosmetic badge only
- Helpful (150 coins): Green badge, signals useful content
- Wholesome (150 coins): Pink badge, signals heartwarming content
- Gold (500 coins): Badge + 1 week Premium for the recipient
- Platinum (1800 coins): Badge + 1 month Premium for the recipient
Awards are stored in post_awards and comment_awards tables. Each award records: type, giver, recipient, coin cost, and timestamp. The awarded content displays badges in a compact row with counts. Award recipients earn bonus karma proportional to the award value.
Admin Panel: Full CRUD for award types (name, icon, cost, description, sort order). Award types can be enabled/disabled without deleting. Usage statistics show which awards are most popular. Types with existing awards cannot be deleted — only disabled.
CoinsVirtual currency earned through activity, spent on awards.
Virtual currency earned through activity, spent on awards.
Every new user starts with 500 coins. Additional coins are earned by: receiving awards (+coins proportional to award value), maintaining long streaks, and through Premium subscriptions (monthly coin stipend of 700 coins).
Coins can only be spent on giving awards to other users' content. The coin balance is displayed in the navigation header and in the award dialog. Coin transactions are atomic — if the award fails for any reason, coins are not deducted.
The coin economy is designed to be self-sustaining: coins flow from givers to earners, creating a positive-sum cycle where appreciated content creators accumulate coins to reward others in turn.
Admin: Gamification ManagementFull admin panel for managing achievements, XP, streaks, and leaderboards.
Full admin panel for managing achievements, XP, streaks, and leaderboards.
The Gamification Admin (/admin/gamification) provides a comprehensive dashboard with 6 tabs: Overview (aggregate stats for achievements, XP, and streaks), Achievements (full catalog with click-to-expand user lists), User Awards (search any user, view progress, grant/revoke achievements), XP & Levels (top earners + recent XP activity feed), Streaks (active streak leaderboard with freeze token counts), and Leaderboards (view and manually refresh Redis-backed leaderboards).
Admins can manually grant or revoke any achievement for any user — useful for event awards, bug compensation, or community recognition. The grant/revoke actions go through the same domain services as automatic evaluation, ensuring XP rewards and notifications are properly triggered.
The overview dashboard shows: total achievement definitions, total unlocks, unique users with achievements, total XP granted, average/max user XP, max level reached, active streaks, longest active/all-time streaks, and available freeze tokens. A "Most Popular Achievements" table shows the top 20 most-unlocked achievements.
Chat & Real-time
5 featuresDirect Messages & Group ChatPrivate DMs and group chat rooms with real-time delivery via WebSocket.
Private DMs and group chat rooms with real-time delivery via WebSocket.
Chat is built on native Bun WebSockets for bidirectional communication — no external library like socket.io or ws needed. Chat rooms support 1:1 DMs and multi-user groups. Messages are persisted in chatMessages and delivered in real time.
Data model: chatRooms (with type: 'dm' or 'group'), chatMembers (with join timestamps and roles), and chatMessages (with body, optional media attachments, and editedAt for message editing support).
Offline users receive messages on their next connection — the chat UI loads the most recent 50 messages initially and supports infinite scroll upward for history. Unread message counts are tracked per-room per-user.
Group chats support: room names, member management (add/remove/leave), and admin roles. DM rooms are auto-created on first message between two users — no manual room creation needed.
Message ReactionsEmoji reactions on individual chat messages.
Emoji reactions on individual chat messages.
Users can react to chat messages with emoji. Reactions are stored in chatReactions with a composite PK (messageId, userId, emoji). Each user can add multiple distinct reactions to a single message.
The UI shows reaction counts grouped by emoji below the message. Hovering shows who reacted. Clicking your own reaction toggles it off. The reaction palette shows frequently used emojis first.
Reactions are delivered in real time via WebSocket — other chat participants see new reactions appear instantly without page refresh.
Server-Sent Events (SSE)Unidirectional real-time stream for notifications, vote counts, and new content alerts.
Unidirectional real-time stream for notifications, vote counts, and new content alerts.
SSE (Server-Sent Events) provides a persistent, unidirectional HTTP connection for server-to-client events. Unlike WebSocket, SSE is one-way (server → client only), which makes it simpler, works through HTTP proxies and CDNs, and auto-reconnects on disconnection.
SSE is used for: notification delivery, live vote count updates on currently viewed posts, new post alerts in active feeds, and live thread updates. The SSE endpoint uses Redis Pub/Sub for cross-instance distribution.
When an event occurs (new comment, vote cast, notification created), the originating API instance publishes to a Redis channel. All SSE-connected instances receive the event and push to relevant connected clients — enabling horizontal scaling without sticky sessions.
WebSocket CommunicationBidirectional real-time channel for chat and interactive features.
Bidirectional real-time channel for chat and interactive features.
WebSocket connections are managed by Bun's native WebSocket API — zero external dependencies. Authentication is validated during the HTTP upgrade handshake using the session cookie. Invalid sessions are rejected before the WebSocket connection is established.
Messages are JSON-encoded with a type discriminator (e.g., { type: 'chat_message', roomId, body }). The server routes messages to the appropriate handler based on type. This pattern allows extending WebSocket functionality without protocol changes.
For horizontal scaling, WebSocket messages are published to Redis Pub/Sub. Each server instance subscribes to relevant channels and forwards messages to locally connected clients. A user connected to Server A sending a message to a user on Server B works seamlessly.
Live ThreadsReal-time collaborative threads for breaking news and live event coverage.
Real-time collaborative threads for breaking news and live event coverage.
Live threads are a special post type with continuously updating content — similar to a live blog. Multiple authorized contributors can post short updates that appear in real time for all viewers via SSE.
The data model uses three tables: liveThreads (status, endedAt), liveThreadContributors (userId, role), and liveThreadUpdates (body, stricken flag). The stricken flag lets contributors cross out updates that turn out to be incorrect — a built-in correction mechanism.
Live threads support two statuses: active (accepting new updates in real time) and ended (read-only archive). When a live thread ends, it becomes a permanent record of the event with all updates preserved.
Use cases: sports event coverage, breaking news, AMAs (Ask Me Anything — a Q&A format where someone answers community questions in real time), product launches, election results. The real-time nature makes them ideal for fast-moving situations where traditional comment threads are too slow.
Moderation
14 featuresMod ActionsLock, Remove, Pin, NSFW-Tag, and Distinguish for posts and comments.
Lock, Remove, Pin, NSFW-Tag, and Distinguish for posts and comments.
Moderators have granular control over content in their communities:
- Lock: Prevents new comments on a post. Useful for threads that have devolved into unconstructive arguments.
- Remove: Hides content from feeds but preserves it for audit. Shows
[removed]to regular users; moderators can still see the original. - Pin: Surfaces important posts at the top of the community feed (sticky). Supports both community-level and comment-level pinning.
- NSFW/Spoiler toggle: Add content warnings to any post, even if the author didn't mark it.
- Distinguish: Highlight your own comment with a colored mod/admin badge to speak officially.
All mod actions are recorded in the modActions table with: action type, target (post/comment ID), moderator identity, reason, and timestamp. This creates a complete audit trail for community governance. Only moderators of the target community can perform these actions.
Suggested SortPer-post default comment sort overridden by moderators for AMA, Daily Discussion, or Q&A threads.
Per-post default comment sort overridden by moderators for AMA, Daily Discussion, or Q&A threads.
Moderators can set a Suggested Sort on individual posts to override the default comment sorting. For example, a Daily Discussion thread should default to 'New' so the latest comments appear first, while an AMA works best with 'Top' or 'Best'.
When a suggested sort is set: (1) The comment sort dropdown defaults to the suggested value instead of 'Best'. (2) A (suggested) label appears next to the active sort option so users know it's a mod recommendation. (3) Users can still manually switch to any other sort — their choice takes priority via the URL parameter.
Implementation: A nullable suggestedSort column on the post table. Mods set it via a sub-menu in the post actions dropdown. The frontend reads the post's suggestedSort and uses it as the default when no explicit commentSort URL parameter is present.
Removal Reasons (Templates)Predefined, community-specific removal reason templates with variable substitution and automatic author notifications.
Predefined, community-specific removal reason templates with variable substitution and automatic author notifications.
Moderators can create reusable removal reason templates per community. Each template has a title (e.g., 'Spam', 'Rule Violation', 'Off-Topic') and a body that supports template variables: {author} (content author's username), {community} (community name), and {rule_link} (link to community rules).
When removing a post or comment, moderators select a predefined reason from a dialog (or write a custom one). The template variables are resolved server-side at removal time, producing a personalized message. This resolved message is automatically sent as a notification to the content author, explaining why their content was removed.
Templates are managed in the community settings under a dedicated 'Removal Reasons' tab (requires posts mod permission). Supports full CRUD, drag-and-drop reordering, and a limit of 30 templates per community. The removal reason ID is recorded in the mod action metadata for audit purposes.
Contest ModeRandomized comment order with hidden scores for community competitions and fair voting.
Randomized comment order with hidden scores for community competitions and fair voting.
Contest Mode is a per-post moderation tool designed for situations where unbiased voting matters — coding challenges, community competitions, caption contests, or any thread where the order of comments shouldn't influence votes.
When a moderator enables Contest Mode on a post: (1) Comment scores are hidden — the API returns zeroed scores, and the UI shows • instead of numbers. (2) Comments are randomly shuffled server-side on every page load — no sort order (Best, New, Top) is applied. (3) Voting still works — users can upvote/downvote as usual, but they can't see the running tally. (4) The sort bar is replaced by a purple 'Contest Mode' indicator explaining the behavior.
Implementation: A contestMode boolean on the post table. The comment API checks this flag and applies Fisher-Yates shuffle + score masking in the response. Mods toggle it via a dropdown menu item in the post detail view. A purple Contest badge appears in the post title when active.
Report SystemPost, comment, and user reporting with a multi-status workflow.
Post, comment, and user reporting with a multi-status workflow.
Reports go through a structured workflow: pending → reviewed → resolved/dismissed. When reporting content, users select a reason — either a community rule violation or a site-wide policy (spam, harassment, misinformation). An optional detail text provides additional context.
Moderators see a report queue with full context: the reported content, the reporter's identity, the selected reason, and the report timestamp. Reports can be actioned individually or in bulk. Multiple reports against the same content are grouped — the report count helps moderators prioritize the most-flagged content.
Resolved reports record: the resolution action (removed, warned, no_action), the resolving moderator, and a resolution note. This creates accountability — both reporters and moderators have a transparent record.
Report Detail & Content PreviewDrill-down views for reports and mod queue with inline content rendering.
Drill-down views for reports and mod queue with inline content rendering.
The Report Detail Slide-Over opens when clicking any report in the admin report list. It shows: reporter identity (username + avatar), reason, custom details, timestamps, and the current status. The reported content is rendered inline using the TipTap RichTextRenderer — posts show title, body, author, community, score, and comment count; comments show body, author, parent post context, and depth.
The Mod Queue now features expandable rows — clicking a row fetches and displays the actual content with a golden highlight border. Each expanded view shows the full content preview on the left and a list of all associated reports (with reporter, reason, and date) on the right.
Both views include context links to the original post/comment in the frontend, making it easy to see the content in its natural context. Report resolution supports optional notes, and prev/next navigation allows reviewing reports without returning to the list.
User BansTemporary, permanent, and shadow bans with optional revocation.
Temporary, permanent, and shadow bans with optional revocation.
Three ban types with distinct behavior:
- Temporary Ban: User is banned for a specified duration (e.g., 7 days, 30 days). The ban automatically expires when the period ends. The user sees a notice explaining the ban reason and remaining duration.
- Permanent Ban: Indefinite ban with no automatic expiry. Only a moderator (or admin for site-wide bans) can lift it.
- Shadow Ban: The most subtle form of ban — the user can still post and comment, but their content is invisible to everyone else. The shadow-banned user sees their own content normally and has no indication they've been banned. This is used against spammers and bad actors to prevent them from simply creating a new account.
All bans include: reason, issuedBy (moderator), startsAt/expiresAt, and an optional banMessage shown to the user (except for shadow bans). Bans can be revoked by any moderator of the community (or any admin for site-wide bans). Ban revocations are logged in the audit trail.
Expertise BadgesVerified expertise badges for subject-matter experts — assigned by community moderators, visible only within the community.
Verified expertise badges for subject-matter experts — assigned by community moderators, visible only within the community.
Community moderators can assign expertise badges to users who demonstrate verified expertise in the community's subject area. Each badge has a custom label (e.g., 'Verified Doctor', 'TypeScript Expert', 'PhD Researcher') and optional evidence notes.
Badges use a composite PK (userId, communityId) — a user can have at most one badge per community, but different badges across communities. The verifiedBy column records which moderator assigned the badge, providing accountability.
Badges are server-side enriched onto author objects (same pattern as user flairs) and rendered as emerald-colored inline badges with a verification checkmark icon. Moderators can assign, update, or revoke badges directly from the comment context menu.
Audit LogComplete, immutable history of all moderator and admin actions.
Complete, immutable history of all moderator and admin actions.
Every significant administrative action is recorded in the auditLog table: bans, content removal, setting changes, role assignments, quarantine decisions, and configuration updates. Each entry stores: action type, actorId (who did it), targetId (what/who was affected), details (JSON with before/after state), and createdAt.
The audit log is searchable by action type, actor, target, and date range. It's accessible to site admins for the full platform and to community moderators for their specific community's actions.
Audit log entries are immutable — they cannot be edited or deleted, even by site admins. This ensures accountability and provides a forensic trail for dispute resolution. If a moderator abuses their power, the audit log provides evidence.
Mod Actions LogPer-community view of moderation activity for team coordination.
Per-community view of moderation activity for team coordination.
Each community has a dedicated moderation log visible to all moderators of that community. It shows all mod actions taken within the community context, ordered chronologically — a timeline of who did what and when.
The log enables team coordination: moderators can see what others have done, avoid duplicate actions (e.g., two mods banning the same user), and review each other's decisions. Entries include full context: action type, target content, acting moderator, and reason.
The mod log is distinct from the site-wide audit log: it focuses on community-specific moderation and is accessible to community mods who don't have access to the platform-wide audit log.
Appeal SystemUsers can appeal moderation decisions — post/comment removals and bans.
Users can appeal moderation decisions — post/comment removals and bans.
Users can submit appeals against three types of moderation decisions: post removals, comment removals, and bans. Each appeal requires a written justification (10-5000 characters) explaining why the decision should be reversed.
Appeals follow a simple workflow: pending → approved/rejected. Users can also withdraw pending appeals. A unique constraint ensures only one appeal per moderation decision — no duplicate submissions.
Admins manage appeals through a dedicated Appeal Queue in the admin panel with status filtering and approve/reject actions with optional reason. When an appeal is approved, the moderation action is automatically reversed (content restored or ban revoked). The user receives a real-time notification about the outcome.
Banned users see a prominent ban banner across the site with the ban reason, type (temporary/permanent), and remaining duration. The banner includes an integrated appeal button that opens the appeal submission form. Once submitted, the button is disabled to prevent duplicates. The ban middleware allows appeal endpoints through even for banned users, so they can submit and check their appeals.
Mod MailFormal messaging system between users and community moderators with anonymized mod responses and warning threads.
Formal messaging system between users and community moderators with anonymized mod responses and warning threads.
Dual-table schema with mod_mail_thread (subject, participants, archive flag) and mod_mail_message (body as TipTap JSONB, isMod flag for anonymization). Threads support three patterns: user→mods, mods→specific-user (warnings), and multi-message conversations.
Responses from mods are anonymized as 'Mod Team' when isMod=true, preserving moderator privacy. Users must be community members to initiate threads. Full audit trail via message authorship tracked in DB. Result<T, E> pattern handles authorization failures gracefully.
Frontend includes a dedicated mod inbox tab in community settings, a user-side inbox across all communities (/mod-mail route), and a 'Message the Mods' button in the community sidebar. Backend emits ModMailThreadCreated and ModMailReplyReceived domain events, triggering notifications via the NATS worker.
Granular Mod PermissionsFine-grained permission control for individual moderators.
Fine-grained permission control for individual moderators.
Instead of a single 'moderator' role with full access, community admins can assign granular permissions to each moderator. Available permissions: posts, comments, flair, config, mail, wiki, ban, and all (full access).
A mod with only flair permission can manage post and user flairs but cannot change community settings or remove posts. A mod with config can edit rules, widgets, settings, and emojis but cannot ban users. The all permission grants full moderator access without promoting to admin.
Permissions are stored as a PostgreSQL TEXT[] array on the community_member table. Community admins always have implicit full access. The Moderators tab in community settings provides a visual permission editor with checkboxes for each permission scope.
Mod User NotesInternal notes about users, visible only to community moderators. Helps track recurring issues and document moderation context.
Internal notes about users, visible only to community moderators. Helps track recurring issues and document moderation context.
Moderators can create internal notes on any user within their community. Notes are label-coded: note (general), warning (verbal warning), ban (ban-related), spam (spam activity), and positive (commendation). Each label is color-coded in the UI for quick visual scanning.
Notes are community-scoped — a user may have different notes in different communities. Only moderators with the notes permission can view and manage notes. The note dialog shows a chronological history with label badges, author attribution, and relative timestamps.
The ModNoteBadge appears inline next to usernames in comment threads (visible only to mods). Clicking it opens a dialog with the full note history, allowing mods to add, edit, or delete notes. A count badge shows the total number of notes at a glance. Only the note author can edit or delete their own notes.
Administration
8 featuresAdmin RolesSite Admin, Content Mod, User Manager, and Community Manager — RBAC with granular permissions.
Site Admin, Content Mod, User Manager, and Community Manager — RBAC with granular permissions.
Four admin role types with distinct permission sets: Site Admin (full platform access, can do everything), Content Moderator (handle reported content site-wide), User Manager (manage user accounts, bans, suspensions), Community Manager (oversee community creation, quarantine decisions, community-level settings).
RBAC (Role-Based Access Control): Roles are stored in adminRoles with a JSONB permissions column. Role assignments link users to roles via adminRoleAssignments. Permission constants are defined in @reddipp/shared and shared between frontend and backend.
Multiple roles can be assigned to a single user. Permissions are additive — a user with both Content Mod and User Manager roles has the combined permissions of both. This allows flexible permission composition without needing a separate role for every combination.
Role Management UI at /admin/roles: Full CRUD for custom roles with a permission checkbox grid grouped by category (User Management, Content, Communities, System). Built-in roles (Site Admin, Content Moderator, etc.) are visually marked with a lock icon and protected from editing/deletion. Each role card shows assigned user count and expands to reveal the full permission set and assigned users list.
Site SettingsPlatform-wide configuration for registration, content policies, and defaults.
Platform-wide configuration for registration, content policies, and defaults.
Site settings control global behavior: maintenance_mode (toggle site access), registration_open (allow/deny new signups), require_email_verification, invite_only, max_post_length, max_comment_length, max_upload_size_mb, site_name, site_description, og_default_image, and twitter_site.
All settings are enforced at runtime — not just stored. registration_open blocks new sign-ups before the auth handler. require_email_verification guards post/comment/vote routes via middleware. max_post_length and max_comment_length are validated dynamically in PostService/CommentService. max_upload_size_mb applies to media upload presigned URL generation. invite_only enables the full invite code system.
Invite Code System: When invite_only is enabled, new users must provide a valid invite code during registration. Admins can create, list, and revoke invite codes via /admin/invite-codes. Codes support optional maxUses limits and expiration dates. Each redemption is tracked (who, when). The registration form dynamically shows an invite code field when the mode is active.
Settings are stored as key-value pairs in the siteSettings table and cached in Redis for fast access across all services. Changes take effect immediately — no restart required. Stale cache is invalidated on update.
A public /api/site-info endpoint exposes frontend-relevant config values (registration status, invite-only mode, content limits) with short-lived caching, so the UI can adapt dynamically.
The admin panel provides a form-based interface for editing settings. All setting changes are recorded in the audit log with both the previous and new values, so changes can be tracked and reverted if needed.
OG-Tags & Social Media PreviewsDynamic Open Graph and Twitter Card meta tags generated from site config and page context.
Dynamic Open Graph and Twitter Card meta tags generated from site config and page context.
The root layout sets global OG tags from site config: og:site_name, og:title, og:description, og:image (from og_default_image setting), twitter:card: summary_large_image, and twitter:site (from twitter_site setting).
Context-specific overrides on child routes: Post pages set og:title to the post title, og:description to author/community context, and og:image to the post thumbnail. Community pages use the community name, description, and banner/icon. User profile pages use the username and avatar.
The og_default_image setting supports direct file upload (via MinIO presigned URL flow) or URL paste in the admin panel. The recommended size is 1200×630px for optimal display in Slack, Discord, and Twitter/X.
twitter_site stores the handle with or without @ — the frontend normalises it to the @handle format when rendering the twitter:site meta tag.
Crowd ControlAuto-collapse comments from untrusted users based on their relationship to the community — configurable per community.
Auto-collapse comments from untrusted users based on their relationship to the community — configurable per community.
Four levels: Off (everything visible), Lenient (collapse only negative-karma users), Moderate (also non-members), Strict (also members who joined less than 24 hours ago). Moderators and admins are always exempt.
Implemented as a pure function (evaluateCrowdControl) that evaluates each comment author's karma, membership status, and join date against the community's configured level. The function runs during comment response enrichment — no extra DB tables, no scheduled jobs.
The API batch-fetches membership and karma data for all comment authors in two parallel queries, then evaluates crowd control per comment. Collapsed comments show a reason tag (e.g., 'May be inappropriate', 'Non-member', 'New member') and can be manually expanded by users.
Spam Filter ConfigurationRule-based content moderation with word blacklists, regex patterns, and report thresholds — enforced in real time by the background worker.
Rule-based content moderation with word blacklists, regex patterns, and report thresholds — enforced in real time by the background worker.
Three rule types: word_blacklist (word-boundary matching), regex_pattern (ReDoS-protected, max 500 chars), and report_threshold (auto-remove after N reports). Three actions: auto_remove (instant deletion), flag_for_review (mod queue), and shadowban. Rules can be scoped globally or per-community with priority ordering and enable/disable toggles.
A NATS JetStream consumer in the worker listens on post.created, comment.created, and report.created events. It extracts plaintext from TipTap documents and checks against active rules in priority order. On match: logs to spam_filter_log (audit trail) and applies the configured action. Rule cache invalidates every 60 seconds.
Full admin dashboard at /admin/spam-filter with stats cards (total/active rules, 24h/7d/30d match counts, top triggered rules), rule CRUD modal with type-aware validation, inline enable/disable toggles, and a real-time log showing which rules are actively catching content.
Quarantine ManagementReview and manage quarantined communities from the admin panel.
Review and manage quarantined communities from the admin panel.
Site admins can quarantine communities that violate platform policies. The flow: admin initiates quarantine with a reason → community becomes gated behind a consent interstitial → community is removed from search and recommendations.
Quarantined communities can appeal via a process managed in the admin panel. Admins can lift quarantine when the community demonstrates compliance with policies.
The quarantine system includes consent tracking: users who explicitly consent to viewing quarantined content have their consent stored per-community in quarantineConsents. This avoids showing the interstitial page on every visit.
Bulk Member ManagementEfficiently handle spam raids with bulk member removal from the admin community detail page.
Efficiently handle spam raids with bulk member removal from the admin community detail page.
The Members tab in the admin community detail page (/admin/communities/:name) features checkbox selection per row and a 'Select All' header checkbox. Admins are automatically excluded from selection to protect community integrity.
A floating bulk-action bar appears when members are selected, showing the count and a 'Remove Selected (N)' button. A confirmation dialog prevents accidental removals, clearly stating the number of affected members.
The backend endpoint POST /admin/communities/:name/members/bulk-remove accepts up to 100 user IDs per request. It uses DELETE ... RETURNING inside a transaction to ensure the memberCount decrement matches the exact number of rows deleted — preventing race conditions with concurrent member leaves.
Admin members are automatically skipped server-side. The response includes removedCount and skippedAdmins for transparent feedback. Domain events (member.removed) are published in parallel for each removed member.
WebhooksSend community events to external services — Discord, Slack, Teams, Telegram, or any custom HTTP endpoint with full auth and header configuration.
Send community events to external services — Discord, Slack, Teams, Telegram, or any custom HTTP endpoint with full auth and header configuration.
Five integration types via a visual card picker: Custom URL (full control), Discord (auto-formatted embeds), Slack (incoming webhook format), Microsoft Teams (connector cards), Telegram (bot messages). Each type formats payloads appropriately for the target service.
Community moderators configure up to 10 webhooks per community via the Integrations tab. Each webhook subscribes to specific event types: post.created, post.updated, post.deleted, comment.created, member.joined, member.left.
Custom webhooks support: HTTP method (GET/POST/PUT/PATCH), authentication (None / Basic Auth / Bearer Token), and custom headers (up to 20 key-value pairs). HMAC-SHA256 signature is always included (X-Webhook-Signature) for payload verification.
Security: SSRF protection rejects webhook URLs targeting private/reserved IP ranges (RFC1918, loopback, link-local, AWS metadata endpoint) — both for stored URLs and resolved DNS. Bearer tokens are never returned in API responses. Test and retry endpoints are rate-limited (5/min per user). Secret is shown exactly once at creation time.
Delivery log: Per-webhook delivery history with request payload, response body, HTTP status, duration, attempt number, and one-click manual retry. Paginated (20 per page) with live refresh.
Delivery pipeline: Domain events → NATS JetStream → webhook worker fans out → exponential backoff retries (1s → 10s → 60s). After configurable failure threshold, webhook is auto-disabled.
Admin dashboard at /admin/webhooks: Global stats (total, active, failing, 24h deliveries/failures), filterable list across all communities, and configurable delivery settings (retry count, backoff, timeout, failure threshold, max per community).
Search
2 featuresFull-Text SearchTypo-tolerant instant search across posts, communities, and users via Meilisearch.
Typo-tolerant instant search across posts, communities, and users via Meilisearch.
Powered by Meilisearch with three dedicated indices: posts (searchable: title + body text), communities (name + description), and users (username + display name + bio). Meilisearch is a lightweight, Rust-based search engine that runs as a single binary.
Indexing is asynchronous via NATS domain events: when content is created or updated, a domain event (PostCreated, CommunityUpdated, etc.) is published to NATS, and the search worker indexes or re-indexes the relevant document. Full re-indexing is also available via CLI for disaster recovery.
Search features: typo tolerance (finds 'programing' when you meant 'programming'), prefix matching (results appear as you type — no need to press Enter), multi-word queries (all words must match), and instant response (<50ms for most queries).
The search UI shows results grouped by type (Posts, Communities, Users) with type tabs. Results include highlighted matching fragments and direct links. The search box supports keyboard navigation (arrow keys + Enter).
Command Palette (⌘K)GitHub/Linear-style Command Palette for instant navigation, search, and actions via ⌘K / Ctrl+K.
GitHub/Linear-style Command Palette for instant navigation, search, and actions via ⌘K / Ctrl+K.
Built on cmdk — the same library used by Linear, Vercel, and GitHub. Provides fuzzy-matched navigation to pages, communities, users, and posts with zero-delay.
Prefix modes for targeted search: @ searches users, r/ searches communities, > enters command mode (Toggle Dark Mode, New Post, Logout, etc.). No prefix performs a global mixed search via Meilisearch.
Frecency-based Recent suggestions: tracks visit frequency and recency with exponential decay (7-day half-life) stored in localStorage. Empty input shows your most-visited pages, communities, and users.
Context-aware commands: on a community page, offers 'New Post in r/{name}'; on a post page, offers 'Share Post' (copies URL). Admin users see admin navigation commands. All commands are navigation-only — no destructive actions.
Full keyboard navigation: ↑↓ to browse, Enter to open, ⌘Enter to open in new tab, Esc to close. Footer shows keyboard hints. The SearchBar in the header acts as a visual trigger with a ⌘K badge.
Monetization
3 featuresPremium SubscriptionsPlatform-level and community-level subscriptions with monthly and yearly billing via Lemon Squeezy.
Platform-level and community-level subscriptions with monthly and yearly billing via Lemon Squeezy.
Platform Premium ($4.99/month or $49.90/year) unlocks: custom profile badges, extended emoji, ad-free browsing, larger upload limits, and a monthly coin stipend (700 coins/month). Premium users get a visible badge on their profile and in comments.
Community Premium ($2.99/month, configurable by community owner) grants access to exclusive community content, custom flair options, and community-specific perks defined by moderators.
Billing is handled via Lemon Squeezy integration (a Stripe alternative focused on digital products). Subscription data is stored in subscriptionPlans (plan definitions) and userSubscriptions (active subscriptions with status, period dates, and providerSubId).
Subscription lifecycle: active → canceled (still accessible until period end) → expired. Users can resubscribe at any time. Webhooks from Lemon Squeezy keep the local DB in sync with payment status — no polling needed.
Ad PlatformAdvertiser accounts, campaigns (CPM/CPC), creatives, targeting, and real-time stats.
Advertiser accounts, campaigns (CPM/CPC), creatives, targeting, and real-time stats.
Advertisers register accounts (with approval workflow: pending → approved → active) and create campaigns. Campaigns define: budget, billing model (CPM = cost per 1000 impressions, CPC = cost per click), schedule (start/end dates), and status (draft → pending_review → active → paused → completed).
Creatives are the actual ad units — three formats available:
- In-Feed: Appears as a promoted post in feeds, visually similar to regular content but clearly labeled as 'Promoted'
- Sidebar: Banner in the community sidebar, always visible while scrolling
- Thread Sponsor: Branded placement at the top of comment threads, associated with a specific post
Targeting rules define where ads appear: specific communities, content categories, user demographics (account age, karma range). Targeting is stored per-campaign in adTargeting with a flexible JSONB criteria column. Real-time statistics track: impressions, clicks, CTR, spend, and cost per result.
Boost Post: Users with approved advertiser accounts can promote their own posts directly from the post menu (⋯ → 'Boost Post'). This creates a simplified ad campaign with budget, billing type (CPM/CPC), duration (1–90 days), and community targeting. Boosted posts appear as native content in feeds at positions 4 and 9 with a yellow Promoted badge and Megaphone icon — clearly distinguishable from organic content but using the same PostCard component for a seamless experience. Click tracking is handled transparently via the ad platform's impression and click pipeline.
Community Ad SettingsPer-community ad preferences and revenue sharing controls.
Per-community ad preferences and revenue sharing controls.
Community moderators configure ad preferences: enable/disable ads, opt out of specific ad categories, set minimum advertiser reputation requirements. These settings give communities control over what appears alongside their content.
Communities with Premium subscriptions can disable ads entirely for premium subscribers or for all visitors, depending on the community's monetization strategy.
Revenue sharing settings are configurable at the community level, allowing community owners to receive a portion of ad revenue generated from ads shown within their community. This incentivizes communities to grow and maintain quality.
Infrastructure
7 featuresPostgreSQL + Drizzle ORMType-safe database layer with ltree extension and UUIDv7 primary keys.
Type-safe database layer with ltree extension and UUIDv7 primary keys.
PostgreSQL 17 with the ltree extension for hierarchical comment trees and pg_uuidv7 for time-ordered primary keys. UUIDv7 IDs are B-Tree index-friendly (monotonically increasing timestamp prefix), globally unique, time-sortable, and non-enumerable (can't guess the next ID).
Drizzle ORM provides end-to-end type safety: schema definitions → TypeScript types → query builders → result types. No separate type definitions needed — the schema IS the type system. DB columns use camelCase (Drizzle convention), quoted in raw SQL to prevent PostgreSQL lowercase folding.
The schema uses JSONB columns extensively for flexible, schema-less data: community settings (theme colors, vote icons), widget configurations, ad targeting criteria, and TipTap document bodies. Zod schemas validate JSONB content at the application layer.
Association tables (votes, memberships, follows, blocks) use composite primary keys from their FK columns — no synthetic id column. This enforces uniqueness at the DB level, avoids redundant indexes, and keeps the schema honest about what these tables represent: relationships, not entities.
Redis / Valkey CacheIn-memory store for rankings, sessions, rate-limiting, and Pub/Sub.
In-memory store for rankings, sessions, rate-limiting, and Pub/Sub.
Valkey (a Redis-compatible fork) serves four distinct roles: (1) Pre-computed ranking scores in sorted sets for sub-millisecond feed queries. (2) Session storage for Better Auth — sessions are validated against Redis on every request for fast auth checks.
(3) Sliding-window rate limiting on API endpoints: per-IP and per-user limits with configurable windows and thresholds. (4) Pub/Sub for distributing real-time events (notifications, votes, new content) across horizontally scaled API instances — this is how SSE and WebSocket scale.
Additional uses: HyperLogLog for post view counting (tracks unique views with O(1) memory — a probabilistic data structure that can count unique elements using only ~12KB regardless of how many views there are), sorted sets for trending/hot rankings, and string keys for caching frequently accessed data.
MinIO Object StorageS3-compatible storage for all user-uploaded media.
S3-compatible storage for all user-uploaded media.
Media uploads use presigned URLs: the client requests a presigned PUT URL from the API, uploads the file directly to MinIO (bypassing the API server entirely for large files), then confirms the upload. This keeps API server memory usage low even during large uploads.
After upload, the backend validates: MIME type (whitelist: image/png, image/jpeg, image/gif, image/webp, video/mp4), file size (configurable per type: 10MB images, 100MB videos), and dimensions (max 4096×4096 for images).
The worker service processes uploads asynchronously via NATS: images are thumbnailed with sharp (libvips-based, 10-50x faster than ImageMagick) at multiple sizes (32px, 128px, 512px, original). Video transcoding via FFmpeg is planned but not yet implemented.
Files are served at CDN-friendly URLs: /media/{bucket}/{key}. Varnish caches media responses aggressively with long TTLs since media content is immutable (new uploads always get new keys, never overwrite).
NATS JetStreamPersistent message queue for reliable background job processing.
Persistent message queue for reliable background job processing.
NATS JetStream provides durable, at-least-once delivery for background tasks. This means every message is guaranteed to be processed at least once — even if the worker crashes mid-processing, the message is redelivered. Streams: media-processing, search-indexing, email, and notifications.
Consumers run in the worker service with automatic retry on failure (exponential backoff — wait 1s, then 2s, then 4s, etc.). Failed messages are dead-lettered after a configurable retry count for manual inspection.
NATS is chosen over Redis Streams or RabbitMQ for its simplicity (single binary, zero configuration), native JetStream persistence, and excellent performance (>1M messages/sec on commodity hardware).
Domain events (PostCreated, VoteCast, CommentCreated, etc.) are published to NATS by API services and consumed by workers for: search indexing, notification creation, ranking recalculation, and media processing. This decouples the API from slow background work.
MeilisearchLightweight, typo-tolerant search engine — single Rust binary, sub-50ms responses.
Lightweight, typo-tolerant search engine — single Rust binary, sub-50ms responses.
Meilisearch provides sub-50ms search responses with zero configuration out of the box. It's written in Rust and deployed as a single Docker container with a persistent volume for index data.
Three indices with distinct searchable and filterable fields: posts (searchable: title, body; filterable: communityId, type, createdAt), communities (searchable: name, description; filterable: visibility), users (searchable: username, displayUsername, bio).
Indexing is event-driven: domain events published to NATS trigger the search worker to add, update, or remove documents. Bulk re-indexing is supported via CLI command (reindexAll) for disaster recovery or after schema changes.
Custom ranking rules prioritize: relevance (typo distance, word proximity) → post score → recency. This ensures popular, recent content surfaces first when search terms match equally across multiple results.
Caddy + VarnishTLS termination, reverse proxy, and HTTP cache with ESI support.
TLS termination, reverse proxy, and HTTP cache with ESI support.
Request chain: Internet → Caddy (TLS termination via automatic ACME/Let's Encrypt) → Varnish (HTTP cache) → Backend (Hono API or TanStack Start SSR). This architecture handles high traffic while keeping the backend load low.
Varnish caching strategy: Logged-out users get fully cached pages (TTL: 60-300s depending on content volatility). Logged-in users get ESI (Edge Side Includes) — the page shell is cached, but user-specific fragments (votes, notifications, subscription status) are fetched live from the backend and assembled by Varnish.
Cache profiles map to Varnish TTLs: publicStable (5min — community sidebars, user profiles), publicDynamic (60s — feeds, post lists), publicShort (15s — active discussions), private (no cache), realtime (no cache + no-store). Every new API endpoint must declare a cache profile.
Grace Mode: when the backend is slow or down, Varnish serves stale cached content with a configurable grace period (default 1 hour). This provides resilience against backend outages — users see slightly outdated but functional content instead of error pages.
RSS FeedsStandard RSS/Atom feeds for communities, users, and the popular feed.
Standard RSS/Atom feeds for communities, users, and the popular feed.
Every community and user profile exposes an RSS feed at a discoverable URL. Feed readers like Feedly, Miniflux, or Thunderbird can subscribe to any community or user and receive new posts without visiting the site.
Three feed scopes: community feeds (/rss/r/{name}) for all posts in a community, user feeds (/rss/u/{username}) for a user's submissions, and the popular feed (/rss/popular) for trending content site-wide. Feeds include post title, body excerpt, author, timestamp, and a direct link.
Easter Eggs
4 featuresMatrix ModeType matrix anywhere on the page or use ?matrix=true to unleash green code rain.
Type matrix anywhere on the page or use ?matrix=true to unleash green code rain.
A full-screen Matrix digital rain effect rendered on an HTML5 Canvas overlay. Half-width Katakana characters, digits, and Latin letters fall in columns with randomized speeds and a phosphor-green trail effect.
The animation runs for 10 seconds with a smooth fade-out, or can be dismissed instantly by clicking or pressing any key. The canvas is GPU-accelerated via requestAnimationFrame for smooth 60fps performance.
Activated by typing the sequence m-a-t-r-i-x (ignored when focus is in text fields) or via the ?matrix=true URL parameter. Built on a reusable useKeySequence hook that powers all sequence-based easter eggs.
r/place MiniA collaborative 64x64 pixel canvas where every logged-in user can place one pixel every 5 minutes — real-time updates via SSE.
A collaborative 64x64 pixel canvas where every logged-in user can place one pixel every 5 minutes — real-time updates via SSE.
Navigate to /place to open the full-screen canvas. Pick one of 16 colors from the classic r/place palette and click any pixel to claim it. A 5-minute cooldown enforces fair collaboration.
Pixel placements are broadcast in real-time via SSE to all connected viewers. The canvas is stored as a compact binary blob in Redis (4 KB for 64x64) with atomic Lua-scripted updates.
Supports zoom (mouse wheel, 2x–32x), pan (shift-drag or middle-click), a grid overlay at high zoom levels, and a heatmap overlay showing recent placement activity.
Geocities Retro ModeAppend ?theme=geocities to any URL — or type g-e-o-c-i-t-i-e-s anywhere — to transform the entire site into a 90s Geocities homepage with interactive elements.
Append ?theme=geocities to any URL — or type g-e-o-c-i-t-i-e-s anywhere — to transform the entire site into a 90s Geocities homepage with interactive elements.
Activated via URL parameter ?theme=geocities, the Command Palette (Ctrl+K → "Geocities Mode"), or by typing the key sequence `g-e-o-c-i-t-i-e-s` (ignored in text fields). Persisted in localStorage and toggleable.
Full retro treatment: Comic Sans MS typography, rainbow-animated headings, neon green-on-dark color scheme, beveled ridge borders, Windows 95-style buttons, blinking text, rainbow HRs, custom pixel-art cursors, and no border-radius anywhere.
Interactive elements: A functional MIDI player using the Web Audio API (square-wave C-major arpeggio loop), a Guestbook with localStorage persistence and pre-seeded entries, a cursor trail that leaves sparkles as you move the mouse, and a persistent visitor counter that increments on every visit.
Decorative elements: Animated flame dividers, spinning globes flanking a glowing Welcome banner, award badges ("Cool Site of the Day", "Lycos Top 5%", "HotWired Hot!"), twinkling stars, an Under Construction banner, Netscape badge, webring, and more.
Implemented as a pure CSS override (html.geocities scope) with an interactive React overlay — zero impact on the normal UI when inactive.
Moorhuhn JagdType moorhuhn or use ?moorhuhn=true to launch a 60-second chicken-shooting arcade game — the legendary 1999 browser game, reimagined.
Type moorhuhn or use ?moorhuhn=true to launch a 60-second chicken-shooting arcade game — the legendary 1999 browser game, reimagined.
A full-screen Canvas arcade game inspired by the cult classic Moorhuhn (1999). Chickens fly across a parallax landscape in three sizes — small (100 pts, fast), medium (50 pts), large (25 pts) — with four flight patterns: straight, sine wave, diving, and rising.
Features a programmatic art style — all visuals (chickens, landscape, clouds, hills) are drawn with Canvas primitives, no external assets needed. Animated wing flaps, feather particle effects on hit, muzzle flash, and floating score popups.
Difficulty progression over 60 seconds: spawn rate increases, more small fast chickens appear, and complex flight patterns unlock. Circular hitbox collision detection with a custom crosshair cursor.
Sound effects via Web Audio API synthesis: white-noise gunshot burst, descending sawtooth hit tone, and a four-note game-over fanfare. Highscore persisted in localStorage.
Comment System
5 featuresTree-structured comments via PostgreSQL
ltree, colored thread lines, collapse/expand.Comments use PostgreSQL's `ltree` extension for efficient tree queries.
ltreeis a data type for hierarchical labels — each comment'spathencodes its full ancestry as dot-separated UUIDv7 hex labels (e.g.,019471a2b3c4.019471a2b3d5.019471a2b3e6). Thedepthis a generated column:nlevel(path).Querying is highly efficient: all descendants of a comment use ltree's
<@operator; direct children use a~pattern with{1}depth constraint. A GIST index on the path column keeps these queries fast even with millions of comments.Thread lines are color-coded by depth using a rotating palette of 5 colors. Lines extend from the parent comment's avatar to the last visible child. Clicking a thread line collapses the entire subtree. The UI uses
@tanstack/react-virtualfor virtualized rendering — only visible comments are in the DOM.Comments beyond depth 5 are not rendered inline — instead, a 'Continue this thread →' link opens the subtree in a dedicated permalink view with its own comment tree.
Moderators and admins can highlight their own comments with a visual badge.
Comments have a
distinguishedcolumn with three states:null(normal),"moderator"(green shield badge), and"admin"(red shield badge). Only the comment's own author can distinguish their comment — you can't distinguish someone else's.Distinguished comments receive visual prominence: a colored left border, a badge next to the username, and a tinted background. This is opt-in per comment — mods choose when to speak officially (as a moderator) vs. personally (as a regular community member).
The distinction state is toggled via a mod action and logged in the moderation log. Distinguishing a comment does not affect its score or ranking position.
Deleted comments show
[deleted]but the reply tree stays intact.When a comment is deleted, a soft-delete timestamp (
deletedAt) is set. The body is replaced with[deleted]and the author reference is cleared in the API response — but theltreepath and all child comments remain intact.This is crucial for preserving conversation context: replies to a deleted comment still make sense because their position in the tree is maintained. Without tree preservation, deleting a comment would orphan or destroy entire discussion threads.
If a deleted comment has no remaining visible children (all descendants are also deleted), the entire subtree can be pruned from the rendering tree, saving DOM nodes and improving performance for deeply nested threads.
Full TipTap editor support including formatting, links, code, and
@mentions.Comments use the same TipTap editor as posts, configured with a compact toolbar. Supported features: bold, italic, strikethrough, links, inline code, code blocks (with syntax highlighting), ordered/unordered lists, blockquotes,
@mentions, spoiler blocks, and community emojis.The comment editor is lazy-loaded (
React.lazywithSuspense) since TipTap's bundle is significant (~150KB gzipped). Editor state is preserved across reply box open/close cycles to prevent accidental content loss.Comment bodies are stored as TipTap JSON documents (ProseMirror schema) — not HTML or Markdown. This gives full control over rendering, sanitization, and future format migrations without the security risks of storing and parsing raw HTML.
Highlights new comments since your last visit with a badge and 'Mark all as read' button.
When you revisit a post thread, comments posted since your last visit are highlighted with a left border accent and subtle background tint. A badge next to the sort bar shows the count of new comments, and a 'Mark all as read' button clears the highlights instantly.
Visit timestamps are stored in a Redis Sorted Set per user (
thread_visits:{userId}, member = postId, score = epoch ms) with a 30-day TTL. No database table needed — the data is inherently ephemeral.The tracking respects the Varnish caching architecture: the public
GET /posts/:id/commentsendpoint remains fully cacheable. The user's last-visit timestamp is fetched via aPOST /me/thread-visits/:postIdendpoint (which atomically records the new visit and returns the previous timestamp). All new-comment detection happens client-side by comparingcomment.createdAt > previousVisit.Users can opt out via Settings → Feed → 'Highlight new comments'. Own comments and deleted comments are never highlighted. First visits show no highlights (logically correct — everything is 'new').