diff --git a/README.md b/README.md
index 7f38518..626e678 100644
--- a/README.md
+++ b/README.md
@@ -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
@@ -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
@@ -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
diff --git a/firestore.rules b/firestore.rules
index 581cb76..aa536fe 100644
--- a/firestore.rules
+++ b/firestore.rules
@@ -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);
+ }
+
+ // 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'])
+ );
+
+ // 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();
}
}
}
diff --git a/index.html b/index.html
index d4bce46..d2426a9 100644
--- a/index.html
+++ b/index.html
@@ -2,18 +2,22 @@
-
+
+
+
+
+
TempChats — Secure Temporary Chat Rooms
-
+
-
-
-
-
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
index ceeeb3c..9af2c20 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "tempchats",
- "version": "2.0.0",
+ "version": "2.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tempchats",
- "version": "2.0.0",
+ "version": "2.2.0",
"dependencies": {
"firebase": "^10.12.0",
"react": "^18.3.1",
diff --git a/package.json b/package.json
index 87ec097..36ea8c3 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "tempchats",
"private": true,
- "version": "2.0.0",
+ "version": "2.2.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/src/App.jsx b/src/App.jsx
index 9b5fabd..3477572 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -1,13 +1,19 @@
-import React, { useState, useEffect, useCallback } from 'react';
+import React, { useState, useEffect, useCallback, useRef } from 'react';
import { db, auth } from './firebase';
import LoginView from './views/LoginView';
import HomeView from './views/HomeView';
import CreateRoomView from './views/CreateRoomView';
import ChatRoomView from './views/ChatRoomView';
+import DirectMessagesView from './views/DirectMessagesView';
import Snackbar from './components/Snackbar';
import PrivacyModal from './components/PrivacyModal';
import DialogModal from './components/DialogModal';
import UserSettingsModal from './components/UserSettingsModal';
+import ReleaseNotesModal from './components/ReleaseNotesModal';
+import { ensureUserProfile, setBetaPreference, getUsernameOwner, releaseIdentity } from './utils/profile';
+import { deleteDocsInBatches } from './utils/batch';
+import { isBetaUser, DIRECT_THREADS } from './utils/beta';
+import { CURRENT_RELEASE } from './releaseNotes';
export default function App() {
const [user, setUser] = useState(() => {
@@ -24,7 +30,9 @@ export default function App() {
const hash = window.location.hash.slice(1);
if (!hash || hash === 'login') return { view: 'home' };
if (hash === 'create') return { view: 'create' };
+ if (hash === 'dms') return { view: 'dms' };
if (hash.startsWith('chat/')) return { view: 'chat', id: hash.split('/')[1] };
+ if (hash.startsWith('dm/')) return { view: 'dmchat', id: hash.split('/')[1] };
return { view: 'home' };
});
@@ -32,16 +40,79 @@ export default function App() {
const [isPrivacyModalOpen, setIsPrivacyModalOpen] = useState(false);
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
const [isLogoutConfirmOpen, setIsLogoutConfirmOpen] = useState(false);
+ const [isReleaseNotesOpen, setIsReleaseNotesOpen] = useState(false);
+
+ const snackbarTimerRef = useRef(null);
const showSnackbar = useCallback((message, type = 'info') => {
setSnackbar({ message, type });
- setTimeout(() => setSnackbar({ message: '', type: 'info' }), 4000);
+ clearTimeout(snackbarTimerRef.current);
+ snackbarTimerRef.current = setTimeout(() => setSnackbar({ message: '', type: 'info' }), 4000);
}, []);
- const updateSettings = (newSettings) => {
+ useEffect(() => () => clearTimeout(snackbarTimerRef.current), []);
+
+ const closeReleaseNotes = useCallback(() => {
+ setIsReleaseNotesOpen(false);
+ localStorage.setItem('tempchats_release_seen', CURRENT_RELEASE);
+ }, []);
+
+ // Sync rollout tags & direct-message handle for accounts created before
+ // those fields existed, and pick up server-side changes to the beta tag.
+ useEffect(() => {
+ if (!user?.uid) return undefined;
+
+ let cancelled = false;
+ ensureUserProfile(user).then((updated) => {
+ if (cancelled || !updated) return;
+
+ // The reserved name now belongs to a different account — this session is
+ // no longer valid, so send the user back to the login screen.
+ if (updated.usernameConflict) {
+ localStorage.removeItem('tempchats_user');
+ setUser(null);
+ showSnackbar('That username now belongs to someone else. Please pick a new one.', 'error');
+ return;
+ }
+
+ const changed = updated.dmHandle !== user.dmHandle
+ || JSON.stringify(updated.tags || {}) !== JSON.stringify(user.tags || {});
+ if (!changed) return;
+ localStorage.setItem('tempchats_user', JSON.stringify(updated));
+ setUser(updated);
+ });
+
+ return () => { cancelled = true; };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [user?.uid]);
+
+ // Surface the release notes once per version
+ useEffect(() => {
+ if (!user?.uid) return;
+ if (localStorage.getItem('tempchats_release_seen') !== CURRENT_RELEASE) {
+ setIsReleaseNotesOpen(true);
+ }
+ }, [user?.uid]);
+
+ const updateBetaPreference = useCallback(async (enabled) => {
+ if (!user?.uid) return;
+ try {
+ const updated = await setBetaPreference(user, enabled);
+ localStorage.setItem('tempchats_user', JSON.stringify(updated));
+ setUser(updated);
+ showSnackbar(enabled ? 'Direct messages beta enabled' : 'Direct messages beta disabled');
+ const currentHash = window.location.hash.slice(1);
+ if (!enabled && (currentHash === 'dms' || currentHash.startsWith('dm/'))) window.location.hash = 'home';
+ } catch (err) {
+ console.error('Beta preference error:', err);
+ showSnackbar('Could not update the beta setting. Please try again.', 'error');
+ }
+ }, [user, showSnackbar]);
+
+ const updateSettings = useCallback((newSettings) => {
setSettings(newSettings);
localStorage.setItem('tempchats_settings', JSON.stringify(newSettings));
- };
+ }, []);
// Hash Navigation Handler
useEffect(() => {
@@ -49,7 +120,9 @@ export default function App() {
const hash = window.location.hash.slice(1);
if (!hash || hash === 'login') setRoute({ view: 'home' });
else if (hash === 'create') setRoute({ view: 'create' });
+ else if (hash === 'dms') setRoute({ view: 'dms' });
else if (hash.startsWith('chat/')) setRoute({ view: 'chat', id: hash.split('/')[1] });
+ else if (hash.startsWith('dm/')) setRoute({ view: 'dmchat', id: hash.split('/')[1] });
else setRoute({ view: 'home' });
};
@@ -57,13 +130,19 @@ export default function App() {
return () => window.removeEventListener('hashchange', handleHashChange);
}, []);
- const navigate = (path) => {
+ // Stable identity: child effects depend on this and would otherwise
+ // re-subscribe to Firestore on every App re-render.
+ const navigate = useCallback((path) => {
window.location.hash = path;
- };
+ }, []);
// Automatic Expired Messages & Orphaned Clean Routine
+ const authUidRef = useRef(user?.authUid || null);
+ authUidRef.current = user?.authUid || null;
+
useEffect(() => {
const purgeExpiredData = async () => {
+ const currentAuthUid = authUidRef.current;
try {
const now = new Date();
// Query expired messages
@@ -73,18 +152,43 @@ export default function App() {
.get();
if (!snap.empty) {
- const batch = db.batch();
- snap.docs.forEach((doc) => batch.delete(doc.ref));
- await batch.commit();
+ await deleteDocsInBatches(snap.docs.map((doc) => doc.ref));
console.log(`Purged ${snap.size} expired messages`);
}
+
+ // Direct message threads are disposable — remove the thread documents
+ // once their 24h lifetime is over so they stop showing up anywhere.
+ // Only the current user's own threads are queried; other people's
+ // direct threads are not readable.
+ if (currentAuthUid) {
+ const ownThreads = await db.collection(DIRECT_THREADS)
+ .where('participants', 'array-contains', currentAuthUid)
+ .limit(50)
+ .get();
+
+ const expiredDirect = ownThreads.docs.filter((doc) => {
+ const data = doc.data();
+ if (!data.expires_at) return false;
+ const exp = data.expires_at.toDate ? data.expires_at.toDate() : new Date(data.expires_at);
+ return exp < now;
+ });
+
+ if (expiredDirect.length > 0) {
+ await deleteDocsInBatches(expiredDirect.map((doc) => doc.ref));
+ }
+ }
} catch (err) {
// Quiet catch for index or permission constraints
}
};
- purgeExpiredData();
- const interval = setInterval(purgeExpiredData, 60000);
+ const purgeIfVisible = () => {
+ if (!document.hidden) purgeExpiredData();
+ };
+
+ purgeIfVisible();
+ // Every client used to run this every 60s, even in background tabs.
+ const interval = setInterval(purgeIfVisible, 300000);
return () => clearInterval(interval);
}, []);
@@ -152,10 +256,8 @@ export default function App() {
if (activeTypers.length > 0) {
newOwnerName = activeTypers[0].id;
- const chatterDoc = await db.collection('users').where('username', '==', newOwnerName).get();
- if (!chatterDoc.empty) {
- newOwnerUid = chatterDoc.docs[0].data().authUid;
- }
+ const chatterOwner = await getUsernameOwner(newOwnerName);
+ if (chatterOwner) newOwnerUid = chatterOwner.authUid;
} else {
const entry = Array.from(recentChatters.entries())[0];
newOwnerName = entry[0];
@@ -174,19 +276,25 @@ export default function App() {
// Room is abandoned — delete room and all associated messages
const roomMsgs = await db.collection('messages').where('room_id', '==', roomId).get();
- const batch = db.batch();
- roomMsgs.docs.forEach((d) => batch.delete(d.ref));
- batch.delete(roomDoc.ref);
- await batch.commit();
+ await deleteDocsInBatches([...roomMsgs.docs.map((d) => d.ref), roomDoc.ref]);
console.log(`Deleted abandoned room ${roomId} and its messages`);
}
- // 2. Delete user's user document
+ // 2. Destroy every direct thread the user takes part in — direct chats
+ // are never handed over, they die with the account.
+ const dmSnap = await db.collection(DIRECT_THREADS).where('participants', 'array-contains', user.authUid).get();
+ for (const dmDoc of dmSnap.docs) {
+ const dmMsgs = await db.collection('messages').where('room_id', '==', dmDoc.id).get();
+ await deleteDocsInBatches([...dmMsgs.docs.map((d) => d.ref), dmDoc.ref]);
+ }
+
+ // 3. Delete the user document and free the reserved name and handle
if (user.uid) {
await db.collection('users').doc(user.uid).delete().catch(() => {});
}
+ await releaseIdentity(user);
- // 3. Wiping auth session & local storage
+ // 4. Wiping auth session & local storage
localStorage.removeItem('tempchats_user');
setUser(null);
await auth.signOut();
@@ -221,9 +329,32 @@ export default function App() {
onOpenPrivacyModal={() => setIsPrivacyModalOpen(true)}
onOpenSettings={() => setIsSettingsModalOpen(true)}
showSnackbar={showSnackbar}
+ isBeta={isBetaUser(user)}
/>
)}
+ {route.view === 'dms' && (
+ isBetaUser(user) ? (
+ setIsLogoutConfirmOpen(true)}
+ onOpenSettings={() => setIsSettingsModalOpen(true)}
+ showSnackbar={showSnackbar}
+ />
+ ) : (
+ setIsLogoutConfirmOpen(true)}
+ onOpenPrivacyModal={() => setIsPrivacyModalOpen(true)}
+ onOpenSettings={() => setIsSettingsModalOpen(true)}
+ showSnackbar={showSnackbar}
+ isBeta={false}
+ />
+ )
+ )}
+
{route.view === 'create' && (
)}
+ {route.view === 'dmchat' && (
+ isBetaUser(user) ? (
+ setIsLogoutConfirmOpen(true)}
+ onOpenSettings={() => setIsSettingsModalOpen(true)}
+ showSnackbar={showSnackbar}
+ settings={settings}
+ />
+ ) : (
+ setIsLogoutConfirmOpen(true)}
+ onOpenPrivacyModal={() => setIsPrivacyModalOpen(true)}
+ onOpenSettings={() => setIsSettingsModalOpen(true)}
+ showSnackbar={showSnackbar}
+ isBeta={false}
+ />
+ )
+ )}
+
{route.view === 'chat' && (
setIsSettingsModalOpen(false)}
onOpenPrivacyModal={() => setIsPrivacyModalOpen(true)}
+ onOpenReleaseNotes={() => setIsReleaseNotesOpen(true)}
+ isBeta={isBetaUser(user)}
+ onUpdateBeta={updateBetaPreference}
/>
+
+
{/* Logout Confirmation Dialog */}
{
+ if (isOpen) setVal(initialValue);
+ }, [isOpen, initialValue]);
+
+ // Close on Escape and lock background scrolling while open
+ useEffect(() => {
+ if (!isOpen) return undefined;
+ const onKeyDown = (e) => { if (e.key === 'Escape') onCancel?.(); };
+ document.addEventListener('keydown', onKeyDown);
+ const prevOverflow = document.body.style.overflow;
+ document.body.style.overflow = 'hidden';
+ return () => {
+ document.removeEventListener('keydown', onKeyDown);
+ document.body.style.overflow = prevOverflow;
+ };
+ }, [isOpen, onCancel]);
+
if (!isOpen) return null;
const handleSubmit = (e) => {
@@ -22,11 +39,11 @@ export default function DialogModal({
};
return (
-