Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ Secure, temporary chat rooms that auto-destruct. Built with React 18, Vite, and

- **Instant Rooms** — Create a chat room in seconds with a unique share code
- **No Registration** — Just pick a username and start chatting anonymously
- **Floating Message Actions** — Hover over any message to reply, edit, or delete cleanly
- **Message Actions Everywhere** — Hover or right-click on desktop, long-press on mobile, to react, reply, copy, edit, or delete
- **Direct Messages (Beta)** — Every account gets a random handle for one-to-one, end-to-end encrypted chats that clear after 24 hours. Rolling out gradually: every app load gives a non-beta account a small chance of being enrolled (enrolment never reverts on its own), and anyone can opt in or out at will from Settings. Backed by the `tags.beta` / `tags.betaOptOut` flags on the Firestore user document
- **Release Notes** — In-app "What's new" dialog, shown once per version and reopenable from Settings
- **Mobile First** — Responsive Material 3 Expressive layouts with One UI ergonomics, safe-area and on-screen keyboard handling
- **Prominent Room Cards** — Modern M3 cards with expiration badges and direct Join buttons
- **Read Receipts** — Optional room feature displaying who has read the latest messages
- **Auto-Destruct** — Rooms and all messages are permanently deleted when they expire
Expand All @@ -22,7 +25,7 @@ Secure, temporary chat rooms that auto-destruct. Built with React 18, Vite, and
## Tech Stack

- **Frontend:** React 18, Vite (Fast HMR & build optimization)
- **Design System:** Material 3 Expressive (custom CSS implementation)
- **Design System:** Material 3 Expressive with One UI inspired ergonomics (custom CSS implementation)
- **Database:** Firebase Firestore (real-time listeners)
- **Auth:** Firebase Anonymous Authentication
- **Icons:** Material Symbols Rounded
Expand Down Expand Up @@ -70,6 +73,30 @@ Secure, temporary chat rooms that auto-destruct. Built with React 18, Vite, and
3. Set the **Output Directory** to: `dist`
4. Deploy!

## Data Model & Security

Firestore collections:

| Collection | Contents | Who can read it |
| --- | --- | --- |
| `rooms` | Public and private room metadata (including the private room code) | Any signed-in client — the home list and join-by-code lookup both query it |
| `direct_threads` | Beta direct-message threads and their encryption key | Only the two participants |
| `messages` | Message documents for both rooms and direct threads | Any signed-in client (direct message bodies are encrypted with the thread key) |
| `usernames` / `handles` | One-shot reservation documents that bind a name or DM handle to an account | Any signed-in client, writable once by the owner |
| `users` | Profile document with rollout tags | Only the account that owns it |

`firestore.rules` enforces that rooms and messages are only edited or deleted by
their owner or author, that usernames and DM handles are immutable once claimed,
that a message's `sender` matches the reservation held by the caller, and that
messages can only be posted into a direct thread the caller belongs to. Room and
thread codes — which are also the encryption secrets — are generated from
`crypto.getRandomValues`.

Known, accepted limitations: message ciphertext and metadata are readable by any
signed-in client, private room codes are visible to anyone listing rooms (this is
what makes join-by-code work), and typing/presence markers inside public rooms are
not bound to an identity.

## License

CC0-1.0 — Public Domain
Expand Down
242 changes: 217 additions & 25 deletions firestore.rules
Original file line number Diff line number Diff line change
Expand Up @@ -2,50 +2,242 @@ rules_version = '2';

service cloud.firestore {
match /databases/{database}/documents {
// Users collection

function isSignedIn() {
return request.auth != null;
}

function changedKeys() {
return request.resource.data.diff(resource.data).affectedKeys();
}

function onlyChanged(keys) {
return changedKeys().hasOnly(keys);
}

function isExpired(data) {
return data.keys().hasAny(['expires_at']) && data.expires_at < request.time;
}

// A username is owned by whoever reserved it first. Messages are bound to
// that reservation so nobody can post under someone else's name.
function usernamePath(name) {
return /databases/$(database)/documents/usernames/$(name.lower());
}

function ownsName(name) {
return name is string
&& name.size() > 0
&& (!exists(usernamePath(name))
|| get(usernamePath(name)).data.authUid == request.auth.uid);
}
Comment on lines +28 to +33

// Username reservations: created once, never edited, released only by the
// account that holds them.
match /usernames/{name} {
allow read: if isSignedIn();
allow create: if isSignedIn()
&& request.resource.data.authUid == request.auth.uid
&& request.resource.data.keys().hasOnly(['authUid', 'userId', 'username']);
allow update: if false;
allow delete: if isSignedIn() && resource.data.authUid == request.auth.uid;
}

// Direct-message handle reservations, same one-shot ownership model.
match /handles/{handle} {
allow read: if isSignedIn();
allow create: if isSignedIn()
&& request.resource.data.authUid == request.auth.uid
&& request.resource.data.keys().hasOnly(['authUid', 'username']);
allow update: if false;
allow delete: if isSignedIn() && resource.data.authUid == request.auth.uid;
}

// Users collection — a profile is private to the account that owns it.
match /users/{userId} {
allow read: if request.auth != null;
allow create: if request.auth != null
allow read: if isSignedIn() && resource.data.authUid == request.auth.uid;

allow create: if isSignedIn()
&& request.resource.data.authUid == request.auth.uid
&& request.resource.data.username is string;
allow delete: if request.auth != null && resource.data.authUid == request.auth.uid;
&& request.resource.data.username is string
&& request.resource.data.username.size() >= 3
&& request.resource.data.username.size() <= 20
&& request.resource.data.keys().hasOnly(['username', 'authUid', 'dmHandle', 'tags', 'created_at']);

// Owners may only change their rollout tags, plus a one-time backfill of
// their direct-message handle. Usernames and the auth binding are
// immutable so identities cannot be hijacked or impersonated.
allow update: if isSignedIn()
&& resource.data.authUid == request.auth.uid
&& request.resource.data.authUid == resource.data.authUid
&& onlyChanged(['dmHandle', 'tags'])
&& request.resource.data.tags is map
&& (!resource.data.keys().hasAny(['dmHandle'])
|| request.resource.data.dmHandle == resource.data.dmHandle);

allow delete: if isSignedIn() && resource.data.authUid == request.auth.uid;
}

// Rooms collection

// Public and private rooms. Rooms are discoverable by design: the home
// screen lists public rooms and joining a private room means looking it up
// by its code, so any signed-in client may read this collection.
match /rooms/{roomId} {
allow read: if request.auth != null;
allow create: if request.auth != null

function isRoomOwner() {
return resource.data.authUid == request.auth.uid;
}

allow read: if isSignedIn();

allow create: if isSignedIn()
&& request.resource.data.authUid == request.auth.uid
&& request.resource.data.name is string
&& request.resource.data.creator is string;
allow update: if request.auth != null;
allow delete: if request.auth != null;
&& request.resource.data.name.size() > 0
&& request.resource.data.name.size() <= 120
&& request.resource.data.creator is string
&& ownsName(request.resource.data.creator)
&& request.resource.data.expires_at is timestamp;

// Owners manage their own room (including handing it over on logout).
// Everyone else may only bump the conversation preview.
allow update: if isSignedIn() && (
isRoomOwner() || onlyChanged(['latestMessage', 'updated_at'])
);

allow delete: if isSignedIn() && (isRoomOwner() || isExpired(resource.data));

// Subcollections under rooms
match /typing/{doc} {
allow read, write: if request.auth != null;
allow read, write: if isSignedIn();
}
match /presence/{doc} {
allow read, write: if request.auth != null;
allow read, write: if isSignedIn();
}
match /read_receipts/{doc} {
allow read, write: if request.auth != null;
allow read, write: if isSignedIn();
}
}

// Messages collection

// Direct message threads (beta). Everything about a thread, including the
// encryption key it carries, is restricted to its two participants. The
// single `participants` condition is what makes the client's
// array-contains query provable to the rules engine.
match /direct_threads/{threadId} {

function isParticipant() {
return request.auth.uid in resource.data.participants;
}

allow read: if isSignedIn() && isParticipant();

allow create: if isSignedIn()
&& request.resource.data.authUid == request.auth.uid
&& request.resource.data.participants is list
&& request.resource.data.participants.size() == 2
&& request.auth.uid in request.resource.data.participants
&& request.resource.data.isPrivate == true
&& request.resource.data.code is string
&& request.resource.data.code.size() >= 16
&& request.resource.data.creator is string
&& ownsName(request.resource.data.creator)
&& request.resource.data.expires_at is timestamp;

// The key, the membership and the expiry are frozen for the lifetime of
// the thread; only the conversation preview moves.
allow update: if isSignedIn()
&& isParticipant()
&& onlyChanged(['latestMessage', 'updated_at']);

// Either side can clear the thread at any time, and expired threads are
// swept by whoever notices them first.
allow delete: if isSignedIn() && (isParticipant() || isExpired(resource.data));

match /typing/{doc} {
allow read, write: if isSignedIn() && request.auth.uid in get(/databases/$(database)/documents/direct_threads/$(threadId)).data.participants;
}
match /presence/{doc} {
allow read, write: if isSignedIn() && request.auth.uid in get(/databases/$(database)/documents/direct_threads/$(threadId)).data.participants;
}
match /read_receipts/{doc} {
allow read, write: if isSignedIn() && request.auth.uid in get(/databases/$(database)/documents/direct_threads/$(threadId)).data.participants;
}
}

// Messages for both rooms and direct threads
match /messages/{messageId} {
allow read: if request.auth != null;
allow create: if request.auth != null

function threadPath(roomId) {
return /databases/$(database)/documents/direct_threads/$(roomId);
}

function roomPath(roomId) {
return /databases/$(database)/documents/rooms/$(roomId);
}

function isMessageAuthor() {
return resource.data.authUid == request.auth.uid;
}

function ownsParentRoom() {
return exists(roomPath(resource.data.room_id))
&& get(roomPath(resource.data.room_id)).data.authUid == request.auth.uid;
}

// Either side of a direct thread can wipe it, matching the promise that
// direct chats can be cleared at any time.
function isThreadMember(roomId) {
return exists(threadPath(roomId))
&& request.auth.uid in get(threadPath(roomId)).data.participants;
}

// A message may only be posted to a room that exists, or to a direct
// thread the sender actually belongs to.
function mayPostIn(roomId) {
return exists(threadPath(roomId)) ? isThreadMember(roomId) : exists(roomPath(roomId));
}

// Message bodies are readable by any signed-in client. Direct message
// bodies (and their reply previews) are end-to-end encrypted with a key
// that lives on the thread document, which only the two participants can
// read, so what leaks here is ciphertext plus metadata (sender, room id,
// timestamps, reactions). This is deliberate: room history has to stay
// listable by `room_id` for every member, and a query on `room_id` alone
// cannot prove thread membership to the rules engine.
allow read: if isSignedIn();

allow create: if isSignedIn()
&& request.resource.data.authUid == request.auth.uid
&& request.resource.data.content is string;
allow update: if request.auth != null;
allow delete: if request.auth != null;
&& request.resource.data.room_id is string
&& request.resource.data.sender is string
&& ownsName(request.resource.data.sender)
&& request.resource.data.content is string
&& request.resource.data.content.size() > 0
// Encrypted payloads expand well beyond the plaintext limit enforced
// by the client, so this is a storage-abuse guard rather than a UI cap
&& request.resource.data.content.size() <= 65536
&& mayPostIn(request.resource.data.room_id);

// Authors edit their own text; anyone in the conversation can react.
allow update: if isSignedIn() && (
(isMessageAuthor() && onlyChanged(['content', 'edited', 'reactions']))
|| onlyChanged(['reactions'])
);
Comment on lines +219 to +223

// Authors and room owners can delete. Vanishing / one-time-view messages
// are removed by the recipient's client, and anyone may sweep messages
// whose lifetime has already run out.
allow delete: if isSignedIn() && (
isMessageAuthor()
|| resource.data.keys().hasAny(['vanishTimeSeconds'])
|| resource.data.keys().hasAny(['isBurnAfterReading'])
|| isExpired(resource.data)
|| ownsParentRoom()
|| isThreadMember(resource.data.room_id)
);
}

// Typing indicators top-level match fallback
match /typing/{roomId} {
allow read, write: if request.auth != null;
allow read, write: if isSignedIn();
}
}
}
16 changes: 10 additions & 6 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,22 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content">
<meta name="theme-color" content="#0f1221">
<meta name="color-scheme" content="dark">
<meta name="description" content="TempChats — Secure, temporary chat rooms that auto-destruct. No registration required. Private, ephemeral conversations.">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>TempChats — Secure Temporary Chat Rooms</title>

<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,400,1,0" rel="stylesheet">
<script src="/config/firebase.js"></script>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,400,1,0&display=block">

<script defer src="/config/firebase.js"></script>
</head>
<body>
<div id="root"></div>
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "tempchats",
"private": true,
"version": "2.0.0",
"version": "2.2.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
Loading