How to use this guide
Read User manual β Journeys β Everyday FAQ. You'll be able to chat, join Spaces, theme, and stay safe.
Read Message journey β Architecture β Data β Realtime β Auth β Run & deploy. Then browse code by file paths.
Memorize the 60-sec pitch, skim decisions, then drill Q&A bank with search + categories.
What you'll be able to explain
- What Kivo is, who it's for, and what it deliberately isn't (not Slack).
- How a message travels (optimistic UI β JWT β Zod β Mongo β Socket β receipts β push β offline outbox).
- Why Mongo is truth and sockets only announce; why no Redis/microservices/TS β yet.
- Auth that actually revokes, presence that doesn't spam, threads that stay quiet, Plus that the client can't fake.
What is Kivo?
kivo.usersynax.dev, open source user-synax/Kivo.Private 1:1 DMs (unique per pair via $all) + small groups (you + β₯2 friends, admins, system chips, last-admin protection).
Discord-like servers: text + announcement channels, roles owner β admin β moderator β member, Discover + 7-day rotating invites.
10 presets + personal studio + per-Space palettes + per-chat look + wallpapers + bubbles + profile effects. Beyond light/dark.
Principles
Fast & realtime Β· Simple despite rich Β· Personal & customizable Β· Clean responsive UI (bottom tabs on phones, icon rail on desktop) Β· Privacy + authorization foundational β never trust the client Β· One identity across DMs/Groups/Spaces Β· No premature microservices.
Pages (what users actually open)
| URL | Who | What |
|---|---|---|
/ | Anyone | Landing (features, customization, security, roadmap). Logged-in β /app. |
/signup Β· /login | Guests | Create account (instant, no OTP) / login with email or username, or Google/GitHub. |
/forgot-password Β· /reset-password?token Β· /verify-email?token | Anyone | 1h reset link (kills all sessions) + 24h verify link (API-only, no auto-email today). |
/app Β· /app/profile | Signed in | Chat dashboard (lists + chat + drawers) Β· read-only profile + logout. |
/u/:username | Anyone | Public shareable profile: badge, flag, status, bio, socials, graph, Wave, QR share. Wears owner's theme. |
/plus Β· /docs Β· /learn Β· /author | Anyone | Pricing + UPI claim + status Β· how-to guide Β· visual story (this page is the deep version) Β· author. |
/admin Β· /admin/dashboard | Admins | Separate login; users, bans, groups/spaces, Plus queue, audit log. |
Repository map + 4-file rule
π± Everyday user manual
1 Β· Create account & log in (2 minutes)
- Open the site β Sign up. Enter display name, username (3β30, letters/numbers/_), email, password (β₯8). You land in
/appinstantly β no OTP, no waiting. - Or tap Continue with Google / GitHub β a new account is made for you (email pre-verified, provider photo as avatar). Same email as an existing account? They're auto-linked, nothing lost.
- Log in later with email or username + password, or the provider button. OAuth-only accounts (no password) tell you to use the provider button instead.
- Forgot password? Forgot password β email β 1h link β new password. All devices are signed out β log in again.
2 Β· Layout: desktop vs phone
Far-left icon rail: Chats Β· Nearby Β· Status Β· Groups Β· Spaces Β· Emoji Factory Β· Settings, avatar at bottom. Header has bell π, search Ctrl+K, New menu. Center = chat; right = profile/group/Space panel on wide screens. Unread dots sit on rail icons.
Bottom tabs: Chats Β· Status Β· Groups Β· Spaces Β· Menu. Menu holds Nearby, Discover, profile, Appearance (full-screen themes), Settings β each a pushed screen with Back. Edge-swipe goes back; notches respected.
3 Β· Friends, Find & Nearby
- New β Friends β Find: search username/email/name (300ms debounce). Results are square cards β tap View profile or Send Request.
- Sending? Choose with welcome message (β€280 chars, shown before accept β be kind, only they see it) or without. Can't friend yourself, blocked users, or duplicates.
- Requests tab: accept/decline; welcome shows as a Quote bubble. Sender gets a notification (80-char snippet).
- Friends tab β Message opens/creates the DM. Unfriend from any profile β removes both sides live; history stays until you Remove-from-list.
- Nearby (rail / Menu β Nearby users, full-screen): first open shows a consent sheet. Tap Share location (one-shot, not tracking) β see people by fuzzed distance only (250 m / 1.2 km β never exact). Radius chips 1/2/5/10 km. Off in Settings β Privacy β Nearby discovery (hides you + clears location).
4 Β· Chat like a pro
| Do | How |
|---|---|
| Send | Type β Enter (Shift+Enter = newline). β€4000 chars (Plus 8000). Appears instantly; retry on fail. |
| Reply / React | Right-click / long-press β Reply (quote preview, X cancels) Β· React Β· double-click/long-press = β€οΈ Β· 270+ picker. |
| Edit / Delete | Own messages only. β on empty box edits your last message. Edited label; delete blanks but keeps order. |
| Forward / Pin / Save | Forward β pick chats (pill "Forwarded from @user", no caption). Pin β banner under header (max 10). Save β bookmark icon panel, jump anytime. |
| Threads | Menu β Thread β side panel. Quiet by design: no badge/bell (mentions still ping). "N replies" chip reopens. |
| Mentions | Type @ β pick participant β highlighted. They get notified even if category muted. |
| Voice / Files | Hold mic β release sends (slide up cancels). Paperclip or paste screenshots β β€10 files β€30 MB. Images β lightbox (arrows/download); docs β cards; grid icon β Media/Files/Links gallery + jump. |
| Receipts | β sent Β· ββ delivered Β· blue ββ all read. Tap ticks β Seen-by card (Read Β· time / Delivered per person). |
| Stay oriented | Day pills (Today/Yesterday/β¦) Β· big emoji for 1β3 emoji-only Β· "N new" pill when scrolled up Β· scroll up loads history Β· right-click chat β Mark unread. |
| Mobile gesture | Swipe bubble right (~45px) to reply. Works with scroll via direction lock. |
5 Β· Groups & Spaces
- Groups β New group β name (β€50) + optional photo + β₯2 friends.
- You become first admin. Settings: rename/photo, add/remove, promote/demote. Last admin can't be removed.
- Joins/leaves show as centered chips, live for all.
- Spaces β New β name/desc/category/banner/avatar β auto
#general. You own it. - Text channels = all post; announcement = admins only. Admin+ manages channels (last protected).
- Roles ownerβadminβmoderatorβmember. Owner-only: promote admin, delete Space.
- Discover β search/category β Join (public). Private = hidden, invite link/code only (7-day rotating, Space settings β Privacy & invites;
/app?join=CODEdeep-link).
6 Β· Make it yours + stay notified + Plus
- Appearance (full-screen): 10 themes switch live, no reload. Studio recolors accent + canvas tone β Save to my account (follows you). Chat look: wallpaper (Plain/Dots/Grid/Lines/Bubbles/Wash) + bubbles (Rounded/Pill/Squared/Outlined-mine). Per-chat palette icon overrides; Spaces can set their own look for members.
- Profile (rail avatar / Menu): name, handle, bio 280, status 60 + emoji chip (32 + 6 vibes), photo 4MB, 10 frames, banner (curated GIFs; Plus: your own β€8MB), effects (Plus: glow/gradient-name/aura), country flag, GitHub graph, X/Instagram/YouTube/website chips, pronouns, status auto-clear, privacy toggles.
- Public page
/u/you: share + QR. Visitors Wave π (20s cooldown β notification deep-linking to them), add/message/block. - Bell π: DMs/groups/Spaces/mentions/requests/waves. Click β jump + mark read. Settings β Notification preferences per category (Space msgs OFF default; mentions break through). Sounds per category + master + preview (synthesized, no downloads). First bell-open asks push permission β offline pings even when closed. Quiet automatically when you're already viewing that DM.
- Install (PWA): Chrome/Edge address-bar install icon Β· Android Add to Home Screen Β· iPhone Share β Add to Home Screen. Recent chats paint instantly offline; offline texts queue and auto-send on reconnect.
- Plus βΉ49/mo:
/plusβ pay published UPI ID β paste 12-digit UTR β approved within 24h β 30 days Plus (custom banner/effects/emoji, bigger limits). Status cards show pending/rejected/expired. - Safety: Block from any DM/profile (removes friendship, hides chat, server-enforced). Manage in Settings β Blocked users (one-tap unblock). Remove-from-list deletes a DM/group for everyone (confirm twice β permanent). Admins can ban rogue accounts.
πΊοΈ Five journeys, end to end
- Signup β land
/app. - New β Friends β Find β search friend β Send Request (+ "hey, it's me!").
- They Accept β Friends β Message β type β Enter β ββ blue. Right-click β Pin "coffee Saturday".
- Groups β New group "Project X" + photo + 2 friends β Create.
- Chat, @mention the lead (overrides mute), start a Thread on the plan message.
- Promote a co-admin; leave anytime (last admin protected).
- Spaces β New "Design Club" (category Design) β #general ready.
- Space settings β add #inspiration (text) + #news (announcement).
- Private? Privacy & invites β New link (7d) β share
/app?join=CODE. Public? It appears in Discover. - Give it a look (accent + wallpaper) β members see it in channels.
/plusβ pay βΉ49 to shown UPI ID in any UPI app β copy 12-digit UTR.- Paste UTR β claim filed (pending card). Admin approves β€24h β 30d Plus.
- Upload custom banner + glow effect + personal emoji
:mylogo:.
- Enter a tunnel, keep typing β messages queue in outbox (banner "You are offline").
- Reload β drafts + queue survive (IndexedDB).
- Back online β queue flushes in order + gap-fill pulls what you missed. Nothing lost.
The journey of a message
Code refs: backend/src/modules/messages/messages.service.js (createMessage) Β· backend/src/modules/notifications/notifications.service.js (createForMessage) Β· backend/src/socket/io.js (emitToConversation) Β· frontend/lib/outbox.js + components/outbox/outbox-flusher.jsx Β· frontend/lib/cache.js.
Tech stack + why each choice
| Piece | Technology | Why this (not the alternative) |
|---|---|---|
| Web app | Next.js 16 + React 19 | File routes + SSR/SEO (landing/docs/profiles) + rewrites. Not plain Vite: needs routing, metadata, sitemap/robots. |
| Styling | Tailwind v4 + shadcn/ui + Base UI | Utilities + accessible primitives. No extra component lib β native + shadcn is enough. |
| Motion | Motion (Framer Motion) | Staggers, dropdowns, panels. Always honors prefers-reduced-motion. |
| Server | Node + Express 5 | Boring + productive. Not Nest/Fastify: no decorator/DI ceremony for a solo MVP. Real entry src/server.js (src/app.js builds app; root server.js = legacy stub). |
| Realtime | Socket.IO 4 | Rooms + auto-reconnect + fallback beat raw WS for an MVP. No polling anywhere. |
| Database | MongoDB Atlas + Mongoose 9 | Flexible chat docs + geospatial (2dsphere) + text index. Not Postgres: schema fluidity + embedded members/channels fit documents. |
| Validation | Zod 4 | Every body/query/param server-side. Strictness without TypeScript. |
| Auth | JWT + bcryptjs 12 | Short tickets + one-way hashes. Sessions table gives real revocation. |
| Files | Appwrite Storage + Multer memory | Separate buckets (avatar/attachments/emoji). Not S3: generous free tier + preview URLs. Never blobs in Mongo. |
| Push | web-push VAPID | Native offline pings. Not Appwrite Messaging: standard + already integrated with SW. |
| Nodemailer Gmail SMTP | Reset/verify links. Fire-and-forget so auth never blocks on mail. | |
| Calls | LiveKit Cloud SFU | Chat servers are bad at media β delegate audio/video to an SFU; deterministic rooms. |
| Images | sharp | Emoji β webp (gif kept for animation), 2MB cap. |
| Runtime | Bun (nodemon dev) | Fast install/run. bun run dev/start. |
| Offline cache | idb-keyval | Browser DB: lists + last-50/chat, SWR instant paint. |
| Lint | Biome | bun run lint. TS intentionally out β JS-only is a project constraint. |
Monorepo, no microservicesJS onlyRedis planned, NOT used
Architecture, API & file tour
Networking rule (interview favorite)
REST uses relative /api/β¦ β Next.js rewrites (next.config.mjs) to BACKEND_URL (default http://localhost:4000), staying same-origin so sameSite=strict httpOnly cookies flow without CORS. Socket.IO dials NEXT_PUBLIC_API_URL directly β rewrites can't proxy WebSocket upgrades. Config: frontend/lib/api.js (fetch + auto-refresh) Β· components/socket-provider.jsx.
Request lifecycle (example: send message)
POST /api/v1/conversations/:id/messages
Authorization: Bearer <15m JWT>
{"content":"hey!","replyToMessageId":null}
routes.js β auth() β rateLimiter(send 40/min) β controller
β service: Zod validate β membership/ban/block check
β getRequesterPlan() limits β Message.create()
β Conversation.lastMessageAt touch
β notifications.createForMessage() [bulk insertMany + async push]
β io.emitToConversation(id,"message:new",msg) β 201
Module mounts
| Mount | What lives there |
|---|---|
/api/v1/auth | register login refresh logout logout-all verify-email resend forgot/reset + OAuth providers/start/link-url/callback |
/api/v1/users | me, profile/avatar/banner, public by username, search 30/min, blocked, privacy, location 10/min, nearby 30/min |
/api/v1/friends | request (+welcome 280, 20/h) accept decline list remove + requests feed |
/api/v1/conversations | DM/group CRUD, members/admins, read/unread, look PATCH /:id/look, permanent DELETE /:id, messages, pinned, threads |
/api/v1/messages | edit delete pin save/unsave saved-feed reactions (receipts via readBy/deliveredTo) |
/api/v1/spaces | CRUD, discover (public only), join, join-by-code, invites, members/roles, channels |
/api/v1/notifications | list unread-count mark-read preferences wave POST /:id/wave 20s |
push Β· attachments Β· search Β· link-preview Β· calls Β· status Β· emoji Β· plus | VAPID subscribe Β· MulterβAppwrite Β· Ctrl+K unified Β· SSRF-guarded og 1h cache Β· LiveKit token/config/status Β· 24h text+photo Β· emoji CRUD Β· UPI claim/status |
/api/admin | login stats users ban/unban/plan POST /users/:id/plan plus-queue approve/reject groups/spaces delete |
GET /health | {success:true, data:{status:"ok"}} |
Errors: {success:false, error:{code,message}} β prod never leaks stacks/secrets/paths (middleware/errorHandler.js). Frontend tour: app/ routes Β· components/dashboard/* shell/panels Β· lib/theme.js truth Β· lib/cache.js outbox.js drafts.js.
Data models β MongoDB is truth
| Model | Holds & key rules Β· file |
|---|---|
User models/User.js | email/username/displayName, bio280, status60, statusEmoji8, pronouns20, statusExpiresAt, avatarStyle(10), avatarUrl+banner (+FileIds select:false), countryβflag, github/x/instagram/youtube/website, verified/showBadge, plan + planExpiresAt, profileEffect, usernameColor hex, passwordHash nullable (OAuth-only), googleId/githubId sparse-unique + verified flags, role, blockedUsers, lastActiveAt, verify/reset hashes, 2FA secret + hashed backups, isBanned, location Point + 2dsphere + discoverableByNearby=true, notificationPreferences (Space OFF), appearance{accent,tint,wallpaper,bubbleStyle} |
| Session | One row per refresh token, TTL expiry. Delete = logout. Powers logout-all/reset-all/ban. |
Conversation models/Conversation.js | dm/group/space_channel, participants, admins, createdBy, spaceId+channelId, appearance look, lastMessageAt. Duplicate DM via service $all (no pairwise unique index exists in Mongo). |
Message models/Message.js | conv+sender, content β€4000 blanked on delete, type text/system/poll, replyTo, threadId null=timeline, reactions, deliveredTo, readBy{user,at}, savedBy, mentions, attachments{fileId,bucketId,name,mime,size,kind,url}, audioDuration, isEdited+history(10), scheduledAt+status, forwardCount, forwardedFromId/Name, pinnedAt/By, poll{question140, opts 2β8, multi, anon, expiry, closed, total}, isDeleted. Indexes per read pattern + text index (threads excluded). |
| FriendRequest | fromβto + pending/accepted/declined + welcomeMessage280 + at. Accepted row = edge. Directed-unique. |
Space models/Space.js | name/slug-unique/desc/category(10)/avatar/banner, owner, members[{user,role,joined}], channels[{name,slug,desc,type,by}], appearance, visibility public/private, inviteCode+inviteExpiresAt 7d (never in payloads). |
| Notification | recipient/sender/senderUsername, types dm/group/space/mention/friend_request/friend_accept/space_invite/wave, flags inApp/pushDelivered/pushError. |
| Status | user text280 background(6) media{file,bucket,url,kind,mime,size,name} viewers[] expiresAt 24h (indexed, no TTL β hourly sweep deletes file then doc). |
| CustomEmoji | name 2β32, spaceId, ownerId, by, url/file/bucket/animated. Sparse-unique per scope. |
| PlusRequest | user utr-12-digit amount4900 status pending/approved/rejected/expired expiresAt 24h reviewedAt note. |
| PushSubscription Β· AdminActionLog | VAPID endpoint+keys (unique) Β· ban/unban/grant_plus/revoke_plus/delete_* + IP + time. |
Never embed messagesPins/saves = fieldsThreads reuse Message
Socket.IO: rooms, presence, receipts
JWT handshake (same secret as HTTP); banned rejected. Auto-join conversation:{id} + space:{id}. socket/io.js helper emitToConversation. File socket/index.js.
Map<user,Set<socket>> single-instance. Peers = union of conversations+spaces (getRelevantPeerIds). 12s grace (pendingOffline) stops flicker. presence:snapshot fixes late joiners. Offline = lastActiveAt β "active X ago" (lib/last-active.js).
typing:start/stop {conversationId} β room excl. sender. No DB write. Client: chat-panel.jsx.
message:delivered {messageId} β $addToSet β delivery-updated. Read = anchor {userId,upToMessageId,upToCreatedAt,readAt} β client groups Read/Delivered. Tap ticks β portaled Seen-by (message-bubble.jsx).
Reconnect β refetch list (single agg) + after=newestId β merge into cache.js. No full download.
call:ring/accept/decline/end β accepted/declined/ended/missed/failed. 30s activeRings β missed chip+bell (calls.service recordMissedCall). 3s spam guard. Blocked β CALL_BLOCKED.
// client minimal (frontend)
import { io } from "socket.io-client";
const s = io(process.env.NEXT_PUBLIC_API_URL, { auth: { token } });
s.on("message:new", m => m.threadId ? openThread(m) : appendChat(m));
s.emit("conversation:focus", { conversationId }); // suppresses its DM notifs
s.emit("message:delivered", { messageId });
| Event | Dir | Meaning |
|---|---|---|
message:new/edited/deleted/reaction/pin-updated | SβRoom | Thread replies reuse with threadId β panel vs chip refresh |
message:read/unread/delivery-updated | Both | Anchor receipts + unread badge + "New messages" line |
conversation:focus/blur | CβS | Viewing DM skips its notification (isUserFocusedOnConversation) |
conversation:member-added/removed/updated/admin-changed/removed | SβRoom | Group live; removed also per-user on permanent delete |
space:updated/member-*/channel-*/deleted/joined/removed | SβSpace | Space/channel lifecycle |
notification:new Β· friend:removed Β· emoji:new/deleted Β· status:new/deleted/viewed | SβUser | Bell Β· live unfriend Β· emoji sync Β· status feed |
Identity, trust, and guards
name + username ^[a-zA-Z0-9_]+$ 3β30 + email + password β₯8 β bcrypt12 β user + session β access + httpOnly cookie β /app. Duplicate β CONFLICT. GuestGate/AuthGate (components/auth-guard.jsx).
Access 15m Bearer + refresh strict-cookie backed by Session TTL. POST /refresh-token mints access, no rotation (race-safe). Single-flight + 60s early refresh (lib/api.js). Logout one / logout-all / reset-all / ban-all = delete docs.
Verify 24h SHA-256 link + resend 1/min (API-only today). Forgot 5/5min always-success (no enumeration) 1h; reset 10/5min + kill all. Gmail SMTP fire-and-forget (lib/email.js, FRONTEND_URL links).
Optional. In-repo lib/totp.js RFC6238 SHA-1/30s constant-time; QR (qrcode); pending secret; bcrypt-hashed single-use backups; 5m ticket β session; password to disable (OAuth-only exempt). UI: dashboard/two-factor-section.jsx.
GET /oauth/providers gates buttons. Start 302 signed-state β callback creates (pre-verified, derived username, provider avatar if unset) or auto-links by email. link-url embeds userId. PROVIDER_ALREADY_LINKED; sparse-unique; OAuth-only null password β OAUTH_ONLY_ACCOUNT. Both linked β native verified. UI: auth/OAuthButtons.jsx, oauth/callback/page.jsx.
Zod all inputs. Membership / sender-only / rank (owner>admin>moderator>member) / owner-only promote+delete / last-owner+admin guards. Helmet, CORS allowlist, body limits, trust proxy, allow-list uploads + old-file delete, no prod leaks.
Rate limits β in-memory fixed window (middleware/rateLimiter.js)
Per-user else IP Β· X-RateLimit-* + Retry-After Β· 5-min unref sweep Β· per-process (shared store only if multi-instance).
register 5/h/IP Β· login 10/15m Β· 2FA code 10/5m/IP Β· setup 5/5m Β· verify-code 5/60s Β· refresh 30/60s Β· forgot 5/5m Β· reset 10/5m Β· resend 1/m Β· send 40/m Β· edit 20/m Β· reactions 60/m Β· friend-req 20/h Β· user-search 30/m Β· nearby 30/m Β· location 10/m Β· space/channel 10/h Β· uploads 10/m Β· search 30/m Β· link-preview 30/m Β· call-token 20/m Β· status 10/day + feed 30/m + view 60/m Β· plus-claim 5/day Β· admin-login 5/15m Β· oauth-link 10/60s.
Security checklist (say this in interviews)
Helmet, CORS allowlist, httpOnly + sameSite strict cookies, trust proxy IPs.
Zod everywhere; MIME allow-lists + 30MB caps + bucket extension lists; old files deleted on replace.
Rate limits, block/ban guards on DM/wave/search/nearby/calls, fuzzed location, SSRF-guarded previews.
How each feature actually works
π₯ Friends Β· Find Β· Nearby
Request by username/email/name + optional welcome 280 (stored + 80-char notif snippet β Quote bubble). Blocked both-ways β BLOCKED_USER. Directed-unique; reverse auto-accepts. Remove = shared edge deleted both sides + friend:removed live; history stays. Find: debounced 30/min, blocked/banned filtered, square cards (profile-preview-card.jsx) + modal with/without message. Nearby full-screen (nearby-screen.jsx like Appearance; rail + Menu β Nearby; FriendsModal tab): default ON, Settings β Privacy OFF clears; one-shot getCurrentPosition 12s β POST /me/location 10/min (reject accuracy>200/OFF) β Point + 2dsphere; GET /nearby?radius=1/2/5/10km 30/min $geoNear fuzz <1kmβ50m β₯1kmβ0.1km, filters banned/blocked-both/friends+pending+declined/stale>15m/hidden, nearest. Consent kivo:nearbyConsent; Permissions-Policy geolocation=(self).
π¬ Messaging core
DM create-or-return ($all). List by lastMessageAt desc + unread (not-mine β§ not-readBy) + online. Cursor newest-first + hasMore, scroll-up infinite, around jump, after catch-up. Optimistic sentβdeliveredβread + retry (chat-panel.jsx memoized MessageRows, O(1) byId). Menu: react strip, copy, save, profile, block, reply, thread, forward, pin, select (multi copy/forward/delete), Share mobile. β€οΈ double-tap (500ms throttle). Edit own (Plus unlimited else 15m, history 10, β-on-empty), soft-delete (blanked, pin cleared). System chips never bump unread. Remove from list (DM either side, group admin, channels 403) = permanent both-sides + conversation:removed + confirm modal.
Reply replyToMessageId inline (deleted = placeholder). Forward bare {forwardedFromId} both-memberships-checked β forwardedFromName pill, no caption/mention-resolve, forwardCount. Pin any member POST /:id/pin timestamp, newest-first max10 banner, pin-updated. Threads threadId=root (roots null) excluded from timeline/cursors/search/pinned/unread/cache; quiet (no badge/bell, mentions still notify); content-required, no quote/forward inside, live root non-system non-reply only; list (root+count/last/participants β chips) + panel oldest-first 500; same message:* routed by threadId (thread-panel.jsx). Saved POST /:id/save β savedBy, flag at read, GET /saved newest-first 200 (Plus 1K), personal cross-device, deleted drops, jump. Mentions @-autocomplete participants-only β @username highlight (mention-autocomplete.jsx), override muted (if Mentions on).
π Polls Β· π’ Status Β· π Files & voice Β· π Calls
Polls (polls/poll-card.jsx): type poll + poll{question140, opts 2β8Γ60, voters, multi, anon, expiry, closed, total}. Free 5/24h/single; Plus 8/7d/multi+anon. vote/retract/end-early; poll:updated/ended; 60s sweep; searchable.
Status 24h friends-only (status/*): text280 + 6 backgrounds OR photo (jpg/png/webp/gif β€30MB, 1/status) + caption280. Vertical tab My-top + grouped friends, green #25D366/grey rings, viewer 5s photo advance + caption + dots, 10/day, Appwrite attachments bucket, hourly file-then-doc sweep, status:new/deleted/viewed + isViewed. Mobile 5th tab.
Attachments (chat/attachments.jsx): browser β backend allow-list + 30MB β Appwrite bucket (images, docs pdf/doc(x)/xls(x)/ppt(x)/txt, audio webm/ogg/mp3/m4a/aac/wav β bucket extensions must include audio). 10/msg (Plus 20; 100MB). Progress + caption + lightbox (arrows/download) + doc cards + Media/Files/Links drawer (chat-media-drawer.jsx) with counts + jump + empty states. Voice: hold mic MediaRecorderβbare MIME (no transcode), slide-up cancel, timer, needs net; 32-bar deterministic pseudo-waveform (fileId seed, CSS divs, no fetch), tabular-nums timer, shared <audio> singleton, slider seek (click, β/βΒ±5s, Home/End, Space), responsive, audioDuration preserved on forward.
Calls β LiveKit SFU (calls/*): deterministic rooms kivo_<conv>, 6h tokens POST /calls/token (membership/ban/block, 20/min), GET /config gates buttons, GET /status Join pill. Socket ring only (30s β missed chip + bell + πCALL: history started/cancelled/declined/missed/ended + duration + Call back). Panel: mute/camera/devices/leave, voiceβvideo upgrade, mirrored selfie, group grid+headcount, reconnect banner, iOS unlock; lazy client SSR-safe, manual themed tracks.
π Notifications Β· π± Offline Β· π Search Β· π Previews Β· π Polish
Bell (notifications/*): DMs/groups/Spaces/announcements/mentions/requests+accepts/waves; cursor pages + mark-all; click deep-links. Prefs per category (Space OFF default; mentions break through; wave ungated 20s). Focus/blur suppression. Sounds Web Audio per category + master + preview (lib/sound.js, kivo:sounds). Perf: insertMany + batch prefs + async VAPID; 404/410 prune. Push: opt-in (bell first-ask), per-user subs, SW click β chat (public/sw.js, lib/push.js, pwa-register.jsx).
PWA/offline: manifest + sw installable; precache shell + hashed /_next/static cache-first; navigations network-first β cached shell β styled offline (public/offline.html); API/Socket never intercepted. IndexedDB (lib/cache.js) lists + last-50/chat SWR + restore last open + Space expand. Outbox (lib/outbox.js) durable per-account text queue, survives reload, flushes on reconnect; attachments need net; dual-signal banner.
Search Ctrl+K (dashboard/search-overlay.jsx): messages+people+spaces + jump highlight (Plus 20/cat vs 5). Blocked/banned filtered. Previews (chat/link-preview.jsx, lib/links.js): first URL β card (image/title/2-line/domain); server 8s/1.5MB regex parse + relative resolve, 1h/500 cache, SSRF-guarded (http(s), no localhost/private/link-local on host + DNS + redirect); empty stays plain; client 200-cache, brokenβtext-only. Polish (derived, zero schema): day dividers Today/Yesterday/weekday/date; big emoji 1β3 grapheme-aware (lib/emoji.js, Intl.Segmenter) 44/36/30px chromeless; jump pill (120px pin, 200px threshold, own sends pin); drafts per-conv (lib/drafts.js 250ms/50-chat/4000-clamp); paste-imageβqueue; swipe ~45px lock + haptics.
π€ Profiles Β· π Wave Β· π¨ Emoji Β· π Plus
Profiles (dashboard/profile-edit-modal.jsx, profile/*): name50/handle/bio280/status60 + chip8 (32 + 6 vibes) + photo4MB + 10 frames (Default, My-accent, solids, gradients) + banner (curated GIFs; Plus custom 8MB PATCH /me/banner, old retired) + effects none/glow/gradient-name/aura (kivo-pfx-*, server-clamped, downgradeβnone) + flag + graph + social chips (social-links.jsx) + pronouns + auto-clear + privacy toggles. Public /u/:username (app/u/[username]): no-login, Plus flourishes, owner-theme skin (lib/profile-skin.js), Share + QR + copy + native Share, Join nudge, Message/Add/Accept/Block/Unfriend. Verified admin-granted + showBadge toggle; Google/GitHub chips.
Wave: notification not message. POST /notifications/:id/wave β wave + senderUsername, 20s cooldown, self/blocked rejected, in-app else VAPID, deep-links to sender, countdown button. Emoji 3-tier (emoji-factory-panel.jsx, lib/custom-emoji.js): Global admin 200 / Space Plus-admin 100 / Personal Plus 50 everywhere; βwebp (gif kept) 2MB sharp; emoji bucket (fallback attachments); :name: + custom:id reactions; IDB SWR; emoji:new/deleted.
| Limit | Free β Plus |
|---|---|
| Message length Β· edit window | 4K Β· 15m β 8K Β· unlimited |
| Attachments Β· file size | 10 Β· 30MB β 20 Β· 100MB |
| Pins Β· saved | 10 Β· 200 β 50 Β· 1K |
| Groups owned Β· members | 5 Β· 20 β 20 Β· 100 |
| Spaces owned Β· members Β· channels | 3 Β· 50 Β· 10 β 15 Β· 300 Β· 30 |
| Calls Β· search/cat Β· polls active Β· opts Β· duration | 5 Β· 5 Β· 5 Β· 5 Β· 1d β 25 Β· 20 Β· 20 Β· 8 Β· 7d (+multi+anon Plus-only) |
| Forward Β· personal emoji | 5 Β· 0 β 10 Β· 50 |
Plus UPI self-serve (plus/plus-screen.jsx, modules/plus/*, lib/plus.js PLAN_LIMITS): pay published UPI ID βΉ49 β paste 12-digit UTR (POST /plus/claim 5/day) β admin queue β€24h β 30d grant (PLUS_DURATION_MS). /plus pricing+steps+status. Expired reads free (getEffectivePlan, never trust client); hourly sweepPlus lapses + scrubs effects. Admin POST /admin/users/:id/plan + audit.
π Spaces detail
Create β auto #general (each channel = own Conversation). Text (all) vs announcement (admin+; anyone may thread-reply). Admin+ channels (last protected). Roles memberβmoderator (admin+), admin owner-only, no direct owner, last-owner protected, self-leave auto-promotes. Public β Discover (search/category, one-click); private hidden + single rotating 7d code (Privacy & invites; New invalidates, Off closes; never in payloads; /app?join=CODE + Discover box). Space look (accent+tint+wallpaper+bubbles, live preview, per-field "Member's own", Resetβpersonal) scopes members' channel view. Owner-only delete kills channels+messages. Files: spaces/*.
Theme system β live demo
Engine (lib/theme.js): derivePalette() washes only surface tokens (base/surface/elevated/border/scrollbars/bubbleReceived) preserving lightness β contrast never breaks. tintStrength 0.2 dark / 0.14 light; isChromatic rejects near-gray; accent sets badge/soft/onAccent via luminance. resolveChatLook() (lib/chat-style.js): conversation β Space β personal per field, null = "Member's own". Wallpapers pure CSS color-mix() (none/dots/grid/diagonal/bubbles/wash); bubbles kivo-bubbles-* (rounded/pill/squared/outline-mine). Partial merges; conversation:updated repaints live; rings follow accent; /u wears owner skin (profile-skin.js).
| Theme | Family | Canvas |
|---|---|---|
| Framer (default) | Dark near-black + blue | #090909 |
| Midnight | Dark navy | #0a0e16 |
| Graphite | Dark slate | #0d0f12 |
| Espresso | Dark warm brown | #130e0a |
| Pine | Dark forest | #0a160f |
| Plum | Dark aubergine | #100a14 |
| Porcelain | Light off-white | #faf9f7 |
| Linen | Light cream | #f8f4ec |
| Mist | Light blue-gray | #f5f7fa |
| Sage | Light green | #f5f7f1 |
One canvas. Every surface answers to it.
Tokens flow through CSS variables β no hardcoded colors, no per-component overrides. That's why 10 themes + studio + Space palettes + conversation looks compose without conflicts.
What keeps it fast (and what to measure)
unreadCountsByConversation() kills N+1 countDocuments. Measure: inbox latency vs conv count.
One insertMany + batch prefs; sender never waits on VAPID. Measure: POST p95 vs recipients.
React.memo MessageRows + stable ctx + O(1) replies. Typing/scroll/presence skip re-renders.
useMemo 32-bar waveform (no canvas/fetch), tabular-nums timer (no CLS), shared audio; in-chat highlight client-only.
Cursor + around + after; index per read pattern + text index. Don't optimize blind β measure first.
Off-screen rows skip layout/paint. No virtualization code to maintain.
Why this, not that
| Decision | Why this, not that |
|---|---|
| WhatsApp + Discord, one identity | People live in both; one login beats two apps. Not Slack: enterprise bloat rejected on purpose. |
| Mongo truth; Socket announces | Events-as-truth vanish on restart. Write-first makes events disposable + replayable. |
| No Redis yet | Single instance; memory limiter+presence simpler. Explicit rule: don't add before needed. Later: adapter + shared limits β never canonical storage. |
| Monorepo folders, no microservices | No version/deploy ceremony. Split only on real scale/org complexity. |
| JS only, no TS | Focused learning surface; Zod at boundaries gives the safety that matters most. |
| Messages own collection | Embedded arrays bloat unbounded; own collection = indexed cursors + threads-as-messages. |
| Threads structurally quiet | Side talk must not punish main timeline β no badge/bell, only mentions. |
| Pins & saves = fields | One update + indexed query; delete clears pin free; no join table. |
| Server-side unfurl | Client fetch leaks IP + mixed-content. One cached fetch/hour/URL. |
| No server call state | Deterministic kivo_id rooms make late-join/reconnect trivial; only 30s ring timer. |
| Optimistic UI | Round-trip waits feel laggy; paint β reconcile β retry. |
| Drafts in localStorage | Half-typed text is device ephemera, not account data. |
| Bulk + async push | Sender latency must not scale with recipient count. |
| No refresh rotation | Rotation races strand clients; Session delete already revokes. |
| Duplicate DM in service | Mongo unique on arrays β pairwise; explicit $all is correct. |
| Voice = attachment | MediaRecorder β existing upload β play URL. Only new state: duration + kind. No transcoding. |
| Chat look CSS-first | color-mix + container classes re-tint dark/light/Space with zero extra state. |
| Plus = flag + expiry | Client never trusted; getEffectivePlan() + caps + sweep. |
| Wave = notification | No row/badge spam; same bell+push pipe, deep-links to profile. |
| Receipts derived | readBy/deliveredTo + participants β no extra store. |
| Nearby fuzzed one-shot | Distance-only + rounding + no tracking = discovery without surveillance. |
| Emoji sparse-unique tiers | Same :name: across scopes, no collision; Plus funds expression. |
| Sweep deletes files first | TTL alone orphans Appwrite files; explicit hourly cleanup. |
| Appwrite not S3 | Free tier + preview URLs + simple SDK for a solo dev; migrate when egress/cost demands. |
| Bun not plain Node/npm | Speed of install/run; still Node-compatible at runtime. |
Run yourself, configure & deploy
# backend (terminal 1) cd backend && bun install cp .env.example .env # fill MongoDB/JWT/Appwrite/VAPID/Gmail/LiveKit/ADMIN_* bun run dev # http://localhost:4000 (prod: bun run start) # frontend (terminal 2) cd frontend && bun install echo "NEXT_PUBLIC_API_URL=http://localhost:4000" > .env.local bun run dev # http://localhost:3000 (prod: build + start) bun run lint # Biome check # push: npx web-push generate-vapid-keys β VAPID_* in backend .env # calls stay hidden until LIVEKIT_* set; storage errors until APPWRITE_* set
Environment variables
| Var | Where | Purpose |
|---|---|---|
PORT Β· MONGODB_URI | backend | API/Socket port (4000) Β· Atlas/local connection |
ACCESS_TOKEN_SECRET/REFRESH_TOKEN_SECRET Β· ACCESS_TOKEN_TTL 15m Β· REFRESH_TOKEN_TTL 7d Β· REFRESH_COOKIE_SAMESITE | backend | JWT signing + lifetimes + cookie mode (strict; lax for cross-subdomain) |
CORS_ALLOWED_ORIGINS | backend | Allowed frontends (empty = allow any in dev) |
APPWRITE_ENDPOINT/PROJECT_ID/API_KEY Β· APPWRITE_BUCKET_ID Β· APPWRITE_ATTACHMENTS_BUCKET_ID Β· APPWRITE_EMOJI_BUCKET_ID | backend | Storage creds Β· avatars Β· attachments (fallback for emoji) Β· emoji (fallback = attachments) |
VAPID_PUBLIC/PRIVATE_KEY/SUBJECT | backend | Web-push keys (generate via web-push) |
LIVEKIT_URL/API_KEY/API_SECRET | backend | Calls; unset = call buttons hidden |
GMAIL_USER/APP_PASSWORD Β· EMAIL_FROM Β· FRONTEND_URL | backend | SMTP sender + From header + base URL for email links (default localhost:3000) |
ADMIN_EMAIL Β· ADMIN_PASSWORD_HASH Β· ADMIN_JWT_SECRET/TTL 30m Β· ADMIN_COOKIE_NAME | backend | Separate admin login (bcrypt hash, own JWT + cookie) |
GOOGLE_CLIENT_ID/SECRET/REDIRECT_URI Β· GITHUB_* | backend | OAuth (redirect defaults ${FRONTEND_URL}/api/v1/auth/oauth/*/callback) |
BACKEND_URL Β· NEXT_PUBLIC_API_URL | frontend | Rewrite target + direct Socket origin (both default localhost:4000) |
NEXT_PUBLIC_PLUS_UPI_ID Β· NEXT_PUBLIC_PLUS_PAYEE | frontend | Published UPI ID + payee name on /plus |
Deploy checklist
Frontend on Vercel-class host with rewrites β backend. Backend on single always-on host with long-lived WebSockets. Atlas + Appwrite + VAPID env set. trust proxy on behind LB. *.env gitignored, .env.example maintained.
LB β N API/Socket servers β Redis (adapter, limits, counters) β Mongo. Split services only when org/scale forces it. Add metrics (p95 POST, socket fan-out, push success), log AdminActionLog longer, back up Mongo.
Questions & answers
π Everyday FAQ (no jargon)
Terms, plainly
Arrives the moment it's sent β no refresh.
Two-way wire that stays open browserβserver.
Broadcast group β e.g. one per chat. Only members hear it.
Signed tamper-proof ticket proving who you are.
Cookie JavaScript can't read β holds your login session.
Show it instantly, confirm when server answers.
Installable website / background script for offline + push.
Browser database for cache / queue of offline sends.
Standard letting servers ping browsers when closed.
Schema checker rejecting bad input on the server.
Tricking a server into fetching internal URLs β Kivo guards previews against it.
Media server forwarding audio/video without mixing β scales calls.
Time-based authenticator codes β second login step.
12-digit UPI payment reference you paste to claim Plus.
Mongo geo-index + nearest-first query powering Nearby.
Roadmap & limits
Shipped (Phase 1 + 1.5): DMs, groups, Spaces/channels, notifications + prefs + push, PWA + offline + outbox, 10 themes + studio + Space palettes + chat look, attachments + voice waveform, profiles + badges + blocking + wave + QR, friends + welcome + Nearby, polls, Status text+photo, threads + saved + forward + pin, Ctrl+K search, link previews, LiveKit calls, 2FA, OAuth, custom emoji, Plus UPI, admin panel, media gallery, delete, Seen-by, drafts/swipe/jump/dividers/big-emoji.
Next (Phase 2β4): stronger search, video attachments, voice rooms, screen share, recording, bots/webhooks, dev platform, mini-apps, theme marketplace.
Not / limits: no E2E encryption (server sees plaintext) Β· offline history = last-50/chat Β· no video attachments yet Β· single-instance presence (needs shared adapter to scale) Β· disappearing messages removed 2026-09-08. If you demo this, say the limits out loud β interviewers trust it more.