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 ( -
-
e.stopPropagation()}> +
+
e.stopPropagation()} role="dialog" aria-modal="true" aria-label={title}>

{title}

- {typeof content === 'string' ?

{content}

: content} - + {typeof content === 'string' ?

{content}

: content} +
{showInput && (
@@ -45,9 +62,11 @@ export default function DialogModal({ )}
- + {cancelText && ( + + )} diff --git a/src/components/MessageBubble.jsx b/src/components/MessageBubble.jsx index 20c276a..43907d8 100644 --- a/src/components/MessageBubble.jsx +++ b/src/components/MessageBubble.jsx @@ -1,20 +1,29 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useCallback, memo } from 'react'; +import { createPortal } from 'react-dom'; -export default function MessageBubble({ msg, isOwn, isContinuation, onReply, onEdit, onDelete, onReact, compactMode }) { +const EMOJIS = ['👍', '❤️', '🔥', '😂', '🎉']; +const LONG_PRESS_MS = 450; + +function MessageBubble({ msg, isOwn, isContinuation, onReply, onEdit, onDelete, onReact, compactMode }) { const [vanishSeconds, setVanishSeconds] = useState(msg.vanishTimeSeconds || null); const [isRevealed, setIsRevealed] = useState(!msg.isBurnAfterReading); const [burnSeconds, setBurnSeconds] = useState(null); + const [isSheetOpen, setIsSheetOpen] = useState(false); + const [isPressed, setIsPressed] = useState(false); const onDeleteRef = useRef(onDelete); onDeleteRef.current = onDelete; + const pressTimerRef = useRef(null); + const pressOriginRef = useRef(null); + const timeStr = msg.created_at?.toDate ? msg.created_at.toDate().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''; // 1. Vanish Timer Fix (Does not restart on re-renders) useEffect(() => { - if (!msg.vanishTimeSeconds || isOwn) return; + if (!msg.vanishTimeSeconds || isOwn) return undefined; setVanishSeconds(msg.vanishTimeSeconds); const interval = setInterval(() => { @@ -33,7 +42,7 @@ export default function MessageBubble({ msg, isOwn, isContinuation, onReply, onE // 2. Burn After Reading (One-Time Reveal Timer) useEffect(() => { - if (!msg.isBurnAfterReading || !isRevealed || isOwn) return; + if (!msg.isBurnAfterReading || !isRevealed || isOwn) return undefined; setBurnSeconds(3); const interval = setInterval(() => { @@ -50,59 +59,100 @@ export default function MessageBubble({ msg, isOwn, isContinuation, onReply, onE return () => clearInterval(interval); }, [msg.isBurnAfterReading, isRevealed, msg.id, isOwn]); - const emojis = ['👍', '❤️', '🔥', '😂', '🎉']; + // 3. Long press opens the action sheet — the hover toolbar is unreachable on touch + const cancelLongPress = useCallback(() => { + clearTimeout(pressTimerRef.current); + pressOriginRef.current = null; + setIsPressed(false); + }, []); + + useEffect(() => () => clearTimeout(pressTimerRef.current), []); + + const handlePointerDown = (e) => { + if (e.pointerType === 'mouse') return; + pressOriginRef.current = { x: e.clientX, y: e.clientY }; + setIsPressed(true); + pressTimerRef.current = setTimeout(() => { + setIsPressed(false); + setIsSheetOpen(true); + if (navigator.vibrate) navigator.vibrate(12); + }, LONG_PRESS_MS); + }; + + const handlePointerMove = (e) => { + const origin = pressOriginRef.current; + if (!origin) return; + if (Math.abs(e.clientX - origin.x) > 10 || Math.abs(e.clientY - origin.y) > 10) cancelLongPress(); + }; + + const handleContextMenu = (e) => { + // Right click (desktop) and the native touch callout both land here + e.preventDefault(); + setIsSheetOpen(true); + }; + + const runAction = (action) => { + setIsSheetOpen(false); + action(); + }; + + const bubbleContent = msg.decryptedContent || msg.content; return (
- {/* Floating Action Toolbar */} + {/* Pointer-device floating toolbar */}
-
- {emojis.map((emoji) => ( +
+ {EMOJIS.map((emoji) => ( ))}
- {isOwn && ( <> - - )}
- {/* Message Bubble */} -
- {!isOwn && !isContinuation && ( -
- {msg.sender} -
- )} + {/* Message bubble */} +
+ {!isOwn && !isContinuation &&
{msg.sender}
} {msg.reply_to && (
-
- Replying to {msg.reply_to.sender} -
-
{msg.reply_to.content}
+
Replying to {msg.reply_to.sender}
+
{msg.decryptedReply || msg.reply_to.content}
)} @@ -112,34 +162,22 @@ export default function MessageBubble({ msg, isOwn, isContinuation, onReply, onE type="button" className="md-btn md-btn--tonal" onClick={() => setIsRevealed(true)} - style={{ height: '32px', fontSize: '0.8rem', padding: '0 12px' }} + style={{ minHeight: '40px', fontSize: '0.8rem', padding: '0 14px' }} > - visibility_off - Tap to Reveal (Destroys in 3s) + + Tap to reveal (destroys in 3s) ) : (
- {msg.decryptedContent || msg.content} + {bubbleContent} {msg.edited && (edited)}
)} - {/* Reactions */} {msg.reactions && Object.keys(msg.reactions).length > 0 && ( -
+
{Object.entries(msg.reactions).map(([emoji, count]) => ( - + {emoji} {count} @@ -147,21 +185,80 @@ export default function MessageBubble({ msg, isOwn, isContinuation, onReply, onE
)} - {/* Status & Time */} -
+
{burnSeconds !== null ? ( - - Destroying in {burnSeconds}s - + Destroying in {burnSeconds}s ) : vanishSeconds !== null ? ( - - Vanishing in {vanishSeconds}s - - ) : } + Vanishing in {vanishSeconds}s + ) : ( + + )} {timeStr}
+ + {/* Touch action sheet — portalled to because the message list uses + paint containment, which would otherwise trap the fixed overlay. */} + {isSheetOpen && createPortal( +
setIsSheetOpen(false)} role="presentation"> +
e.stopPropagation()} role="dialog" aria-label="Message actions"> +
+ {EMOJIS.map((emoji) => ( + + ))} +
+ +
+ + + + + {isOwn && ( + <> + + + + )} +
+ + +
+
, + document.body + )}
); } + +export default memo(MessageBubble); diff --git a/src/components/PrivacyBanner.jsx b/src/components/PrivacyBanner.jsx index 11f95de..6a10497 100644 --- a/src/components/PrivacyBanner.jsx +++ b/src/components/PrivacyBanner.jsx @@ -11,25 +11,33 @@ export default function PrivacyBanner({ onOpenPrivacyModal }) { }; return ( -
-
-
- shield_lock - Your Privacy Matters +
+
+
+ + Your privacy matters
-
-

+

Chats auto-delete. No personal data collected. Automated safety filtering active.{' '} - { e.preventDefault(); onOpenPrivacyModal(); }} - style={{ color: 'var(--md-sys-color-primary)', fontWeight: 600, textDecoration: 'none' }} +

); diff --git a/src/components/PrivacyModal.jsx b/src/components/PrivacyModal.jsx index 6b3a028..54f3c86 100644 --- a/src/components/PrivacyModal.jsx +++ b/src/components/PrivacyModal.jsx @@ -1,32 +1,47 @@ import React from 'react'; +const SECTIONS = [ + { + title: '1. No personal data required', + body: 'We do not collect email addresses, phone numbers, or passwords. Anonymous accounts are linked only to your local browser session.' + }, + { + title: '2. Cookies & local storage', + body: 'We use LocalStorage strictly for maintaining your active session key. We do not place tracking cookies or share analytical profiles with third parties.' + }, + { + title: '3. Safety & content filtering', + body: 'To ensure a safe environment, public room messages pass through automated content filters. Messages containing severe abuse or illegal content are subject to automatic removal or account restriction.' + }, + { + title: '4. Automatic self-destruction', + body: 'When a chat room expires, all messages, read receipts, and room metadata are permanently wiped from server memory.' + }, + { + title: '5. Direct messages (beta)', + body: 'If you join the direct messages beta, your account is given a random handle that other people can use to start a one-to-one chat with you. Direct threads are end-to-end encrypted, are readable only by the two people in them, and are deleted automatically after 24 hours — or sooner if either person deletes them. You can leave the beta at any time from Settings.' + } +]; + export default function PrivacyModal({ isOpen, onClose }) { if (!isOpen) return null; return ( -
-
e.stopPropagation()} style={{ maxWidth: '560px' }}> -

Privacy & Trust Terms

-
+
+
e.stopPropagation()} style={{ maxWidth: '560px' }} role="dialog" aria-modal="true" aria-label="Privacy and trust terms"> +

Privacy & trust terms

+

Welcome to TempChats! We believe in ephemeral communication and giving you full control over your data.

- -

1. No Personal Data Required

-

We do not collect email addresses, phone numbers, or passwords. Anonymous accounts are linked only to your local browser session.

- -

2. Cookies & Local Storage

-

We use LocalStorage strictly for maintaining your active session key. We do not place tracking cookies or share analytical profiles with third parties.

- -

3. Safety & Content Filtering

-

To ensure a safe environment, public room messages pass through automated content filters. Messages containing severe abuse or illegal content are subject to automatic removal or account restriction.

- -

4. Automatic Self-Destruction

-

When a chat room expires, all messages, read receipts, and room metadata are permanently wiped from server memory.

+ {SECTIONS.map((section) => ( +
+

{section.title}

+

{section.body}

+
+ ))}
-
- +
+
diff --git a/src/components/ReleaseNotesModal.jsx b/src/components/ReleaseNotesModal.jsx new file mode 100644 index 0000000..612cd72 --- /dev/null +++ b/src/components/ReleaseNotesModal.jsx @@ -0,0 +1,70 @@ +import React, { useEffect } from 'react'; +import { RELEASE_NOTES } from '../releaseNotes'; + +export default function ReleaseNotesModal({ isOpen, onClose }) { + useEffect(() => { + if (!isOpen) return undefined; + const onKeyDown = (e) => { if (e.key === 'Escape') onClose?.(); }; + 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, onClose]); + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()} role="dialog" aria-modal="true" aria-label="Release notes"> +
+

+ + What’s new +

+ +
+ +
+ {RELEASE_NOTES.map((release) => ( +
+
+ Version {release.version} + {release.date} +
+ + {release.highlights.map((item) => ( +
+ +
+
+ {item.title} + {item.badge && {item.badge}} +
+

{item.description}

+ {item.badge && ( +

+ This feature is rolling out gradually to beta testers and randomly selected users for testing, so it may not be + available on your account yet. +

+ )} +
+
+ ))} +
+ ))} +
+ +
+ +
+
+
+ ); +} diff --git a/src/components/RoomCard.jsx b/src/components/RoomCard.jsx index 0f2cf95..81c17cc 100644 --- a/src/components/RoomCard.jsx +++ b/src/components/RoomCard.jsx @@ -1,40 +1,43 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, memo } from 'react'; -export default function RoomCard({ room, onJoin }) { +function RoomCard({ room, onJoin }) { const [timeLeft, setTimeLeft] = useState(''); const [isUrgent, setIsUrgent] = useState(false); useEffect(() => { - if (!room.expires_at) return; + if (!room.expires_at) return undefined; + + let interval; const calculateTime = () => { const target = room.expires_at.toDate ? room.expires_at.toDate() : new Date(room.expires_at); const diffMs = target.getTime() - Date.now(); if (diffMs <= 0) { setTimeLeft('Expired'); + setIsUrgent(true); + clearInterval(interval); return; } setIsUrgent(diffMs < 30 * 60000); const mins = Math.floor(diffMs / 60000); const hours = Math.floor(mins / 60); const remMins = mins % 60; - if (hours > 0) setTimeLeft(`${hours}h ${remMins}m left`); - else setTimeLeft(`${remMins}m left`); + setTimeLeft(hours > 0 ? `${hours}h ${remMins}m left` : `${remMins}m left`); }; calculateTime(); - const interval = setInterval(calculateTime, 30000); + interval = setInterval(calculateTime, 30000); return () => clearInterval(interval); }, [room.expires_at]); return ( -
+
-
+

{room.name}

- person + {room.creator}
@@ -42,24 +45,24 @@ export default function RoomCard({ room, onJoin }) { {timeLeft && (
- timer + {timeLeft}
)}
{room.latestMessage && ( -

- {room.latestMessage} -

+

{room.latestMessage}

)}
-
+
); } + +export default memo(RoomCard); diff --git a/src/components/Snackbar.jsx b/src/components/Snackbar.jsx index 4d70c3c..ca8657d 100644 --- a/src/components/Snackbar.jsx +++ b/src/components/Snackbar.jsx @@ -3,13 +3,13 @@ import React from 'react'; export default function Snackbar({ message, type = 'info', onClose }) { if (!message) return null; return ( -
- +
+ - {message} -
); diff --git a/src/components/TopAppBar.jsx b/src/components/TopAppBar.jsx index b2b7b6e..6325621 100644 --- a/src/components/TopAppBar.jsx +++ b/src/components/TopAppBar.jsx @@ -2,16 +2,20 @@ import React from 'react'; export default function TopAppBar({ user, onLogout, onBack, title = 'TempChats', extraActions, onOpenSettings }) { return ( -
-
+
+
{onBack && ( - )}
- {!onBack && chat_bubble} - {title} + {!onBack && ( + + )} + {title}
@@ -20,13 +24,15 @@ export default function TopAppBar({ user, onLogout, onBack, title = 'TempChats', {user && ( <> {onOpenSettings && ( - )} -
{user.username.charAt(0).toUpperCase()}
- {user.username} - diff --git a/src/components/UserSettingsModal.jsx b/src/components/UserSettingsModal.jsx index da50814..c651824 100644 --- a/src/components/UserSettingsModal.jsx +++ b/src/components/UserSettingsModal.jsx @@ -1,100 +1,121 @@ import React from 'react'; -export default function UserSettingsModal({ isOpen, settings, onUpdateSettings, onClose, onOpenPrivacyModal }) { +const SETTINGS = [ + { + key: 'soundEnabled', + icon: 'notifications_active', + title: 'Message chimes', + description: 'Play a subtle chime when sending & receiving messages' + }, + { + key: 'compactMode', + icon: 'density_small', + title: 'Compact spacing', + description: 'Reduce padding between chat bubbles' + }, + { + key: 'readReceiptsEnabled', + icon: 'done_all', + title: 'Share read receipts', + description: 'Allow rooms to show when you have read messages' + } +]; + +export default function UserSettingsModal({ isOpen, settings, onUpdateSettings, onClose, onOpenPrivacyModal, onOpenReleaseNotes, isBeta = false, onUpdateBeta }) { if (!isOpen) return null; return ( -
-
e.stopPropagation()} style={{ maxWidth: '500px' }}> -
-

- settings - User Settings +
+
e.stopPropagation()} role="dialog" aria-modal="true" aria-label="User settings"> +
+

+ + Settings

-
-
- {/* Sound Toggle */} -
-
-
Message Chimes
-
- Play a subtle chime when sending & receiving messages -
-
- -
- - {/* Compact Mode Toggle */} -
-
-
Compact Message Spacing
-
- Reduce padding between chat bubbles +
+ {SETTINGS.map((item) => ( +
+
+ +
+
{item.title}
+
{item.description}
+
+
- -
+ ))} - {/* Read Receipts Preference */} -
-
-
Share Read Receipts
-
- Allow rooms to show when you have read messages + {onUpdateBeta && ( +
+
+ +
+
+ Direct messages + Beta +
+
+ Try one-to-one chats that clear after 24 hours. This feature is still rolling out gradually, so turn it on here to + join early or turn it off to leave the beta. +
+
+
- -
- -
+ )} -
-
-
+ +
+ +
); diff --git a/src/releaseNotes.js b/src/releaseNotes.js new file mode 100644 index 0000000..3105f37 --- /dev/null +++ b/src/releaseNotes.js @@ -0,0 +1,80 @@ +// ========================================================================== +// Release notes shown in the in-app "What's new" dialog. +// Bump CURRENT_RELEASE whenever a new entry is added so the dialog is +// surfaced once to every user. +// ========================================================================== + +export const CURRENT_RELEASE = '2.2.0'; + +export const RELEASE_NOTES = [ + { + version: '2.2.0', + date: 'August 2026', + highlights: [ + { + icon: 'science', + title: 'Turn on direct messages yourself', + description: + 'Direct messages now have a switch in Settings, so you no longer have to wait to be picked. Accounts that are not in the beta also get a small chance of being added every time the app loads, and once you are in you stay in unless you switch it off yourself.', + badge: 'Beta — gradual rollout' + }, + { + icon: 'shield_lock', + title: 'Hardened security rules', + description: + 'Direct threads and their encryption keys are readable only by the two people in them, usernames and handles can no longer be changed or impersonated, and messages and rooms can only be edited or deleted by the people they belong to.' + }, + { + icon: 'delete_sweep', + title: 'Delete a direct chat early', + description: 'Remove a direct thread and everything in it straight from the direct messages list instead of waiting 24 hours.' + } + ] + }, + { + version: '2.1.0', + date: 'August 2026', + highlights: [ + { + icon: 'forum', + title: 'Direct messages', + description: + 'Every account now gets a random handle you can share so people can message you one to one. Direct chats are end-to-end encrypted and clear themselves after 24 hours.', + badge: 'Beta — gradual rollout' + }, + { + icon: 'right_click', + title: 'Right-click message actions', + description: + 'Right-clicking a message on desktop opens the same action sheet as long-pressing on mobile, including copy, reply, edit and delete.' + }, + { + icon: 'news', + title: 'Release notes', + description: 'This dialog. Reopen it any time from Settings → What\u2019s new.' + } + ] + }, + { + version: '2.0.0', + date: 'July 2026', + highlights: [ + { + icon: 'phone_android', + title: 'Mobile-first redesign', + description: + 'Material 3 Expressive layouts with One UI ergonomics, safe-area handling and on-screen keyboard awareness.' + }, + { + icon: 'touch_app', + title: 'Touch message actions', + description: 'Long-press any message to react, reply, copy, edit or delete it.' + }, + { + icon: 'bolt', + title: 'Faster rooms', + description: 'Windowed message listeners and cached decryption keep long rooms smooth on low-end phones.' + } + ] + } +]; diff --git a/src/styles.css b/src/styles.css index b7ba7aa..661347b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,93 +1,164 @@ /* ========================================================================== - Material 3 Expressive Design System — TempChats + TempChats Design System + Material 3 Expressive tokens + One UI ergonomics (large titles, bottom + reachable actions, soft capsule shapes). Mobile first. ========================================================================== */ :root { - /* Color Tokens — Dark Theme (Indigo Primary) */ - --md-sys-color-primary: #818cf8; - --md-sys-color-on-primary: #1e1b4b; - --md-sys-color-primary-container: #3730a3; - --md-sys-color-on-primary-container: #e0e7ff; - - --md-sys-color-secondary: #a5b4fc; + /* ---- Color tokens — dark scheme (indigo primary) ---- */ + --md-sys-color-primary: #a5b0ff; + --md-sys-color-on-primary: #171a4d; + --md-sys-color-primary-container: #3b37a8; + --md-sys-color-on-primary-container: #e3e6ff; + + --md-sys-color-secondary: #b9c1ff; --md-sys-color-on-secondary: #1e1b4b; - --md-sys-color-secondary-container: #312e81; - --md-sys-color-on-secondary-container: #e0e7ff; + --md-sys-color-secondary-container: #322e8c; + --md-sys-color-on-secondary-container: #e6e9ff; - --md-sys-color-tertiary: #c084fc; + --md-sys-color-tertiary: #d7b4ff; --md-sys-color-on-tertiary: #3b0764; - --md-sys-color-tertiary-container: #581c87; - --md-sys-color-on-tertiary-container: #f3e8ff; + --md-sys-color-tertiary-container: #592c8f; + --md-sys-color-on-tertiary-container: #f4e8ff; - --md-sys-color-error: #f87171; + --md-sys-color-error: #ff9d9d; --md-sys-color-on-error: #450a0a; - --md-sys-color-error-container: #7f1d1d; - --md-sys-color-on-error-container: #fef2f2; + --md-sys-color-error-container: #8d2323; + --md-sys-color-on-error-container: #ffeceb; + + --md-sys-color-success: #6ee7a5; - --md-sys-color-surface: #0f1221; + --md-sys-color-surface: #0d1020; --md-sys-color-on-surface: #f1f5f9; --md-sys-color-surface-variant: #1e243b; - --md-sys-color-on-surface-variant: #94a3b8; - - --md-sys-color-surface-container-lowest: #0a0d18; - --md-sys-color-surface-container-low: #13172a; - --md-sys-color-surface-container: #181d34; - --md-sys-color-surface-container-high: #212845; - --md-sys-color-surface-container-highest: #2b3459; - - --md-sys-color-outline: #475569; - --md-sys-color-outline-variant: #334155; - - /* Typography Scale */ - --md-sys-typescale-display-large: 700 3.5rem/4rem 'Inter', sans-serif; - --md-sys-typescale-display-medium: 700 2.8rem/3.2rem 'Inter', sans-serif; - --md-sys-typescale-display-small: 600 2.2rem/2.6rem 'Inter', sans-serif; - --md-sys-typescale-title-large: 600 1.4rem/1.8rem 'Inter', sans-serif; - --md-sys-typescale-title-medium: 600 1.1rem/1.5rem 'Inter', sans-serif; - --md-sys-typescale-title-small: 600 0.95rem/1.3rem 'Inter', sans-serif; - --md-sys-typescale-body-large: 400 1rem/1.5rem 'Inter', sans-serif; - --md-sys-typescale-body-medium: 400 0.9rem/1.35rem 'Inter', sans-serif; - --md-sys-typescale-body-small: 400 0.8rem/1.2rem 'Inter', sans-serif; - --md-sys-typescale-label-large: 600 0.9rem/1.2rem 'Inter', sans-serif; - --md-sys-typescale-label-medium: 500 0.8rem/1.1rem 'Inter', sans-serif; - --md-sys-typescale-label-small: 500 0.7rem/1rem 'Inter', sans-serif; - - /* Shapes & Radius */ + --md-sys-color-on-surface-variant: #a3aec4; + + --md-sys-color-surface-container-lowest: #080a14; + --md-sys-color-surface-container-low: #12162a; + --md-sys-color-surface-container: #171c33; + --md-sys-color-surface-container-high: #202742; + --md-sys-color-surface-container-highest: #2a3355; + + --md-sys-color-outline: #56617c; + --md-sys-color-outline-variant: #313a56; + + --md-sys-color-scrim: rgba(3, 5, 12, 0.72); + + /* State layers */ + --md-sys-state-hover: rgba(255, 255, 255, 0.08); + --md-sys-state-pressed: rgba(255, 255, 255, 0.12); + + /* ---- Expressive typography scale ---- */ + --md-sys-typescale-display-large: 700 2.75rem/3rem 'Inter', system-ui, sans-serif; + --md-sys-typescale-display-medium: 700 2.25rem/2.6rem 'Inter', system-ui, sans-serif; + --md-sys-typescale-display-small: 700 1.85rem/2.2rem 'Inter', system-ui, sans-serif; + --md-sys-typescale-headline-large: 700 1.65rem/2.1rem 'Inter', system-ui, sans-serif; + --md-sys-typescale-title-large: 650 1.3rem/1.7rem 'Inter', system-ui, sans-serif; + --md-sys-typescale-title-medium: 600 1.05rem/1.45rem 'Inter', system-ui, sans-serif; + --md-sys-typescale-title-small: 600 0.95rem/1.3rem 'Inter', system-ui, sans-serif; + --md-sys-typescale-body-large: 400 1rem/1.5rem 'Inter', system-ui, sans-serif; + --md-sys-typescale-body-medium: 400 0.9rem/1.4rem 'Inter', system-ui, sans-serif; + --md-sys-typescale-body-small: 400 0.8rem/1.25rem 'Inter', system-ui, sans-serif; + --md-sys-typescale-label-large: 600 0.9rem/1.2rem 'Inter', system-ui, sans-serif; + --md-sys-typescale-label-medium: 500 0.8rem/1.15rem 'Inter', system-ui, sans-serif; + --md-sys-typescale-label-small: 500 0.72rem/1rem 'Inter', system-ui, sans-serif; + + /* ---- Shape (One UI leans on generous, soft radii) ---- */ --md-sys-shape-corner-none: 0px; - --md-sys-shape-corner-extra-small: 4px; - --md-sys-shape-corner-small: 8px; - --md-sys-shape-corner-medium: 12px; - --md-sys-shape-corner-large: 16px; - --md-sys-shape-corner-extra-large: 24px; + --md-sys-shape-corner-extra-small: 6px; + --md-sys-shape-corner-small: 10px; + --md-sys-shape-corner-medium: 16px; + --md-sys-shape-corner-large: 22px; + --md-sys-shape-corner-extra-large: 28px; + --md-sys-shape-corner-extra-large-increased: 36px; --md-sys-shape-corner-full: 9999px; - /* Motion & Elevation */ + /* ---- Motion (expressive spatial springs) ---- */ + --md-sys-motion-easing-standard: cubic-bezier(0.2, 0, 0, 1); --md-sys-motion-easing-emphasized: cubic-bezier(0.2, 0, 0, 1); --md-sys-motion-easing-emphasized-decelerate: cubic-bezier(0.05, 0.7, 0.1, 1); - --md-sys-elevation-1: 0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2); - --md-sys-elevation-2: 0 4px 12px rgba(0, 0, 0, 0.35); - --md-sys-elevation-3: 0 8px 24px rgba(0, 0, 0, 0.45); + --md-sys-motion-easing-spring: cubic-bezier(0.18, 0.9, 0.22, 1.08); + --md-sys-motion-duration-short: 150ms; + --md-sys-motion-duration-medium: 260ms; + --md-sys-motion-duration-long: 420ms; + + /* ---- Elevation ---- */ + --md-sys-elevation-1: 0 1px 2px rgba(0, 0, 0, 0.35), 0 1px 3px rgba(0, 0, 0, 0.2); + --md-sys-elevation-2: 0 4px 14px rgba(0, 0, 0, 0.38); + --md-sys-elevation-3: 0 12px 32px rgba(0, 0, 0, 0.5); + + /* ---- Layout ---- */ + --layout-gutter: 16px; + --safe-top: env(safe-area-inset-top, 0px); + --safe-bottom: env(safe-area-inset-bottom, 0px); + --safe-left: env(safe-area-inset-left, 0px); + --safe-right: env(safe-area-inset-right, 0px); +} + +@media (min-width: 600px) { + :root { --layout-gutter: 24px; } } -/* Reset & Global */ +/* ========================================================================== + Reset & base + ========================================================================== */ + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +html { + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; +} + body { - font-family: 'Inter', sans-serif; + font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif; background-color: var(--md-sys-color-surface); color: var(--md-sys-color-on-surface); - min-height: 100vh; + min-height: 100dvh; line-height: 1.5; overflow-x: hidden; + overscroll-behavior-y: none; -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; +} + +/* Never let a stray element push the viewport sideways on phones. */ +#root { isolation: isolate; } + +button, input, select, textarea { + font-family: inherit; + color: inherit; } -/* Typography Classes */ +button { -webkit-tap-highlight-color: transparent; touch-action: manipulation; } + +:focus-visible { + outline: 2px solid var(--md-sys-color-primary); + outline-offset: 2px; +} + +::selection { background: var(--md-sys-color-primary-container); } + +/* Slim, unobtrusive scrollbars (hidden entirely on touch devices) */ +* { scrollbar-width: thin; scrollbar-color: var(--md-sys-color-outline) transparent; } +*::-webkit-scrollbar { width: 8px; height: 8px; } +*::-webkit-scrollbar-thumb { + background: var(--md-sys-color-outline-variant); + border-radius: var(--md-sys-shape-corner-full); +} +*::-webkit-scrollbar-track { background: transparent; } + +/* ========================================================================== + Typography helpers + ========================================================================== */ + .display-small { font: var(--md-sys-typescale-display-small); } +.headline-large { font: var(--md-sys-typescale-headline-large); } .title-large { font: var(--md-sys-typescale-title-large); } .title-medium { font: var(--md-sys-typescale-title-medium); } .title-small { font: var(--md-sys-typescale-title-small); } @@ -95,153 +166,277 @@ body { .body-medium { font: var(--md-sys-typescale-body-medium); } .body-small { font: var(--md-sys-typescale-body-small); } -/* Material Symbol Helper */ +.text-muted { color: var(--md-sys-color-on-surface-variant); } + +.truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +/* ========================================================================== + Material Symbols + ========================================================================== */ + .material-symbols-rounded { - font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24; + font-family: 'Material Symbols Rounded'; + font-weight: normal; + font-style: normal; + font-size: 24px; + line-height: 1; + letter-spacing: normal; + text-transform: none; display: inline-block; + white-space: nowrap; + word-wrap: normal; + direction: ltr; + font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24; + -webkit-font-feature-settings: 'liga'; + font-feature-settings: 'liga'; + -webkit-font-smoothing: antialiased; vertical-align: middle; - line-height: 1; + user-select: none; + flex: none; } -/* Buttons */ +/* ========================================================================== + Buttons — expressive capsules with press-morph feedback + ========================================================================== */ + .md-btn { + position: relative; display: inline-flex; align-items: center; justify-content: center; gap: 8px; - height: 40px; + min-height: 40px; padding: 0 20px; border-radius: var(--md-sys-shape-corner-full); font: var(--md-sys-typescale-label-large); border: none; + background: transparent; cursor: pointer; - transition: all 200ms var(--md-sys-motion-easing-emphasized); text-decoration: none; white-space: nowrap; user-select: none; + overflow: hidden; + transition: + background-color var(--md-sys-motion-duration-short) var(--md-sys-motion-easing-standard), + color var(--md-sys-motion-duration-short) var(--md-sys-motion-easing-standard), + box-shadow var(--md-sys-motion-duration-medium) var(--md-sys-motion-easing-standard), + border-radius var(--md-sys-motion-duration-medium) var(--md-sys-motion-easing-spring), + transform var(--md-sys-motion-duration-medium) var(--md-sys-motion-easing-spring); +} +.md-btn:active:not(:disabled) { + /* M3 Expressive shape morph on press */ + border-radius: var(--md-sys-shape-corner-medium); + transform: scale(0.96); } +.md-btn:disabled { opacity: 0.45; cursor: not-allowed; } + .md-btn--filled { background-color: var(--md-sys-color-primary); color: var(--md-sys-color-on-primary); } -.md-btn--filled:hover { - background-color: #939bf4; - box-shadow: var(--md-sys-elevation-1); -} .md-btn--tonal { background-color: var(--md-sys-color-secondary-container); color: var(--md-sys-color-on-secondary-container); } -.md-btn--tonal:hover { - background-color: #3b379e; -} .md-btn--outlined { - background-color: transparent; color: var(--md-sys-color-primary); border: 1px solid var(--md-sys-color-outline); } -.md-btn--outlined:hover { - background-color: rgba(129, 140, 248, 0.08); +.md-btn--danger { + background-color: var(--md-sys-color-error-container); + color: var(--md-sys-color-on-error-container); } +.md-btn--text { color: var(--md-sys-color-primary); padding: 0 12px; } + .md-btn--icon { - width: 40px; - height: 40px; + width: 44px; + height: 44px; + min-height: 44px; padding: 0; border-radius: var(--md-sys-shape-corner-full); - background: transparent; color: var(--md-sys-color-on-surface-variant); } -.md-btn--icon:hover { - background-color: var(--md-sys-color-surface-container-highest); - color: var(--md-sys-color-on-surface); +.md-btn--icon:active:not(:disabled) { border-radius: var(--md-sys-shape-corner-medium); } + +/* Hover states only where a real pointer exists — avoids sticky states on touch */ +@media (hover: hover) and (pointer: fine) { + .md-btn--filled:hover { box-shadow: var(--md-sys-elevation-1); filter: brightness(1.06); } + .md-btn--tonal:hover { filter: brightness(1.14); } + .md-btn--outlined:hover { background-color: var(--md-sys-state-hover); } + .md-btn--danger:hover { filter: brightness(1.12); } + .md-btn--icon:hover { background-color: var(--md-sys-state-hover); color: var(--md-sys-color-on-surface); } + .md-btn--text:hover { background-color: var(--md-sys-state-hover); } } -.md-btn--danger { - background-color: var(--md-sys-color-error-container); - color: var(--md-sys-color-on-error-container); + +/* Extended FAB — One UI style, thumb reachable */ +.fab { + position: fixed; + right: max(var(--layout-gutter), var(--safe-right)); + bottom: calc(var(--layout-gutter) + var(--safe-bottom)); + z-index: 80; + display: inline-flex; + align-items: center; + gap: 10px; + height: 60px; + padding: 0 24px; + border: none; + border-radius: var(--md-sys-shape-corner-large); + background-color: var(--md-sys-color-primary-container); + color: var(--md-sys-color-on-primary-container); + font: var(--md-sys-typescale-label-large); + box-shadow: var(--md-sys-elevation-3); + cursor: pointer; + transition: transform var(--md-sys-motion-duration-medium) var(--md-sys-motion-easing-spring), + border-radius var(--md-sys-motion-duration-medium) var(--md-sys-motion-easing-spring); +} +.fab:active { transform: scale(0.94); border-radius: var(--md-sys-shape-corner-extra-large); } +.fab .material-symbols-rounded { font-size: 26px; } +/* On larger screens the action cards already expose this action */ +@media (min-width: 600px) { .fab { display: none; } } + +/* Segmented / chip controls */ +.chip-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; } -.md-btn--danger:hover { - background-color: #991b1b; +.chip-row--scroll { + flex-wrap: nowrap; + overflow-x: auto; + scrollbar-width: none; + -webkit-overflow-scrolling: touch; + padding-bottom: 2px; } +.chip-row--scroll::-webkit-scrollbar { display: none; } + +.md-chip { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 36px; + padding: 0 14px; + border-radius: var(--md-sys-shape-corner-full); + border: 1px solid var(--md-sys-color-outline-variant); + background-color: var(--md-sys-color-surface-container-high); + color: var(--md-sys-color-on-surface-variant); + font: var(--md-sys-typescale-label-medium); + white-space: nowrap; + cursor: pointer; + transition: background-color var(--md-sys-motion-duration-short) var(--md-sys-motion-easing-standard), + color var(--md-sys-motion-duration-short) var(--md-sys-motion-easing-standard), + transform var(--md-sys-motion-duration-medium) var(--md-sys-motion-easing-spring); +} +.md-chip:active { transform: scale(0.95); } +.md-chip--selected { + background-color: var(--md-sys-color-primary); + border-color: var(--md-sys-color-primary); + color: var(--md-sys-color-on-primary); + font-weight: 600; +} +.md-chip .material-symbols-rounded { font-size: 18px; } + +/* ========================================================================== + Form controls + ========================================================================== */ -/* Form Inputs */ .md-text-field { position: relative; width: 100%; } .md-text-field__input { width: 100%; - height: 56px; - padding: 20px 16px 6px; + height: 60px; + padding: 24px 16px 8px; background-color: var(--md-sys-color-surface-container-high); border: 1px solid var(--md-sys-color-outline-variant); - border-radius: var(--md-sys-shape-corner-small); + border-radius: var(--md-sys-shape-corner-medium); color: var(--md-sys-color-on-surface); - font: var(--md-sys-typescale-body-large); + /* 16px minimum keeps iOS Safari from zooming the whole page on focus */ + font: 400 1rem/1.5rem 'Inter', system-ui, sans-serif; outline: none; - transition: 200ms; + transition: border-color var(--md-sys-motion-duration-short) var(--md-sys-motion-easing-standard), + background-color var(--md-sys-motion-duration-short) var(--md-sys-motion-easing-standard); } .md-text-field__input:focus { border-color: var(--md-sys-color-primary); - border-width: 2px; background-color: var(--md-sys-color-surface-container-highest); } .md-text-field__label { position: absolute; - left: 16px; - top: 18px; + left: 17px; + top: 19px; color: var(--md-sys-color-on-surface-variant); font: var(--md-sys-typescale-body-large); pointer-events: none; - transition: 200ms var(--md-sys-motion-easing-emphasized); + transform-origin: left top; + transition: transform var(--md-sys-motion-duration-medium) var(--md-sys-motion-easing-emphasized), + color var(--md-sys-motion-duration-short) var(--md-sys-motion-easing-standard); } .md-text-field__input:focus ~ .md-text-field__label, .md-text-field__input:not(:placeholder-shown) ~ .md-text-field__label { - top: 6px; - font: var(--md-sys-typescale-label-small); + transform: translateY(-12px) scale(0.72); color: var(--md-sys-color-primary); + font-weight: 600; } -/* Switch & Select */ .md-select { width: 100%; - height: 48px; - padding: 0 16px; + min-height: 56px; + padding: 0 44px 0 16px; background-color: var(--md-sys-color-surface-container-high); border: 1px solid var(--md-sys-color-outline-variant); border-radius: var(--md-sys-shape-corner-medium); color: var(--md-sys-color-on-surface); - font: var(--md-sys-typescale-body-large); + font: 400 1rem/1.5rem 'Inter', system-ui, sans-serif; outline: none; -} + appearance: none; + background-image: linear-gradient(45deg, transparent 50%, var(--md-sys-color-on-surface-variant) 50%), + linear-gradient(135deg, var(--md-sys-color-on-surface-variant) 50%, transparent 50%); + background-position: calc(100% - 22px) 50%, calc(100% - 16px) 50%; + background-size: 6px 6px, 6px 6px; + background-repeat: no-repeat; +} +.md-select:focus { border-color: var(--md-sys-color-primary); } + +/* One UI style switch */ .md-switch { position: relative; display: inline-block; width: 52px; height: 32px; + flex: none; } -.md-switch input { - opacity: 0; - width: 0; - height: 0; -} +.md-switch input { position: absolute; opacity: 0; width: 100%; height: 100%; margin: 0; cursor: pointer; } .md-switch__track { position: absolute; - cursor: pointer; inset: 0; background-color: var(--md-sys-color-surface-container-highest); border: 2px solid var(--md-sys-color-outline); border-radius: var(--md-sys-shape-corner-full); - transition: 200ms; + transition: background-color var(--md-sys-motion-duration-medium) var(--md-sys-motion-easing-standard), + border-color var(--md-sys-motion-duration-medium) var(--md-sys-motion-easing-standard); + pointer-events: none; } .md-switch__thumb { position: absolute; height: 16px; width: 16px; left: 6px; - bottom: 6px; + top: 50%; + margin-top: -8px; background-color: var(--md-sys-color-outline); border-radius: var(--md-sys-shape-corner-full); - transition: 200ms var(--md-sys-motion-easing-emphasized); + transition: transform var(--md-sys-motion-duration-medium) var(--md-sys-motion-easing-spring), + width var(--md-sys-motion-duration-medium) var(--md-sys-motion-easing-spring), + height var(--md-sys-motion-duration-medium) var(--md-sys-motion-easing-spring), + margin var(--md-sys-motion-duration-medium) var(--md-sys-motion-easing-spring), + background-color var(--md-sys-motion-duration-medium) var(--md-sys-motion-easing-standard); } .md-switch input:checked + .md-switch__track { background-color: var(--md-sys-color-primary); @@ -252,72 +447,178 @@ body { background-color: var(--md-sys-color-on-primary); width: 24px; height: 24px; + margin-top: -12px; left: 2px; - bottom: 2px; } +.md-switch input:focus-visible + .md-switch__track { outline: 2px solid var(--md-sys-color-primary); outline-offset: 2px; } + +/* Settings row (One UI list item) */ +.setting-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 16px; + border-radius: var(--md-sys-shape-corner-large); + background-color: var(--md-sys-color-surface-container-high); +} +.setting-row__text { min-width: 0; } + +/* ========================================================================== + Surfaces + ========================================================================== */ -/* Card */ .md-card { background-color: var(--md-sys-color-surface-container); border-radius: var(--md-sys-shape-corner-large); border: 1px solid var(--md-sys-color-outline-variant); overflow: hidden; - transition: all 300ms var(--md-sys-motion-easing-emphasized); -} -.md-card--elevated { - box-shadow: var(--md-sys-elevation-1); + transition: transform var(--md-sys-motion-duration-medium) var(--md-sys-motion-easing-spring), + box-shadow var(--md-sys-motion-duration-medium) var(--md-sys-motion-easing-standard), + border-color var(--md-sys-motion-duration-medium) var(--md-sys-motion-easing-standard); } +.md-card--elevated { box-shadow: var(--md-sys-elevation-1); } + +/* ========================================================================== + Top app bar — compact bar + One UI large title + ========================================================================== */ -/* App Header */ .top-app-bar { - height: 64px; - padding: 0 20px; - display: flex; - align-items: center; - justify-content: space-between; - background-color: var(--md-sys-color-surface); - border-bottom: 1px solid var(--md-sys-color-outline-variant); position: sticky; top: 0; z-index: 100; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 60px; + padding: max(4px, var(--safe-top)) max(8px, var(--safe-right)) 4px max(8px, var(--safe-left)); + background-color: color-mix(in srgb, var(--md-sys-color-surface) 88%, transparent); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border-bottom: 1px solid transparent; + transition: border-color var(--md-sys-motion-duration-medium) var(--md-sys-motion-easing-standard); +} +.top-app-bar--bordered { border-bottom-color: var(--md-sys-color-outline-variant); } + +.top-app-bar__leading { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + flex: 1; } .top-app-bar__title { - font: var(--md-sys-typescale-title-large); + font: var(--md-sys-typescale-title-medium); color: var(--md-sys-color-on-surface); display: flex; align-items: center; - gap: 10px; + gap: 8px; + min-width: 0; +} +.top-app-bar__title > span:last-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .top-app-bar__actions { display: flex; align-items: center; - gap: 12px; + gap: 2px; + flex: none; } +.top-app-bar__username { display: none; } + +@media (min-width: 720px) { + .top-app-bar { padding-left: max(16px, var(--safe-left)); padding-right: max(16px, var(--safe-right)); gap: 12px; } + .top-app-bar__title { font: var(--md-sys-typescale-title-large); } + .top-app-bar__actions { gap: 6px; } + .top-app-bar__username { display: inline; font: var(--md-sys-typescale-label-large); } +} + +/* Extra actions collapse into a horizontally scrollable strip on phones */ +.top-app-bar__extra { + display: flex; + align-items: center; + gap: 6px; + max-width: 100%; + overflow-x: auto; + scrollbar-width: none; +} +.top-app-bar__extra::-webkit-scrollbar { display: none; } + .user-avatar { width: 36px; height: 36px; + flex: none; border-radius: var(--md-sys-shape-corner-full); - background-color: var(--md-sys-color-primary-container); + background: linear-gradient(140deg, var(--md-sys-color-primary-container), var(--md-sys-color-tertiary-container)); color: var(--md-sys-color-on-primary-container); display: flex; align-items: center; justify-content: center; - font: var(--md-sys-typescale-title-medium); + font: var(--md-sys-typescale-title-small); +} + +/* One UI large title header */ +.large-title { + padding: 4px 4px 12px; +} +.large-title__eyebrow { + font: var(--md-sys-typescale-label-medium); + color: var(--md-sys-color-primary); + letter-spacing: 0.06em; + text-transform: uppercase; } +.large-title__text { + font: var(--md-sys-typescale-display-small); + letter-spacing: -0.02em; + margin-top: 2px; +} +.large-title__sub { + font: var(--md-sys-typescale-body-medium); + color: var(--md-sys-color-on-surface-variant); + margin-top: 6px; +} +@media (min-width: 720px) { + .large-title__text { font: var(--md-sys-typescale-display-medium); } +} + +/* ========================================================================== + Layouts + ========================================================================== */ -/* Layout Views */ +.home-layout { min-height: 100dvh; } + +.home-content { + max-width: 1120px; + margin: 0 auto; + padding: + 12px + max(var(--layout-gutter), var(--safe-right)) + calc(96px + var(--safe-bottom)) + max(var(--layout-gutter), var(--safe-left)); +} +@media (min-width: 720px) { + .home-content { padding-top: 20px; padding-bottom: calc(48px + var(--safe-bottom)); } +} + +/* Login */ .login-screen { - min-height: 100vh; + min-height: 100dvh; display: flex; align-items: center; justify-content: center; - padding: 24px; - background: radial-gradient(circle at top right, rgba(129, 140, 248, 0.15), transparent 40%); + padding: max(20px, var(--safe-top)) max(16px, var(--safe-left)) max(20px, var(--safe-bottom)) max(16px, var(--safe-right)); + background: + radial-gradient(120% 80% at 100% 0%, rgba(129, 140, 248, 0.20), transparent 60%), + radial-gradient(110% 70% at 0% 100%, rgba(192, 132, 252, 0.14), transparent 60%); } .login-card { background-color: var(--md-sys-color-surface-container); - padding: 40px; - border-radius: var(--md-sys-shape-corner-extra-large); + border: 1px solid var(--md-sys-color-outline-variant); + padding: 28px 22px; + border-radius: var(--md-sys-shape-corner-extra-large-increased); box-shadow: var(--md-sys-elevation-2); max-width: 440px; width: 100%; @@ -325,170 +626,290 @@ body { flex-direction: column; gap: 24px; } -.home-layout { - min-height: 100vh; -} -.home-content { - max-width: 1100px; - margin: 0 auto; - padding: 32px 24px; +@media (min-width: 480px) { + .login-card { padding: 40px 36px; } } + +/* Home action cards */ .home-actions { display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 20px; - margin-bottom: 32px; + grid-template-columns: 1fr; + gap: 12px; + margin-bottom: 24px; } +@media (min-width: 600px) { + .home-actions { grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; } +} + .action-card { + display: flex; + align-items: center; + gap: 16px; + width: 100%; + text-align: left; background-color: var(--md-sys-color-surface-container); + border: 1px solid var(--md-sys-color-outline-variant); border-radius: var(--md-sys-shape-corner-extra-large); - padding: 28px; + padding: 18px; cursor: pointer; - transition: all 300ms var(--md-sys-motion-easing-emphasized); - border: 1px solid var(--md-sys-color-outline-variant); + transition: transform var(--md-sys-motion-duration-medium) var(--md-sys-motion-easing-spring), + box-shadow var(--md-sys-motion-duration-medium) var(--md-sys-motion-easing-standard), + border-color var(--md-sys-motion-duration-medium) var(--md-sys-motion-easing-standard); } -.action-card:hover { - transform: translateY(-4px); - box-shadow: var(--md-sys-elevation-2); - border-color: var(--md-sys-color-primary); +.action-card:active { transform: scale(0.98); } +@media (hover: hover) and (pointer: fine) { + .action-card:hover { + transform: translateY(-4px); + box-shadow: var(--md-sys-elevation-2); + border-color: var(--md-sys-color-primary); + } +} +@media (min-width: 600px) { + .action-card { flex-direction: column; align-items: flex-start; gap: 12px; padding: 24px; } } .action-card__icon { - width: 56px; - height: 56px; - border-radius: var(--md-sys-shape-corner-large); + width: 52px; + height: 52px; + flex: none; + border-radius: var(--md-sys-shape-corner-medium); background-color: var(--md-sys-color-primary-container); color: var(--md-sys-color-on-primary-container); display: flex; align-items: center; justify-content: center; - margin-bottom: 16px; - font-size: 28px; } +.action-card__icon .material-symbols-rounded { font-size: 28px; } -/* Room Cards Grid */ +/* Search */ +.search-bar { + display: flex; + align-items: center; + background-color: var(--md-sys-color-surface-container-high); + border-radius: var(--md-sys-shape-corner-full); + padding: 6px 16px; + gap: 10px; + min-height: 52px; + border: 1px solid var(--md-sys-color-outline-variant); + transition: border-color var(--md-sys-motion-duration-short) var(--md-sys-motion-easing-standard); +} +.search-bar:focus-within { border-color: var(--md-sys-color-primary); } +.search-bar__input { + border: none; + background: transparent; + flex: 1; + min-width: 0; + color: var(--md-sys-color-on-surface); + font: 400 1rem/1.5rem 'Inter', system-ui, sans-serif; + outline: none; +} + +/* Section headers */ +.section-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin: 28px 0 4px; +} + +/* Room grid */ .rooms-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); - gap: 20px; - margin-top: 20px; + grid-template-columns: 1fr; + gap: 12px; + margin-top: 14px; +} +@media (min-width: 640px) { + .rooms-grid { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; } } + .room-card { display: flex; flex-direction: column; justify-content: space-between; + border-radius: var(--md-sys-shape-corner-extra-large); } -.room-card:hover { - transform: translateY(-4px); - box-shadow: var(--md-sys-elevation-2); - border-color: var(--md-sys-color-primary); +.room-card:active { transform: scale(0.99); } +@media (hover: hover) and (pointer: fine) { + .room-card:hover { + transform: translateY(-4px); + box-shadow: var(--md-sys-elevation-2); + border-color: var(--md-sys-color-primary); + } } .room-card__header { display: flex; justify-content: space-between; align-items: flex-start; - padding: 20px 20px 12px; + gap: 10px; + padding: 18px 18px 10px; } .room-card__title { font: var(--md-sys-typescale-title-medium); color: var(--md-sys-color-on-surface); + overflow-wrap: anywhere; +} +.room-card__meta { + padding: 0 18px; + color: var(--md-sys-color-on-surface-variant); + font: var(--md-sys-typescale-body-small); } +.room-card__preview { + padding: 0 18px; + margin-top: 10px; + color: var(--md-sys-color-on-surface-variant); + font: var(--md-sys-typescale-body-small); +} +.room-card__footer { + padding: 14px 18px; + border-top: 1px solid var(--md-sys-color-outline-variant); + margin-top: 14px; + display: flex; + justify-content: flex-end; +} + .countdown-badge { background-color: var(--md-sys-color-tertiary-container); color: var(--md-sys-color-on-tertiary-container); font: var(--md-sys-typescale-label-small); - padding: 4px 10px; + padding: 5px 10px; border-radius: var(--md-sys-shape-corner-full); display: inline-flex; align-items: center; gap: 4px; + white-space: nowrap; + flex: none; } .countdown-badge--urgent { background-color: var(--md-sys-color-error-container); color: var(--md-sys-color-on-error-container); } -.room-card__meta { - padding: 0 20px; +.countdown-badge .material-symbols-rounded { font-size: 14px; } + +/* Empty state */ +.empty-state { + text-align: center; + padding: 48px 20px; color: var(--md-sys-color-on-surface-variant); - font: var(--md-sys-typescale-body-small); } -.room-card__footer { - padding: 16px 20px; - border-top: 1px solid var(--md-sys-color-outline-variant); - margin-top: 16px; +.empty-state .material-symbols-rounded { font-size: 48px; opacity: 0.5; } + +/* ========================================================================== + Chat + ========================================================================== */ + +.chat-layout { + position: relative; + /* dvh tracks the collapsing mobile browser chrome, so the composer is never + pushed off-screen the way 100vh does. */ + height: 100dvh; + max-height: 100dvh; display: flex; - justify-content: flex-end; + flex-direction: column; + overflow: hidden; } -/* Search bar */ -.search-bar { +.chat-room-bar { display: flex; align-items: center; - background-color: var(--md-sys-color-surface-container-high); - border-radius: var(--md-sys-shape-corner-full); - padding: 8px 20px; - gap: 12px; - border: 1px solid var(--md-sys-color-outline-variant); -} -.search-bar:focus-within { - border-color: var(--md-sys-color-primary); -} -.search-bar__input { - border: none; - background: transparent; - flex: 1; - color: var(--md-sys-color-on-surface); - font: var(--md-sys-typescale-body-large); - outline: none; + gap: 8px; + padding: 8px max(12px, var(--safe-left)) 8px max(12px, var(--safe-right)); + background-color: var(--md-sys-color-surface-container-low); + border-bottom: 1px solid var(--md-sys-color-outline-variant); + overflow-x: auto; + scrollbar-width: none; + -webkit-overflow-scrolling: touch; } +.chat-room-bar::-webkit-scrollbar { display: none; } -/* Chat Room Layout */ -.chat-layout { - height: 100vh; - display: flex; - flex-direction: column; -} .chat-messages { flex: 1; + min-height: 0; overflow-y: auto; - padding: 16px; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + padding: 12px max(10px, var(--safe-left)) 4px max(10px, var(--safe-right)); display: flex; flex-direction: column; - gap: 8px; + gap: 2px; +} +@media (min-width: 720px) { + .chat-messages { padding-left: 20px; padding-right: 20px; } } + .chat-input-area { - padding: 12px 20px; + flex: none; + padding: 8px max(10px, var(--safe-left)) calc(8px + var(--safe-bottom)) max(10px, var(--safe-right)); background-color: var(--md-sys-color-surface-container); border-top: 1px solid var(--md-sys-color-outline-variant); } -.chat-input-bar { +@media (min-width: 720px) { + .chat-input-area { padding-left: 20px; padding-right: 20px; } +} + +.composer-options { display: flex; align-items: center; - gap: 12px; + gap: 6px; + margin-bottom: 8px; + overflow-x: auto; + scrollbar-width: none; + -webkit-overflow-scrolling: touch; +} +.composer-options::-webkit-scrollbar { display: none; } +.composer-options__label { + font: var(--md-sys-typescale-label-small); + color: var(--md-sys-color-on-surface-variant); + flex: none; +} + +.chat-input-bar { + display: flex; + align-items: flex-end; + gap: 8px; background-color: var(--md-sys-color-surface-container-high); - border-radius: var(--md-sys-shape-corner-full); - padding: 6px 8px 6px 20px; + border-radius: var(--md-sys-shape-corner-extra-large); + padding: 6px 6px 6px 16px; border: 1px solid var(--md-sys-color-outline-variant); + transition: border-color var(--md-sys-motion-duration-short) var(--md-sys-motion-easing-standard); } +.chat-input-bar:focus-within { border-color: var(--md-sys-color-primary); } .chat-input-bar__input { border: none; background: transparent; flex: 1; + min-width: 0; + resize: none; + max-height: 120px; + padding: 10px 0; color: var(--md-sys-color-on-surface); - font: var(--md-sys-typescale-body-large); + font: 400 1rem/1.4rem 'Inter', system-ui, sans-serif; outline: none; } +.chat-send-btn { + width: 48px; + height: 48px; + min-height: 48px; + flex: none; + padding: 0; + border-radius: var(--md-sys-shape-corner-full); +} -/* FLOATING MESSAGE ACTIONS & BUBBLE STYLING */ +/* Message rows */ .message-item { display: flex; - margin-bottom: 2px; position: relative; + /* Skip layout/paint work for messages scrolled far out of view */ + content-visibility: auto; + contain-intrinsic-size: auto 60px; + animation: message-in var(--md-sys-motion-duration-medium) var(--md-sys-motion-easing-emphasized-decelerate); } -.message-item--own { - justify-content: flex-end; -} -.message-item--other { - justify-content: flex-start; +.message-item--own { justify-content: flex-end; } +.message-item--other { justify-content: flex-start; } + +@keyframes message-in { + from { opacity: 0; transform: translateY(8px) scale(0.98); } + to { opacity: 1; transform: none; } } .message-bubble-wrapper { @@ -496,100 +917,147 @@ body { display: flex; align-items: center; gap: 8px; - max-width: 75%; + max-width: 88%; + min-width: 0; } -.message-item--own .message-bubble-wrapper { - flex-direction: row-reverse; +@media (min-width: 720px) { + .message-bubble-wrapper { max-width: 72%; } } +.message-item--own .message-bubble-wrapper { flex-direction: row-reverse; } .message-bubble { - padding: 12px 18px; - word-break: break-word; + padding: 10px 14px; + -webkit-touch-callout: none; + overflow-wrap: anywhere; + min-width: 0; position: relative; - transition: transform 200ms ease; + border: none; + box-shadow: none; + transition: transform var(--md-sys-motion-duration-short) var(--md-sys-motion-easing-spring); } +.message-bubble--pressed { transform: scale(0.97); } .message-item--own .message-bubble { background-color: var(--md-sys-color-primary); color: var(--md-sys-color-on-primary); - border-radius: 20px 20px 4px 20px; + border-radius: 22px 22px 6px 22px; } .message-item--other .message-bubble { background-color: var(--md-sys-color-surface-container-high); color: var(--md-sys-color-on-surface); - border-radius: 20px 20px 20px 4px; -} - -/* Floating Action Toolbar on Hover */ -.message-actions-toolbar { - opacity: 0; - visibility: hidden; - transform: translateY(4px) scale(0.95); - transition: all 150ms var(--md-sys-motion-easing-emphasized); - display: flex; - align-items: center; - gap: 2px; - background-color: var(--md-sys-color-surface-container-highest); - border: 1px solid var(--md-sys-color-outline-variant); - border-radius: var(--md-sys-shape-corner-full); - padding: 2px 6px; - box-shadow: var(--md-sys-elevation-2); - z-index: 10; -} -.message-bubble-wrapper:hover .message-actions-toolbar { - opacity: 1; - visibility: visible; - transform: translateY(0) scale(1); + border-radius: 22px 22px 22px 6px; } +.message-item--own.message-item--continuation .message-bubble { border-radius: 22px 6px 6px 22px; } +.message-item--other.message-item--continuation .message-bubble { border-radius: 6px 22px 22px 6px; } .message-sender { font: var(--md-sys-typescale-label-medium); margin-bottom: 4px; - opacity: 0.85; + color: var(--md-sys-color-primary); } .message-time { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; font: var(--md-sys-typescale-label-small); - opacity: 0.65; + opacity: 0.7; margin-top: 4px; - text-align: right; } .message-reply-box { padding: 6px 10px; - background-color: rgba(0, 0, 0, 0.18); + background-color: rgba(0, 0, 0, 0.2); border-radius: var(--md-sys-shape-corner-small); margin-bottom: 6px; - border-left: 3px solid var(--md-sys-color-primary); + border-left: 3px solid currentColor; font: var(--md-sys-typescale-body-small); + min-width: 0; } .message-edited-tag { - font-size: 0.75rem; + font-size: 0.72rem; opacity: 0.7; margin-left: 6px; font-style: italic; } +.message-reactions { + display: flex; + gap: 4px; + margin-top: 6px; + flex-wrap: wrap; +} +.reaction-pill { + display: inline-flex; + align-items: center; + gap: 3px; + background-color: rgba(255, 255, 255, 0.14); + border-radius: var(--md-sys-shape-corner-full); + padding: 2px 8px; + font: var(--md-sys-typescale-label-small); +} + +/* Pointer-only floating toolbar */ +.message-actions-toolbar { + display: none; + opacity: 0; + visibility: hidden; + transform: translateY(4px) scale(0.95); + transition: opacity var(--md-sys-motion-duration-short) var(--md-sys-motion-easing-standard), + transform var(--md-sys-motion-duration-short) var(--md-sys-motion-easing-spring), + visibility var(--md-sys-motion-duration-short); + align-items: center; + gap: 2px; + background-color: var(--md-sys-color-surface-container-highest); + border: 1px solid var(--md-sys-color-outline-variant); + border-radius: var(--md-sys-shape-corner-full); + padding: 2px 6px; + box-shadow: var(--md-sys-elevation-2); + z-index: 10; +} +@media (hover: hover) and (pointer: fine) { + .message-actions-toolbar { display: flex; } + .message-bubble-wrapper:hover .message-actions-toolbar, + .message-bubble-wrapper:focus-within .message-actions-toolbar { + opacity: 1; + visibility: visible; + transform: translateY(0) scale(1); + } +} +.message-actions-toolbar .md-btn--icon { width: 32px; height: 32px; min-height: 32px; } +.message-actions-toolbar .material-symbols-rounded { font-size: 18px; } +.message-actions-toolbar__emojis { + display: flex; + gap: 2px; + border-right: 1px solid var(--md-sys-color-outline-variant); + padding-right: 4px; + margin-right: 2px; +} +.message-actions-toolbar__emojis .md-btn--icon { font-size: 15px; } -/* Read receipts tag */ .read-receipts-bar { - font-size: 0.75rem; + flex: none; + font: var(--md-sys-typescale-label-small); color: var(--md-sys-color-on-surface-variant); text-align: right; - padding: 4px 16px; + padding: 2px 16px 4px; } -/* Typing Indicator */ .typing-indicator { + flex: none; + align-self: flex-start; display: inline-flex; align-items: center; gap: 6px; - padding: 8px 16px; + padding: 8px 14px; background-color: var(--md-sys-color-surface-container-high); border-radius: var(--md-sys-shape-corner-full); - margin: 4px 16px; + margin: 2px 14px 6px; font: var(--md-sys-typescale-body-small); color: var(--md-sys-color-on-surface-variant); + max-width: calc(100% - 28px); } .typing-dot { width: 6px; height: 6px; + flex: none; background-color: var(--md-sys-color-primary); border-radius: 50%; animation: bounce 1.2s infinite ease-in-out; @@ -602,90 +1070,289 @@ body { 40% { transform: scale(1); opacity: 1; } } -/* Modal Overlay */ +/* Jump-to-latest pill */ +.jump-latest { + position: absolute; + left: 50%; + bottom: 12px; + transform: translateX(-50%); + z-index: 20; + box-shadow: var(--md-sys-elevation-2); +} + +/* ========================================================================== + Dialogs — centered on desktop, bottom sheets on phones (One UI) + ========================================================================== */ + .md-dialog-overlay { position: fixed; inset: 0; - background-color: rgba(0, 0, 0, 0.65); - backdrop-filter: blur(4px); + background-color: var(--md-sys-color-scrim); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); display: flex; - align-items: center; + align-items: flex-end; justify-content: center; - padding: 24px; z-index: 1000; - animation: fadeIn 200ms ease-out; + animation: fadeIn var(--md-sys-motion-duration-short) ease-out; } .md-dialog { background-color: var(--md-sys-color-surface-container-high); - border-radius: var(--md-sys-shape-corner-extra-large); - padding: 28px; - max-width: 480px; + border-radius: var(--md-sys-shape-corner-extra-large-increased) var(--md-sys-shape-corner-extra-large-increased) 0 0; + padding: 20px 20px calc(20px + var(--safe-bottom)); width: 100%; + max-height: 92dvh; + overflow-y: auto; + overscroll-behavior: contain; box-shadow: var(--md-sys-elevation-3); display: flex; flex-direction: column; - gap: 20px; + gap: 18px; + animation: sheetUp var(--md-sys-motion-duration-long) var(--md-sys-motion-easing-emphasized-decelerate); } -.md-dialog__title { - font: var(--md-sys-typescale-title-large); +.md-dialog::before { + content: ''; + width: 36px; + height: 4px; + border-radius: var(--md-sys-shape-corner-full); + background-color: var(--md-sys-color-outline); + align-self: center; + margin-bottom: 2px; + opacity: 0.7; } +.md-dialog__title { font: var(--md-sys-typescale-title-large); } .md-dialog__actions { display: flex; - justify-content: flex-end; - gap: 12px; + justify-content: stretch; + gap: 10px; +} +.md-dialog__actions .md-btn { flex: 1; min-height: 48px; } + +@media (min-width: 600px) { + .md-dialog-overlay { align-items: center; padding: 24px; } + .md-dialog { + max-width: 480px; + border-radius: var(--md-sys-shape-corner-extra-large-increased); + padding: 28px; + max-height: 86dvh; + animation: dialogIn var(--md-sys-motion-duration-medium) var(--md-sys-motion-easing-spring); + } + .md-dialog::before { display: none; } + .md-dialog__actions { justify-content: flex-end; } + .md-dialog__actions .md-btn { flex: 0 0 auto; } } -/* Privacy Banner */ +@keyframes sheetUp { + from { transform: translateY(100%); } + to { transform: translateY(0); } +} +@keyframes dialogIn { + from { opacity: 0; transform: scale(0.94); } + to { opacity: 1; transform: scale(1); } +} +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +/* Message action sheet (touch replacement for hover toolbar) */ +.action-sheet__emojis { + display: flex; + justify-content: space-between; + gap: 6px; +} +.action-sheet__emoji { + flex: 1; + min-height: 52px; + border: none; + border-radius: var(--md-sys-shape-corner-large); + background-color: var(--md-sys-color-surface-container-highest); + font-size: 22px; + cursor: pointer; + transition: transform var(--md-sys-motion-duration-medium) var(--md-sys-motion-easing-spring); +} +.action-sheet__emoji:active { transform: scale(0.9); } +.action-sheet__list { + display: flex; + flex-direction: column; + gap: 4px; +} +.action-sheet__item { + display: flex; + align-items: center; + gap: 14px; + width: 100%; + min-height: 52px; + padding: 0 14px; + border: none; + border-radius: var(--md-sys-shape-corner-large); + background: transparent; + color: var(--md-sys-color-on-surface); + font: var(--md-sys-typescale-body-large); + text-align: left; + cursor: pointer; +} +.action-sheet__item:active { background-color: var(--md-sys-state-pressed); } +.action-sheet__item--danger { color: var(--md-sys-color-error); } + +/* ========================================================================== + Banners & snackbar + ========================================================================== */ + .privacy-banner { position: fixed; - bottom: 24px; - right: 24px; + left: max(12px, var(--safe-left)); + right: max(12px, var(--safe-right)); + bottom: calc(84px + var(--safe-bottom)); background-color: var(--md-sys-color-surface-container-high); border-radius: var(--md-sys-shape-corner-extra-large); - padding: 20px; + padding: 16px; box-shadow: var(--md-sys-elevation-3); - max-width: 380px; z-index: 90; border: 1px solid var(--md-sys-color-outline-variant); display: flex; flex-direction: column; - gap: 8px; + gap: 6px; + animation: sheetUp var(--md-sys-motion-duration-long) var(--md-sys-motion-easing-emphasized-decelerate); +} +@media (min-width: 720px) { + .privacy-banner { left: auto; right: 24px; bottom: 24px; max-width: 380px; padding: 20px; } } -/* Toast Snackbar */ .snackbar { position: fixed; - bottom: 24px; - left: 50%; - transform: translateX(-50%); + left: max(12px, var(--safe-left)); + right: max(12px, var(--safe-right)); + bottom: calc(12px + var(--safe-bottom)); background-color: var(--md-sys-color-surface-container-highest); color: var(--md-sys-color-on-surface); - padding: 12px 24px; - border-radius: var(--md-sys-shape-corner-full); - box-shadow: var(--md-sys-elevation-2); + padding: 12px 12px 12px 18px; + border-radius: var(--md-sys-shape-corner-large); + box-shadow: var(--md-sys-elevation-3); display: flex; align-items: center; gap: 12px; z-index: 2000; font: var(--md-sys-typescale-body-medium); - animation: slideUp 300ms var(--md-sys-motion-easing-emphasized-decelerate); + animation: slideUp var(--md-sys-motion-duration-medium) var(--md-sys-motion-easing-emphasized-decelerate); } +.snackbar__text { flex: 1; min-width: 0; overflow-wrap: anywhere; } .snackbar--error { background-color: var(--md-sys-color-error-container); color: var(--md-sys-color-on-error-container); } - +@media (min-width: 600px) { + .snackbar { + left: 50%; + right: auto; + transform: translateX(-50%); + max-width: 520px; + border-radius: var(--md-sys-shape-corner-full); + animation-name: slideUpCentered; + } +} @keyframes slideUp { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} +@keyframes slideUpCentered { from { opacity: 0; transform: translate(-50%, 20px); } to { opacity: 1; transform: translate(-50%, 0); } } -@keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } + +/* ========================================================================== + Beta features & release notes + ========================================================================== */ + +.beta-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 10px; + border-radius: var(--md-sys-shape-corner-full); + background-color: var(--md-sys-color-primary-container); + color: var(--md-sys-color-on-primary-container, #fff); + font: var(--md-sys-typescale-body-small); + font-weight: 600; + letter-spacing: 0.02em; + text-transform: none; + white-space: nowrap; } -.truncate { +.release-notes { + display: flex; + flex-direction: column; + gap: 22px; + max-height: 55vh; + overflow-y: auto; + padding-right: 4px; +} +.release-notes__release { + display: flex; + flex-direction: column; + gap: 12px; +} +.release-notes__version { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + border-bottom: 1px solid var(--md-sys-color-outline-variant); + padding-bottom: 6px; +} +.release-notes__item { + display: flex; + gap: 12px; + align-items: flex-start; +} +.release-notes__warning { + margin-top: 6px; + padding: 8px 10px; + border-radius: var(--md-sys-shape-corner-medium); + background-color: var(--md-sys-color-surface-container-high); + color: var(--md-sys-color-on-surface-variant); +} + +.dm-handle-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 16px; + margin-bottom: 16px; + border-radius: var(--md-sys-shape-corner-extra-large); +} + +.dm-start-form { + display: flex; + gap: 12px; + align-items: center; + flex-wrap: wrap; + margin-bottom: 8px; +} +.dm-start-form .md-btn { min-height: 52px; } + +/* ========================================================================== + Accessibility + ========================================================================== */ + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + scroll-behavior: auto !important; + } +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; overflow: hidden; - text-overflow: ellipsis; + clip: rect(0, 0, 0, 0); white-space: nowrap; + border: 0; } diff --git a/src/utils/batch.js b/src/utils/batch.js new file mode 100644 index 0000000..26c9b49 --- /dev/null +++ b/src/utils/batch.js @@ -0,0 +1,16 @@ +import { db } from '../firebase'; + +// Firestore allows at most 500 writes per batch, so long conversations have to +// be deleted in chunks. Keeping this in one place means every cleanup path +// (logout, purge, deleting a direct chat) behaves the same way. +const BATCH_LIMIT = 450; + +export async function deleteDocsInBatches(refs) { + const list = refs.filter(Boolean); + for (let i = 0; i < list.length; i += BATCH_LIMIT) { + const batch = db.batch(); + list.slice(i, i + BATCH_LIMIT).forEach((ref) => batch.delete(ref)); + // eslint-disable-next-line no-await-in-loop + await batch.commit(); + } +} diff --git a/src/utils/beta.js b/src/utils/beta.js new file mode 100644 index 0000000..e6cc429 --- /dev/null +++ b/src/utils/beta.js @@ -0,0 +1,99 @@ +// ========================================================================== +// Gradual feature rollout helpers +// A user is a beta tester when their Firestore user document carries +// `tags.beta === true`. Missing tags (or an empty tag map) mean "not beta". +// +// Enrolment is one-way: the random roll can only ever turn the flag on. Only +// an explicit action by the user (the Settings toggle) can turn it back off, +// and doing so records `tags.betaOptOut` so the roll stops re-enrolling them. +// ========================================================================== + +// Share of newly registered users that start out in the beta. +const BETA_SIGNUP_RATE = 0.25; + +// Chance of an existing, non-beta user being enrolled on any given app load. +const BETA_REFRESH_RATE = 0.05; + +const HANDLE_ADJECTIVES = [ + 'swift', 'quiet', 'lunar', 'amber', 'brave', 'cosmic', 'velvet', 'nimble', + 'solar', 'hidden', 'clever', 'silent', 'rapid', 'mellow', 'crimson', 'frosty' +]; + +const HANDLE_NOUNS = [ + 'otter', 'falcon', 'cedar', 'comet', 'ember', 'harbor', 'lynx', 'maple', + 'nebula', 'onyx', 'quartz', 'raven', 'summit', 'tide', 'willow', 'zephyr' +]; + +const HANDLE_PATTERN = /^[a-z]+-[a-z]+-\d{4}$/; + +function randomInt(max) { + const buf = new Uint32Array(1); + if (globalThis.crypto?.getRandomValues) { + globalThis.crypto.getRandomValues(buf); + // Reject the tail of the range so the modulo stays uniform + const limit = Math.floor(0x100000000 / max) * max; + let value = buf[0]; + while (value >= limit) { + globalThis.crypto.getRandomValues(buf); + value = buf[0]; + } + return value % max; + } + return Math.floor(Math.random() * max); +} + +function chance(rate) { + return randomInt(1000000) / 1000000 < rate; +} + +export function isBetaUser(user) { + return Boolean(user?.tags?.beta); +} + +export function hasOptedOutOfBeta(user) { + return Boolean(user?.tags?.betaOptOut); +} + +export function rollSignupBetaFlag() { + return chance(BETA_SIGNUP_RATE); +} + +// Existing users are re-rolled on each load until they win a spot. Users who +// already have the flag, or who explicitly opted out, are never re-rolled. +export function shouldEnrolOnRefresh(tags) { + if (tags?.beta) return false; + if (tags?.betaOptOut) return false; + return chance(BETA_REFRESH_RATE); +} + +export function generateDmHandle() { + const adjective = HANDLE_ADJECTIVES[randomInt(HANDLE_ADJECTIVES.length)]; + const noun = HANDLE_NOUNS[randomInt(HANDLE_NOUNS.length)]; + const digits = String(randomInt(10000)).padStart(4, '0'); + return `${adjective}-${noun}-${digits}`; +} + +// Room and thread codes double as encryption secrets, so they come from the +// cryptographic RNG rather than Math.random(). +const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + +export function generateSecureCode(length) { + let out = ''; + for (let i = 0; i < length; i += 1) out += CODE_ALPHABET[randomInt(CODE_ALPHABET.length)]; + return out; +} + +export function normalizeDmHandle(value) { + return String(value || '').trim().toLowerCase().replace(/^@/, ''); +} + +export function isValidDmHandle(value) { + return HANDLE_PATTERN.test(normalizeDmHandle(value)); +} + +// Direct threads live in their own collection so security rules can restrict +// them to their two participants without affecting public room queries. +export const DIRECT_THREADS = 'direct_threads'; + +export const DM_LIFETIME_HOURS = 24; +export const DM_MAX_THREADS = 50; diff --git a/src/utils/profile.js b/src/utils/profile.js new file mode 100644 index 0000000..03ea082 --- /dev/null +++ b/src/utils/profile.js @@ -0,0 +1,132 @@ +import { db } from '../firebase'; +import { generateDmHandle, shouldEnrolOnRefresh } from './beta'; + +// Reservation documents give usernames and direct-message handles a single, +// unguessable owner: the document id is the name itself, and security rules +// only allow it to be created once. They also let the app look an identity up +// by id instead of querying the users collection, which stays private. +export const USERNAMES = 'usernames'; +export const HANDLES = 'handles'; + +export function usernameKey(username) { + return String(username || '').trim().toLowerCase(); +} + +export async function getUsernameOwner(username) { + const key = usernameKey(username); + if (!key) return null; + const doc = await db.collection(USERNAMES).doc(key).get(); + return doc.exists ? doc.data() : null; +} + +export async function getHandleOwner(handle) { + const key = String(handle || '').trim().toLowerCase(); + if (!key) return null; + const doc = await db.collection(HANDLES).doc(key).get(); + return doc.exists ? doc.data() : null; +} + +// Returns true when the name now belongs to this account. +export async function reserveUsername(username, authUid, userId) { + const key = usernameKey(username); + const existing = await getUsernameOwner(key); + if (existing) return existing.authUid === authUid; + + try { + await db.collection(USERNAMES).doc(key).set({ authUid, userId, username }); + return true; + } catch (err) { + const owner = await getUsernameOwner(key); + return Boolean(owner && owner.authUid === authUid); + } +} + +// Handles are short and human readable, so collisions are possible — claim +// them one at a time until a free one is reserved for this account. +export async function allocateDmHandle(authUid, username, attempts = 6) { + for (let i = 0; i < attempts; i += 1) { + const candidate = generateDmHandle(); + try { + // eslint-disable-next-line no-await-in-loop + const existing = await db.collection(HANDLES).doc(candidate).get(); + if (existing.exists) continue; + // eslint-disable-next-line no-await-in-loop + await db.collection(HANDLES).doc(candidate).set({ authUid, username }); + return candidate; + } catch (err) { + // Someone claimed it first — try another one + } + } + return null; +} + +// Backfills reservations for older accounts, re-rolls the beta enrolment for +// users who are not in it yet, and returns the freshest profile data. The tag +// update runs in a transaction so it cannot clobber a concurrent opt-out. +export async function ensureUserProfile(user) { + if (!user?.uid) return user; + + try { + const ref = db.collection('users').doc(user.uid); + const doc = await ref.get(); + if (!doc.exists) return user; + + const data = doc.data() || {}; + if (data.authUid !== user.authUid) return user; + + const username = data.username || user.username; + const owned = await reserveUsername(username, user.authUid, user.uid); + if (!owned) return { ...user, usernameConflict: true }; + + let dmHandle = data.dmHandle; + if (!dmHandle) { + dmHandle = await allocateDmHandle(user.authUid, username); + if (dmHandle) await ref.update({ dmHandle }); + } + + const tags = await db.runTransaction(async (tx) => { + const fresh = await tx.get(ref); + const freshTags = (fresh.exists && fresh.data().tags && typeof fresh.data().tags === 'object') + ? fresh.data().tags + : null; + + const current = freshTags || {}; + if (!shouldEnrolOnRefresh(current)) { + if (freshTags) return freshTags; + // Older accounts have no tags map at all — give them one so the flag + // is always present and readable. + const seeded = { beta: false }; + tx.update(ref, { tags: seeded }); + return seeded; + } + + // Enrolment is one-way — the roll can only ever set the flag to true. + const nextTags = { ...current, beta: true }; + tx.update(ref, { tags: nextTags }); + return nextTags; + }); + + return { ...user, username, dmHandle, tags }; + } catch (err) { + console.error('Profile sync error:', err); + return user; + } +} + +// Explicit opt-in/opt-out from Settings. Opting out is remembered so the +// random rollout does not immediately re-enrol the user. +export async function setBetaPreference(user, enabled) { + if (!user?.uid) return user; + + const tags = { ...(user.tags || {}), beta: Boolean(enabled), betaOptOut: !enabled }; + await db.collection('users').doc(user.uid).update({ tags }); + return { ...user, tags }; +} + +// Frees the reservations held by an account when it is wiped. +export async function releaseIdentity(user) { + const tasks = []; + if (user?.username) tasks.push(db.collection(USERNAMES).doc(usernameKey(user.username)).delete()); + if (user?.dmHandle) tasks.push(db.collection(HANDLES).doc(String(user.dmHandle).toLowerCase()).delete()); + await Promise.all(tasks.map((task) => task.catch(() => {}))); +} diff --git a/src/views/ChatRoomView.jsx b/src/views/ChatRoomView.jsx index 3c96b23..8c07e10 100644 --- a/src/views/ChatRoomView.jsx +++ b/src/views/ChatRoomView.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { db, firebase } from '../firebase'; import TopAppBar from '../components/TopAppBar'; import MessageBubble from '../components/MessageBubble'; @@ -6,6 +6,14 @@ import DialogModal from '../components/DialogModal'; import { encryptText, decryptText } from '../utils/crypto'; import { playChime } from '../utils/audio'; import { getQRCodeUrl } from '../utils/qr'; +import { DIRECT_THREADS } from '../utils/beta'; + +// Only the most recent slice of a room is rendered — older messages expire anyway +// and unbounded listeners are the main source of jank on low-end phones. +const MESSAGE_WINDOW = 200; +// Mirrors the size limit enforced by the Firestore security rules +const MAX_MESSAGE_LENGTH = 4000; +const READ_RECEIPT_THROTTLE_MS = 5000; function moderateContent(text, level = 'minimal') { if (!text || level === 'none') return text; @@ -23,14 +31,20 @@ function moderateContent(text, level = 'minimal') { } const themePalettes = { - indigo: { primary: '#818cf8', container: '#3730a3' }, - emerald: { primary: '#34d399', container: '#065f46' }, - violet: { primary: '#c084fc', container: '#581c87' }, - amber: { primary: '#fbbf24', container: '#78350f' }, - rose: { primary: '#fb7185', container: '#881337' } + indigo: { primary: '#a5b0ff', container: '#3b37a8' }, + emerald: { primary: '#5eead4', container: '#065f46' }, + violet: { primary: '#d7b4ff', container: '#581c87' }, + amber: { primary: '#fcd34d', container: '#78350f' }, + rose: { primary: '#fda4af', container: '#881337' } }; -export default function ChatRoomView({ roomId, user, onNavigate, onLogout, showSnackbar, onOpenSettings, settings }) { +const DEFAULT_PRIMARY = '#a5b0ff'; +const DEFAULT_PRIMARY_CONTAINER = '#3b37a8'; + +export default function ChatRoomView({ roomId, user, onNavigate, onLogout, showSnackbar, onOpenSettings, settings, isDirect = false }) { + const collectionName = isDirect ? DIRECT_THREADS : 'rooms'; + const roomRef = useMemo(() => (roomId ? db.collection(collectionName).doc(roomId) : null), [collectionName, roomId]); + const [room, setRoom] = useState(null); const [messages, setMessages] = useState([]); const [inputVal, setInputVal] = useState(''); @@ -42,6 +56,7 @@ export default function ChatRoomView({ roomId, user, onNavigate, onLogout, showS const [readers, setReaders] = useState([]); const [timeLeft, setTimeLeft] = useState(''); const [chatVelocity, setChatVelocity] = useState('Calm'); + const [showJumpToLatest, setShowJumpToLatest] = useState(false); // Modals const [editingMsg, setEditingMsg] = useState(null); @@ -51,14 +66,27 @@ export default function ChatRoomView({ roomId, user, onNavigate, onLogout, showS const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); + const inputRef = useRef(null); const typingTimeoutRef = useRef(null); const isTypingRef = useRef(false); + // Refs keep the Firestore listener stable — depending on state such as + // `messages.length` used to tear down and re-create the subscription on + // *every single message*, which re-downloaded the whole room each time. + const soundEnabledRef = useRef(settings.soundEnabled); + soundEnabledRef.current = settings.soundEnabled; + const usernameRef = useRef(user.username); + usernameRef.current = user.username; + const lastMessageIdRef = useRef(null); + const decryptCacheRef = useRef(new Map()); + const isAtBottomRef = useRef(true); + const lastReceiptRef = useRef(0); + // 1. Listen to Room Document & Apply Theme useEffect(() => { - if (!roomId) return; + if (!roomId) return undefined; - const unsub = db.collection('rooms').doc(roomId).onSnapshot( + const unsub = roomRef.onSnapshot( (doc) => { if (!doc.exists) { showSnackbar('Room has been deleted or expired', 'error'); @@ -66,11 +94,19 @@ export default function ChatRoomView({ roomId, user, onNavigate, onLogout, showS return; } const roomData = { id: doc.id, ...doc.data() }; + + // Direct message threads are strictly two-party + if (roomData.isDirect && !(roomData.participants || []).includes(user.authUid)) { + showSnackbar('This direct chat is private', 'error'); + onNavigate('home'); + return; + } + setRoom(roomData); // Apply theme variables dynamically - if (roomData.theme && themePalettes[roomData.theme]) { - const pal = themePalettes[roomData.theme]; + const pal = themePalettes[roomData.theme]; + if (pal) { document.documentElement.style.setProperty('--md-sys-color-primary', pal.primary); document.documentElement.style.setProperty('--md-sys-color-primary-container', pal.container); } @@ -80,44 +116,44 @@ export default function ChatRoomView({ roomId, user, onNavigate, onLogout, showS return () => { unsub(); - // Reset theme - document.documentElement.style.setProperty('--md-sys-color-primary', '#818cf8'); - document.documentElement.style.setProperty('--md-sys-color-primary-container', '#3730a3'); + document.documentElement.style.setProperty('--md-sys-color-primary', DEFAULT_PRIMARY); + document.documentElement.style.setProperty('--md-sys-color-primary-container', DEFAULT_PRIMARY_CONTAINER); }; - }, [roomId, onNavigate, showSnackbar]); + }, [roomId, onNavigate, showSnackbar, user.authUid]); // 2. Presence Pinger useEffect(() => { - if (!roomId || !user) return; + if (!roomId || !user) return undefined; + const presenceRef = roomRef.collection('presence').doc(user.username); const pingPresence = () => { - db.collection('rooms').doc(roomId).collection('presence').doc(user.username).set({ - last_seen: Date.now() - }).catch(() => {}); + if (document.hidden) return; + presenceRef.set({ last_seen: Date.now() }).catch(() => {}); }; pingPresence(); const interval = setInterval(pingPresence, 15000); + document.addEventListener('visibilitychange', pingPresence); return () => { clearInterval(interval); - db.collection('rooms').doc(roomId).collection('presence').doc(user.username).delete().catch(() => {}); + document.removeEventListener('visibilitychange', pingPresence); + presenceRef.delete().catch(() => {}); }; }, [roomId, user]); // 3. Listen to Active Presence Users useEffect(() => { - if (!roomId) return; + if (!roomId) return undefined; - const unsub = db.collection('rooms').doc(roomId).collection('presence').onSnapshot((snapshot) => { - const active = []; + const unsub = roomRef.collection('presence').onSnapshot((snapshot) => { const now = Date.now(); - snapshot.docs.forEach((d) => { - const data = d.data(); - if (data.last_seen && now - data.last_seen < 45000) { - active.push(d.id); - } - }); + const active = snapshot.docs + .filter((d) => { + const data = d.data(); + return data.last_seen && now - data.last_seen < 45000; + }) + .map((d) => d.id); setPresenceUsers(active); }); @@ -126,7 +162,7 @@ export default function ChatRoomView({ roomId, user, onNavigate, onLogout, showS // 4. Countdown Timer useEffect(() => { - if (!room?.expires_at) return; + if (!room?.expires_at) return undefined; const updateTimer = () => { const target = room.expires_at.toDate ? room.expires_at.toDate() : new Date(room.expires_at); @@ -149,78 +185,137 @@ export default function ChatRoomView({ roomId, user, onNavigate, onLogout, showS }, [room?.expires_at, onNavigate, showSnackbar]); // 5. Listen & Decrypt Messages & Velocity Calculation + const isPrivateRoom = Boolean(room?.isPrivate); + const roomCode = room?.code; + useEffect(() => { - if (!roomId) return; + if (!roomId) return undefined; + + let cancelled = false; const unsub = db.collection('messages') .where('room_id', '==', roomId) .orderBy('created_at', 'asc') + .limitToLast(MESSAGE_WINDOW) .onSnapshot( async (snapshot) => { const rawList = snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() })); - // Calculate Chat Velocity (messages in last 2 minutes) + // Chat velocity (messages in the last 2 minutes) const now = Date.now(); const recentCount = rawList.filter((m) => { - const time = m.created_at?.toDate ? m.created_at.toDate().getTime() : Date.now(); + const time = m.created_at?.toDate ? m.created_at.toDate().getTime() : now; return now - time < 120000; }).length; - if (recentCount >= 8) setChatVelocity('High Velocity'); + if (recentCount >= 8) setChatVelocity('High velocity'); else if (recentCount >= 3) setChatVelocity('Active'); else setChatVelocity('Calm'); - // Decrypt private room messages - const processed = await Promise.all( - rawList.map(async (msg) => { - if (room?.isPrivate && room?.code) { - const dec = await decryptText(msg.content, room.code); - return { ...msg, decryptedContent: dec }; - } - return msg; - }) - ); - - setMessages(processed); - if (settings.soundEnabled && rawList.length > messages.length && messages.length > 0) { - const latest = rawList[rawList.length - 1]; - if (latest.sender !== user.username) { - playChime('receive'); + let processed = rawList; + + if (isPrivateRoom && roomCode) { + // Decrypting is expensive (PBKDF2 per message); cache by content so + // an incoming message never re-decrypts the whole backlog. + const cache = decryptCacheRef.current; + processed = await Promise.all( + rawList.map(async (msg) => { + const key = `${msg.id}:${msg.content}`; + if (!cache.has(key)) cache.set(key, await decryptText(msg.content, roomCode)); + + let decryptedReply; + if (msg.reply_to?.content) { + const replyKey = `${msg.id}:reply:${msg.reply_to.content}`; + if (!cache.has(replyKey)) cache.set(replyKey, await decryptText(msg.reply_to.content, roomCode)); + decryptedReply = cache.get(replyKey); + } + + return { ...msg, decryptedContent: cache.get(key), decryptedReply }; + }) + ); + + if (cache.size > MESSAGE_WINDOW * 4) { + const live = new Set(rawList.flatMap((m) => ( + m.reply_to?.content + ? [`${m.id}:${m.content}`, `${m.id}:reply:${m.reply_to.content}`] + : [`${m.id}:${m.content}`] + ))); + cache.forEach((_, key) => { if (!live.has(key)) cache.delete(key); }); } } + + if (cancelled) return; + + const latest = rawList[rawList.length - 1]; + const isNewIncoming = + latest && + latest.id !== lastMessageIdRef.current && + lastMessageIdRef.current !== null && + latest.sender !== usernameRef.current; + + lastMessageIdRef.current = latest ? latest.id : null; + setMessages(processed); + + if (isNewIncoming && soundEnabledRef.current) playChime('receive'); }, (err) => console.error('Messages snapshot error:', err) ); - return () => unsub(); - }, [roomId, room?.isPrivate, room?.code, settings.soundEnabled, user.username, messages.length]); + return () => { + cancelled = true; + unsub(); + }; + }, [roomId, isPrivateRoom, roomCode]); - // 6. Auto scroll & Trigger Read Receipt + // Reset per-room caches when navigating between rooms useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - if (room?.readReceipts && roomId && user?.username) { - db.collection('rooms').doc(roomId).collection('read_receipts').doc(user.username).set({ - timestamp: Date.now() - }).catch(() => {}); + decryptCacheRef.current.clear(); + lastMessageIdRef.current = null; + isAtBottomRef.current = true; + setShowJumpToLatest(false); + }, [roomId]); + + const markRead = useCallback((force = false) => { + if (!room?.readReceipts || !roomId || !user?.username) return; + const now = Date.now(); + if (!force && now - lastReceiptRef.current < READ_RECEIPT_THROTTLE_MS) return; + lastReceiptRef.current = now; + roomRef.collection('read_receipts').doc(user.username) + .set({ timestamp: now }) + .catch(() => {}); + }, [room?.readReceipts, roomId, user?.username]); + + const scrollToBottom = useCallback((smooth = true) => { + const el = messagesContainerRef.current; + if (!el) return; + el.scrollTo({ top: el.scrollHeight, behavior: smooth ? 'smooth' : 'auto' }); + isAtBottomRef.current = true; + setShowJumpToLatest(false); + }, []); + + // 6. Auto scroll only when the user is already following the conversation + useEffect(() => { + if (messages.length === 0) return; + if (isAtBottomRef.current) { + scrollToBottom(false); + markRead(); + } else { + setShowJumpToLatest(true); } - }, [messages.length, room?.readReceipts, roomId, user?.username]); + }, [messages.length, scrollToBottom, markRead]); // 7. Listen to Typing useEffect(() => { - if (!roomId) return; + if (!roomId) return undefined; - const unsub = db.collection('rooms').doc(roomId).collection('typing').onSnapshot((snapshot) => { + const unsub = roomRef.collection('typing').onSnapshot((snapshot) => { const typers = []; snapshot.docs.forEach((doc) => { - if (doc.id !== user.username) { - const data = doc.data(); - if (data.updated_at) { - const time = data.updated_at.toDate ? data.updated_at.toDate().getTime() : new Date(data.updated_at).getTime(); - if (Date.now() - time < 5000) { - typers.push(doc.id); - } - } - } + if (doc.id === user.username) return; + const data = doc.data(); + if (!data.updated_at) return; + const time = data.updated_at.toDate ? data.updated_at.toDate().getTime() : new Date(data.updated_at).getTime(); + if (Date.now() - time < 5000) typers.push(doc.id); }); setTypingUsers(typers); }); @@ -230,41 +325,39 @@ export default function ChatRoomView({ roomId, user, onNavigate, onLogout, showS // 8. Listen to Read Receipts useEffect(() => { - if (!roomId || !room?.readReceipts) return; + if (!roomId || !room?.readReceipts) return undefined; - const unsub = db.collection('rooms').doc(roomId).collection('read_receipts').onSnapshot((snapshot) => { - const activeReaders = []; - snapshot.docs.forEach((doc) => { - if (doc.id !== user.username) { - activeReaders.push(doc.id); - } - }); - setReaders(activeReaders); + const unsub = roomRef.collection('read_receipts').onSnapshot((snapshot) => { + setReaders(snapshot.docs.filter((doc) => doc.id !== user.username).map((doc) => doc.id)); }); return () => unsub(); }, [roomId, room?.readReceipts, user.username]); - const handleScroll = () => { + const handleScroll = useCallback(() => { const el = messagesContainerRef.current; - if (!el || !room?.readReceipts || !roomId) return; - const isAtBottom = el.scrollHeight - el.clientHeight <= el.scrollTop + 30; - if (isAtBottom) { - db.collection('rooms').doc(roomId).collection('read_receipts').doc(user.username).set({ - timestamp: Date.now() - }).catch(() => {}); - } - }; + if (!el) return; + const atBottom = el.scrollHeight - el.clientHeight <= el.scrollTop + 60; + isAtBottomRef.current = atBottom; + setShowJumpToLatest((prev) => (prev === !atBottom ? prev : !atBottom)); + if (atBottom) markRead(); + }, [markRead]); // 9. Input Typing Handler (Debounced) const handleInputChange = (e) => { const val = e.target.value; setInputVal(val); + const el = e.target; + if (el.tagName === 'TEXTAREA') { + el.style.height = 'auto'; + el.style.height = `${Math.min(el.scrollHeight, 120)}px`; + } + if (val.trim().length > 0) { if (!isTypingRef.current) { isTypingRef.current = true; - db.collection('rooms').doc(roomId).collection('typing').doc(user.username).set({ + roomRef.collection('typing').doc(user.username).set({ updated_at: firebase.firestore.FieldValue.serverTimestamp() }).catch(() => {}); } @@ -272,21 +365,29 @@ export default function ChatRoomView({ roomId, user, onNavigate, onLogout, showS clearTimeout(typingTimeoutRef.current); typingTimeoutRef.current = setTimeout(() => { isTypingRef.current = false; - db.collection('rooms').doc(roomId).collection('typing').doc(user.username).delete().catch(() => {}); + roomRef.collection('typing').doc(user.username).delete().catch(() => {}); }, 3000); } }; + useEffect(() => () => clearTimeout(typingTimeoutRef.current), []); + // 10. Send Message Handler const handleSend = async (e) => { e.preventDefault(); const cleanContent = inputVal.trim(); if (!cleanContent || !room) return; + if (cleanContent.length > MAX_MESSAGE_LENGTH) { + showSnackbar(`Messages are limited to ${MAX_MESSAGE_LENGTH} characters`, 'error'); + return; + } + setInputVal(''); + if (inputRef.current) inputRef.current.style.height = 'auto'; isTypingRef.current = false; clearTimeout(typingTimeoutRef.current); - db.collection('rooms').doc(roomId).collection('typing').doc(user.username).delete().catch(() => {}); + roomRef.collection('typing').doc(user.username).delete().catch(() => {}); const moderated = moderateContent(cleanContent, room.moderationLevel); const finalContent = room.isPrivate && room.code @@ -306,20 +407,25 @@ export default function ChatRoomView({ roomId, user, onNavigate, onLogout, showS if (isBurnAfterReading) messageData.isBurnAfterReading = true; if (replyTo) { + // Quoted previews are stored with the same protection as the message + // itself, otherwise private conversations would leak in plaintext. + const quoted = replyTo.decryptedContent || replyTo.content; messageData.reply_to = { id: replyTo.id, sender: replyTo.sender, - content: replyTo.decryptedContent || replyTo.content + content: room.isPrivate && room.code ? await encryptText(quoted, room.code) : quoted }; setReplyTo(null); } + isAtBottomRef.current = true; + try { await db.collection('messages').add(messageData); if (settings.soundEnabled) playChime('send'); - db.collection('rooms').doc(roomId).update({ - latestMessage: room.isPrivate ? '🔒 [Encrypted Message]' : moderated, + roomRef.update({ + latestMessage: room.isPrivate ? '🔒 [Encrypted message]' : moderated, updated_at: firebase.firestore.FieldValue.serverTimestamp() }).catch(() => {}); } catch (err) { @@ -329,20 +435,48 @@ export default function ChatRoomView({ roomId, user, onNavigate, onLogout, showS } }; + const handleInputKeyDown = (e) => { + // Enter sends on pointer devices; phones keep Enter as newline so the + // on-screen keyboard's send button stays predictable. + if (e.key === 'Enter' && !e.shiftKey && window.matchMedia('(hover: hover) and (pointer: fine)').matches) { + handleSend(e); + } + }; + // 11. Reaction Handler - const handleReact = async (msgId, emoji) => { - const msg = messages.find((m) => m.id === msgId); - if (!msg) return; - const reactions = msg.reactions || {}; - const count = (reactions[emoji] || 0) + 1; + const handleReact = useCallback(async (msgId, emoji) => { try { await db.collection('messages').doc(msgId).update({ - [`reactions.${emoji}`]: count + [`reactions.${emoji}`]: firebase.firestore.FieldValue.increment(1) }); - } catch (e) { - console.error('Reaction error:', e); + } catch (err) { + console.error('Reaction error:', err); } - }; + }, []); + + // 13. Delete Message Handler + const handleConfirmDelete = useCallback(async (msgId, silent = false) => { + const targetId = typeof msgId === 'string' ? msgId : deletingMsgId; + if (!targetId) return; + try { + await db.collection('messages').doc(targetId).delete(); + } catch (err) { + if (!silent) { + console.error('Delete error:', err); + showSnackbar('Failed to delete message', 'error'); + } + } finally { + setDeletingMsgId(null); + } + }, [deletingMsgId, showSnackbar]); + + const handleMessageDelete = useCallback((msgId, silent) => { + if (silent) { + handleConfirmDelete(msgId, true); + return; + } + setDeletingMsgId(msgId); + }, [handleConfirmDelete]); // 12. Edit Message Handler const handleConfirmEdit = async (newContent) => { @@ -351,10 +485,14 @@ export default function ChatRoomView({ roomId, user, onNavigate, onLogout, showS return; } const clean = newContent.trim(); - if (clean === editingMsg.content) { + if (clean === (editingMsg.decryptedContent || editingMsg.content)) { setEditingMsg(null); return; } + if (clean.length > MAX_MESSAGE_LENGTH) { + showSnackbar(`Messages are limited to ${MAX_MESSAGE_LENGTH} characters`, 'error'); + return; + } try { const moderated = moderateContent(clean, room.moderationLevel); const finalContent = room.isPrivate && room.code @@ -365,126 +503,133 @@ export default function ChatRoomView({ roomId, user, onNavigate, onLogout, showS content: finalContent, edited: true }); - } catch (e) { - console.error('Edit error:', e); + } catch (err) { + console.error('Edit error:', err); showSnackbar('Failed to edit message', 'error'); } finally { setEditingMsg(null); } }; - // 13. Delete Message Handler - const handleConfirmDelete = async (msgId, silent = false) => { - const targetId = msgId || deletingMsgId; - if (!targetId) return; - try { - await db.collection('messages').doc(targetId).delete(); - } catch (e) { - if (!silent) { - console.error('Delete error:', e); - showSnackbar('Failed to delete message', 'error'); - } - } finally { - setDeletingMsgId(null); - } - }; + const handleReply = useCallback((m) => { + setReplyTo(m); + inputRef.current?.focus(); + }, []); + + const handleEdit = useCallback((m) => setEditingMsg(m), []); const copyRoomCode = () => { if (!room?.code) return; - navigator.clipboard.writeText(room.code); + navigator.clipboard?.writeText(room.code); showSnackbar(`Copied room code "${room.code}" to clipboard!`); }; + const roomTitle = useMemo(() => { + if (!room) return 'Loading...'; + if (!room.isDirect) return room.name; + const otherUid = (room.participants || []).find((uid) => uid !== user.authUid); + return room.participantHandles?.[otherUid] || room.name; + }, [room, user.authUid]); + + const renderedMessages = useMemo(() => messages.map((msg, idx) => { + const prevMsg = messages[idx - 1]; + return ( + + ); + }), [messages, user.username, handleReply, handleEdit, handleMessageDelete, handleReact, settings.compactMode]); + return (
onNavigate('home')} + onBack={() => onNavigate(room?.isDirect ? 'dms' : 'home')} onOpenSettings={onOpenSettings} - title={room ? room.name : 'Loading...'} - extraActions={ - room && ( -
- {/* Velocity Badge */} - - {chatVelocity} - + title={room ? roomTitle : 'Loading...'} + /> - {/* Online Users Badge */} - + {/* Room status strip — scrolls horizontally instead of overflowing the app bar on phones */} + {room && ( +
+ - {/* Private Room Tools */} - {room.isPrivate && ( - <> - - - - - )} - - {timeLeft && ( -
- timer - {timeLeft} -
- )} -
- ) - } - /> + {timeLeft && ( + + + {timeLeft} + + )} + + {chatVelocity} + + {room.isDirect && ( + <> + + + Direct message + + + + Encrypted + + + )} + + {room.isPrivate && !room.isDirect && ( + <> + + + + + Encrypted + + + )} +
+ )} {/* Messages Container */}
- {messages.map((msg, idx) => { - const isOwn = msg.sender === user.username; - const prevMsg = messages[idx - 1]; - const isContinuation = prevMsg && prevMsg.sender === msg.sender; - - return ( - setReplyTo(m)} - onEdit={(m) => setEditingMsg(m)} - onDelete={(id, silent) => handleConfirmDelete(id, silent)} - onReact={handleReact} - compactMode={settings.compactMode} - /> - ); - })} + {renderedMessages}
+ {showJumpToLatest && ( + + )} + {/* Read receipts indicator */} {room?.readReceipts && readers.length > 0 && ( -
- ✓✓ Seen by {readers.join(', ')} -
+
✓✓ Seen by {readers.join(', ')}
)} {/* Typing indicators */} @@ -493,10 +638,8 @@ export default function ChatRoomView({ roomId, user, onNavigate, onLogout, showS
- - {typingUsers.length === 1 - ? `${typingUsers[0]} is typing...` - : `${typingUsers.length} people are typing...`} + + {typingUsers.length === 1 ? `${typingUsers[0]} is typing...` : `${typingUsers.length} people are typing...`}
)} @@ -504,72 +647,67 @@ export default function ChatRoomView({ roomId, user, onNavigate, onLogout, showS {/* Input Area */}
{replyTo && ( -
-
-
+
+
+
Replying to {replyTo.sender}
-
- {replyTo.decryptedContent || replyTo.content} -
+
{replyTo.decryptedContent || replyTo.content}
-
)} - {/* Options Toolbar */} -
- Timer: - - - - -
+ {/* Composer options */} +
+ Vanish + {[null, 10, 30].map((opt) => ( + + ))}
- - @@ -578,15 +716,17 @@ export default function ChatRoomView({ roomId, user, onNavigate, onLogout, showS {/* Online Users Modal */} +
{presenceUsers.length === 0 ? ( -

Just you online right now.

+

Just you online right now.

) : ( presenceUsers.map((name) => ( -
- fiber_manual_record +
+ {name}
)) @@ -602,18 +742,21 @@ export default function ChatRoomView({ roomId, user, onNavigate, onLogout, showS {/* QR Code Sharing Modal */} +
{room?.code && ( Room QR Code )} -

- Scan with mobile camera to instantly open and join this room with Code {room?.code} +

+ Scan with a phone camera to open this room with code {room?.code}

} @@ -626,8 +769,9 @@ export default function ChatRoomView({ roomId, user, onNavigate, onLogout, showS {/* Edit Dialog Modal */} - onNavigate('home')} title="Create Room" onOpenSettings={onOpenSettings} /> + onNavigate('home')} title="Create room" onOpenSettings={onOpenSettings} />
-
- + {/* Room Name */}
-

Room Details

+

Room details

- +
{/* Room Theme Accent */}
-

Room Theme Accent

-
+

Theme accent

+
{themeOptions.map((opt) => ( ))} @@ -113,20 +123,21 @@ export default function CreateRoomView({ user, onNavigate, onLogout, showSnackba
{/* Private Room Switch */} -
-
+
+
- lock - Private Room (Unlocks Encryption & Custom Lifetime) + + Private room
-
- Requires a 6-character code to join. Messages are End-to-End Encrypted. +
+ Requires a 6-character code to join. Messages are end-to-end encrypted and lifetimes can be customised.