From b9ba0c14e5c3b8af4f06bddb4cd5da3e00780fdd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:18:09 +0000 Subject: [PATCH 1/6] Redesign UI with Material 3 Expressive + One UI and fix mobile UX and perf --- index.html | 16 +- src/App.jsx | 28 +- src/components/DialogModal.jsx | 35 +- src/components/MessageBubble.jsx | 203 +++-- src/components/PrivacyBanner.jsx | 36 +- src/components/PrivacyModal.jsx | 51 +- src/components/RoomCard.jsx | 35 +- src/components/Snackbar.jsx | 10 +- src/components/TopAppBar.jsx | 24 +- src/components/UserSettingsModal.jsx | 131 ++- src/styles.css | 1173 +++++++++++++++++++------- src/views/ChatRoomView.jsx | 573 +++++++------ src/views/CreateRoomView.jsx | 95 ++- src/views/HomeView.jsx | 96 ++- src/views/LoginView.jsx | 39 +- vite.config.js | 13 + 16 files changed, 1728 insertions(+), 830 deletions(-) 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/src/App.jsx b/src/App.jsx index 9b5fabd..c767e94 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,4 +1,4 @@ -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'; @@ -33,15 +33,20 @@ export default function App() { const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); const [isLogoutConfirmOpen, setIsLogoutConfirmOpen] = 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 updateSettings = useCallback((newSettings) => { setSettings(newSettings); localStorage.setItem('tempchats_settings', JSON.stringify(newSettings)); - }; + }, []); // Hash Navigation Handler useEffect(() => { @@ -57,9 +62,11 @@ 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 useEffect(() => { @@ -83,8 +90,13 @@ export default function App() { } }; - 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); }, []); diff --git a/src/components/DialogModal.jsx b/src/components/DialogModal.jsx index 14476f7..aa4af7e 100644 --- a/src/components/DialogModal.jsx +++ b/src/components/DialogModal.jsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; export default function DialogModal({ isOpen, @@ -14,6 +14,23 @@ export default function DialogModal({ }) { const [val, setVal] = useState(initialValue); + useEffect(() => { + 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..07acf3a 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,58 +59,99 @@ 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} -
+
Replying to {msg.reply_to.sender}
{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..9b2b70e 100644 --- a/src/components/PrivacyModal.jsx +++ b/src/components/PrivacyModal.jsx @@ -1,32 +1,43 @@ 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.' + } +]; + 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/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..eb13a9c 100644 --- a/src/components/UserSettingsModal.jsx +++ b/src/components/UserSettingsModal.jsx @@ -1,99 +1,80 @@ import React from 'react'; +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 }) { 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 +
+ {SETTINGS.map((item) => ( +
+
+ +
+
{item.title}
+
{item.description}
+
+
- -
- - {/* Compact Mode Toggle */} -
-
-
Compact Message Spacing
-
- Reduce padding between chat bubbles -
-
- -
- - {/* Read Receipts Preference */} -
-
-
Share Read Receipts
-
- Allow rooms to show when you have read messages -
-
- -
- -
+ ))}
-
- +
+
diff --git a/src/styles.css b/src/styles.css index b7ba7aa..5e26769 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); } -/* Reset & Global */ +@media (min-width: 600px) { + :root { --layout-gutter: 24px; } +} + +/* ========================================================================== + 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; +} + +button { -webkit-tap-highlight-color: transparent; touch-action: manipulation; } + +:focus-visible { + outline: 2px solid var(--md-sys-color-primary); + outline-offset: 2px; } -/* Typography Classes */ +::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); } -.md-btn--danger:hover { - background-color: #991b1b; +.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; } +.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 + ========================================================================== */ + +.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)); } } -/* Layout Views */ +/* 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,217 @@ 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; } +} + +@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 */ .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; } + +/* ========================================================================== + 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; + } } -.truncate { +.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/views/ChatRoomView.jsx b/src/views/ChatRoomView.jsx index 3c96b23..c3db121 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'; @@ -7,6 +7,11 @@ import { encryptText, decryptText } from '../utils/crypto'; import { playChime } from '../utils/audio'; import { getQRCodeUrl } from '../utils/qr'; +// 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; +const READ_RECEIPT_THROTTLE_MS = 5000; + function moderateContent(text, level = 'minimal') { if (!text || level === 'none') return text; const minimalPatterns = [/nigger/gi, /faggot/gi, /kike/gi, /chink/gi, /spic/gi]; @@ -23,13 +28,16 @@ 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' } }; +const DEFAULT_PRIMARY = '#a5b0ff'; +const DEFAULT_PRIMARY_CONTAINER = '#3b37a8'; + export default function ChatRoomView({ roomId, user, onNavigate, onLogout, showSnackbar, onOpenSettings, settings }) { const [room, setRoom] = useState(null); const [messages, setMessages] = useState([]); @@ -42,6 +50,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,12 +60,25 @@ 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( (doc) => { @@ -69,8 +91,8 @@ export default function ChatRoomView({ roomId, user, onNavigate, onLogout, showS 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 +102,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]); // 2. Presence Pinger useEffect(() => { - if (!roomId || !user) return; + if (!roomId || !user) return undefined; + const presenceRef = db.collection('rooms').doc(roomId).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 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 +148,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 +171,125 @@ 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)); + return { ...msg, decryptedContent: cache.get(key) }; + }) + ); + + if (cache.size > MESSAGE_WINDOW * 2) { + const live = new Set(rawList.map((m) => `${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; + db.collection('rooms').doc(roomId).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 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,37 +299,35 @@ 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); + 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; @@ -277,6 +344,8 @@ export default function ChatRoomView({ roomId, user, onNavigate, onLogout, showS } }; + useEffect(() => () => clearTimeout(typingTimeoutRef.current), []); + // 10. Send Message Handler const handleSend = async (e) => { e.preventDefault(); @@ -284,6 +353,7 @@ export default function ChatRoomView({ roomId, user, onNavigate, onLogout, showS if (!cleanContent || !room) 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(() => {}); @@ -314,12 +384,14 @@ export default function ChatRoomView({ roomId, user, onNavigate, onLogout, showS 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, + latestMessage: room.isPrivate ? '🔒 [Encrypted message]' : moderated, updated_at: firebase.firestore.FieldValue.serverTimestamp() }).catch(() => {}); } catch (err) { @@ -329,20 +401,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,7 +451,7 @@ 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; } @@ -365,36 +465,44 @@ 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 renderedMessages = useMemo(() => messages.map((msg, idx) => { + const prevMsg = messages[idx - 1]; + return ( + + ); + }), [messages, user.username, handleReply, handleEdit, handleMessageDelete, handleReact, settings.compactMode]); + return (
onNavigate('home')} onOpenSettings={onOpenSettings} title={room ? room.name : 'Loading...'} - extraActions={ - room && ( -
- {/* Velocity Badge */} - - {chatVelocity} - + /> - {/* 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.isPrivate && ( + <> + + + + + 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 +580,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 +589,66 @@ 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 +657,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 +683,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 +710,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 +122,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.
+
+ ); +} diff --git a/src/views/HomeView.jsx b/src/views/HomeView.jsx index 49876a6..8a9d82a 100644 --- a/src/views/HomeView.jsx +++ b/src/views/HomeView.jsx @@ -5,7 +5,7 @@ import RoomCard from '../components/RoomCard'; import PrivacyBanner from '../components/PrivacyBanner'; import DialogModal from '../components/DialogModal'; -export default function HomeView({ user, onNavigate, onLogout, onOpenPrivacyModal, onOpenSettings, showSnackbar }) { +export default function HomeView({ user, onNavigate, onLogout, onOpenPrivacyModal, onOpenSettings, showSnackbar, isBeta = false }) { const [publicRooms, setPublicRooms] = useState([]); const [searchQuery, setSearchQuery] = useState(''); const [isJoinCodeOpen, setIsJoinCodeOpen] = useState(false); @@ -38,10 +38,13 @@ export default function HomeView({ user, onNavigate, onLogout, onOpenPrivacyModa try { const snap = await db.collection('rooms').where('code', '==', cleanCode).get(); - if (snap.empty) { + // Direct message threads also carry a code, but they are only reachable + // by their two participants. + const match = snap.docs.find((doc) => !doc.data().isDirect); + if (!match) { showSnackbar('Invalid or expired room code', 'error'); } else { - onNavigate(`chat/${snap.docs[0].id}`); + onNavigate(`chat/${match.id}`); } } catch (e) { console.error('Join code error:', e); @@ -101,6 +104,26 @@ export default function HomeView({ user, onNavigate, onLogout, onOpenPrivacyModa
+ {isBeta && ( + + )} + {/* Search */}
diff --git a/src/views/LoginView.jsx b/src/views/LoginView.jsx index b7c8ab7..e0f2447 100644 --- a/src/views/LoginView.jsx +++ b/src/views/LoginView.jsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; import { db, auth } from '../firebase'; +import { createUserProfileFields, ensureUserProfile } from '../utils/profile'; export default function LoginView({ onLoginSuccess, showSnackbar }) { const [username, setUsername] = useState(''); @@ -25,25 +26,42 @@ export default function LoginView({ onLoginSuccess, showSnackbar }) { // 2. Check if username is already claimed by someone else const existing = await db.collection('users').where('username', '==', cleaned).get(); if (!existing.empty) { - const docData = existing.docs[0].data(); + const doc = existing.docs[0]; + const docData = doc.data(); if (docData.authUid !== authUser.uid) { showSnackbar('Username is already taken by another user', 'error'); setLoading(false); return; } + + // Same account signing back in — reuse the existing profile + const restored = await ensureUserProfile({ + username: cleaned, + uid: doc.id, + authUid: authUser.uid + }); + + localStorage.setItem('tempchats_user', JSON.stringify(restored)); + onLoginSuccess(restored); + return; } // 3. Register user document + const profileFields = await createUserProfileFields(); const userRef = await db.collection('users').add({ username: cleaned, authUid: authUser.uid, + dmHandle: profileFields.dmHandle, + tags: profileFields.tags, created_at: new Date() }); const userData = { username: cleaned, uid: userRef.id, - authUid: authUser.uid + authUid: authUser.uid, + dmHandle: profileFields.dmHandle, + tags: profileFields.tags }; localStorage.setItem('tempchats_user', JSON.stringify(userData)); From fba93992375c2da37bbd13c9c242326f01548004 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:26:56 +0000 Subject: [PATCH 4/6] Harden Firestore rules and widen DM beta rollout with a settings toggle Co-authored-by: DaDevMikey <169651161+DaDevMikey@users.noreply.github.com> --- README.md | 2 +- firestore.rules | 163 +++++++++++++++++++++++---- package-lock.json | 4 +- package.json | 2 +- src/App.jsx | 82 +++++++++++--- src/components/UserSettingsModal.jsx | 33 +++++- src/releaseNotes.js | 26 ++++- src/utils/beta.js | 47 +++++++- src/utils/profile.js | 34 +++++- src/views/ChatRoomView.jsx | 12 ++ src/views/DirectMessagesView.jsx | 47 +++++++- src/views/HomeView.jsx | 12 +- 12 files changed, 405 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 15ffaa8..058fb71 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ 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 - **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. Gradually rolling out to beta testers and randomly selected users via the `tags.beta` flag on the Firestore user document +- **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 diff --git a/firestore.rules b/firestore.rules index 41c25b7..a7d5c52 100644 --- a/firestore.rules +++ b/firestore.rules @@ -2,54 +2,169 @@ rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { + + 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; + } + // Users collection match /users/{userId} { - allow read: if request.auth != null; - allow create: if request.auth != null + allow read: if isSignedIn(); + + allow create: if isSignedIn() && request.resource.data.authUid == request.auth.uid - && request.resource.data.username is string; - // Users may only update their own profile (rollout tags, DM handle) - allow update: if request.auth != null + && 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, handles 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 == request.auth.uid; - allow delete: if request.auth != null && 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 match /rooms/{roomId} { - allow read: if request.auth != null; - allow create: if request.auth != null + + function isDirectRoom(data) { + return data.keys().hasAny(['isDirect']) && data.isDirect == true; + } + + function isParticipant(data) { + return data.keys().hasAny(['participants']) + && data.participants is list + && request.auth.uid in data.participants; + } + + function isRoomOwner() { + return resource.data.authUid == request.auth.uid; + } + + // Direct message threads — including their encryption code — are only + // visible to the two people in them. + allow read: if isSignedIn() + && (!isDirectRoom(resource.data) || isParticipant(resource.data)); + + 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 + && request.resource.data.expires_at is timestamp + && (!isDirectRoom(request.resource.data) + || (isParticipant(request.resource.data) && request.resource.data.participants.size() == 2)); + + // Owners can manage their room (including handing it over on logout) but + // can never turn it into, or out of, a direct thread. Everyone else may + // only bump the conversation preview of a room they can post in. + allow update: if isSignedIn() && ( + (isRoomOwner() && !changedKeys().hasAny(['isDirect', 'participants', 'code'])) + || (onlyChanged(['latestMessage', 'updated_at']) + && (!isDirectRoom(resource.data) || isParticipant(resource.data))) + ); + + allow delete: if isSignedIn() && ( + isRoomOwner() + || isExpired(resource.data) + || (isDirectRoom(resource.data) && isParticipant(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 match /messages/{messageId} { - allow read: if request.auth != null; - allow create: if request.auth != null + + function roomData(roomId) { + return get(/databases/$(database)/documents/rooms/$(roomId)).data; + } + + function roomExists(roomId) { + return exists(/databases/$(database)/documents/rooms/$(roomId)); + } + + function isMessageAuthor() { + return resource.data.authUid == request.auth.uid; + } + + function ownsParentRoom() { + return roomExists(resource.data.room_id) + && roomData(resource.data.room_id).authUid == request.auth.uid; + } + + function mayPostIn(roomId) { + return roomExists(roomId) + && (roomData(roomId).get('isDirect', false) != true + || request.auth.uid in roomData(roomId).get('participants', [])); + } + + // Direct message bodies are end-to-end encrypted with a key that lives on + // the thread document, which only the two participants can read. + 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 + && 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() + ); } - + // Typing indicators top-level match fallback match /typing/{roomId} { - allow read, write: if request.auth != null; + allow read, write: if isSignedIn(); } } } 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 0ae26e1..36ea8c3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "tempchats", "private": true, - "version": "2.1.0", + "version": "2.2.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.jsx b/src/App.jsx index 802b7db..b55282b 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -10,7 +10,7 @@ import PrivacyModal from './components/PrivacyModal'; import DialogModal from './components/DialogModal'; import UserSettingsModal from './components/UserSettingsModal'; import ReleaseNotesModal from './components/ReleaseNotesModal'; -import { ensureUserProfile } from './utils/profile'; +import { ensureUserProfile, setBetaPreference } from './utils/profile'; import { isBetaUser } from './utils/beta'; import { CURRENT_RELEASE } from './releaseNotes'; @@ -82,6 +82,20 @@ export default function App() { } }, [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'); + if (!enabled && window.location.hash.slice(1) === 'dms') 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)); @@ -109,8 +123,12 @@ export default function App() { }, []); // 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 @@ -128,16 +146,26 @@ export default function App() { // Direct message threads are disposable — remove the thread documents // once their 24h lifetime is over so they stop showing up anywhere. - const expiredRooms = await db.collection('rooms') - .where('expires_at', '<', now) - .limit(20) - .get(); - - const expiredDirect = expiredRooms.docs.filter((doc) => doc.data().isDirect); - if (expiredDirect.length > 0) { - const roomBatch = db.batch(); - expiredDirect.forEach((doc) => roomBatch.delete(doc.ref)); - await roomBatch.commit(); + // Only the current user's own threads are queried; other people's + // direct threads are not readable. + if (currentAuthUid) { + const ownThreads = await db.collection('rooms') + .where('participants', 'array-contains', currentAuthUid) + .limit(50) + .get(); + + const expiredDirect = ownThreads.docs.filter((doc) => { + const data = doc.data(); + if (!data.isDirect || !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) { + const roomBatch = db.batch(); + expiredDirect.forEach((doc) => roomBatch.delete(doc.ref)); + await roomBatch.commit(); + } } } catch (err) { // Quiet catch for index or permission constraints @@ -194,6 +222,17 @@ export default function App() { for (const roomDoc of roomsSnap.docs) { const roomId = roomDoc.id; + // Direct threads belong to their two participants only — they are + // never handed over, they are destroyed with the account. + if (roomDoc.data().isDirect) { + const dmMsgs = await db.collection('messages').where('room_id', '==', roomId).get(); + const dmBatch = db.batch(); + dmMsgs.docs.forEach((d) => dmBatch.delete(d.ref)); + dmBatch.delete(roomDoc.ref); + await dmBatch.commit(); + continue; + } + // Check active chatters in typing/presence collection const typingSnap = await db.collection('rooms').doc(roomId).collection('typing').get(); const activeTypers = typingSnap.docs.filter((d) => d.id !== user.username); @@ -247,12 +286,27 @@ export default function App() { console.log(`Deleted abandoned room ${roomId} and its messages`); } - // 2. Delete user's user document + // 2. Remove direct threads the user takes part in but did not create. + // Their counterpart's messages expire on their own 24h schedule. + const dmSnap = await db.collection('rooms').where('participants', 'array-contains', user.authUid).get(); + for (const dmDoc of dmSnap.docs) { + if (!dmDoc.data().isDirect) continue; + const ownMsgs = await db.collection('messages') + .where('room_id', '==', dmDoc.id) + .where('authUid', '==', user.authUid) + .get(); + const dmBatch = db.batch(); + ownMsgs.docs.forEach((d) => dmBatch.delete(d.ref)); + dmBatch.delete(dmDoc.ref); + await dmBatch.commit(); + } + + // 3. Delete user's user document if (user.uid) { await db.collection('users').doc(user.uid).delete().catch(() => {}); } - // 3. Wiping auth session & local storage + // 4. Wiping auth session & local storage localStorage.removeItem('tempchats_user'); setUser(null); await auth.signOut(); @@ -345,6 +399,8 @@ export default function App() { onClose={() => setIsSettingsModalOpen(false)} onOpenPrivacyModal={() => setIsPrivacyModalOpen(true)} onOpenReleaseNotes={() => setIsReleaseNotesOpen(true)} + isBeta={isBetaUser(user)} + onUpdateBeta={updateBetaPreference} /> diff --git a/src/components/UserSettingsModal.jsx b/src/components/UserSettingsModal.jsx index 016fd14..c651824 100644 --- a/src/components/UserSettingsModal.jsx +++ b/src/components/UserSettingsModal.jsx @@ -21,7 +21,7 @@ const SETTINGS = [ } ]; -export default function UserSettingsModal({ isOpen, settings, onUpdateSettings, onClose, onOpenPrivacyModal, onOpenReleaseNotes }) { +export default function UserSettingsModal({ isOpen, settings, onUpdateSettings, onClose, onOpenPrivacyModal, onOpenReleaseNotes, isBeta = false, onUpdateBeta }) { if (!isOpen) return null; return ( @@ -63,6 +63,37 @@ export default function UserSettingsModal({ isOpen, settings, onUpdateSettings,
))} + {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/views/HomeView.jsx b/src/views/HomeView.jsx index 8a9d82a..083a16b 100644 --- a/src/views/HomeView.jsx +++ b/src/views/HomeView.jsx @@ -36,10 +36,16 @@ export default function HomeView({ user, onNavigate, onLogout, onOpenPrivacyModa if (!code || !code.trim()) return; const cleanCode = code.trim().toUpperCase(); + // Room codes are always 6 alphanumeric characters. Validating first keeps + // the lookup off private direct-thread codes, which are longer. + if (!/^[A-Z0-9]{6}$/.test(cleanCode)) { + showSnackbar('Room codes are 6 letters or numbers', 'error'); + setIsJoinCodeOpen(false); + return; + } + try { - const snap = await db.collection('rooms').where('code', '==', cleanCode).get(); - // Direct message threads also carry a code, but they are only reachable - // by their two participants. + const snap = await db.collection('rooms').where('code', '==', cleanCode).limit(5).get(); const match = snap.docs.find((doc) => !doc.data().isDirect); if (!match) { showSnackbar('Invalid or expired room code', 'error'); From 717a5f106d2028e9fc48dd2cb25c326d621f906c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:57:23 +0000 Subject: [PATCH 5/6] Move direct messages to their own collection and harden identity rules Co-authored-by: DaDevMikey <169651161+DaDevMikey@users.noreply.github.com> --- README.md | 24 +++++ firestore.rules | 156 ++++++++++++++++++++++--------- src/App.jsx | 83 ++++++++++------ src/components/MessageBubble.jsx | 2 +- src/components/PrivacyModal.jsx | 4 + src/utils/beta.js | 14 +++ src/utils/profile.js | 121 ++++++++++++++++-------- src/views/ChatRoomView.jsx | 49 +++++++--- src/views/CreateRoomView.jsx | 3 +- src/views/DirectMessagesView.jsx | 34 ++++--- src/views/LoginView.jsx | 54 +++++++---- 11 files changed, 382 insertions(+), 162 deletions(-) diff --git a/README.md b/README.md index 058fb71..626e678 100644 --- a/README.md +++ b/README.md @@ -73,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 a7d5c52..9c6a389 100644 --- a/firestore.rules +++ b/firestore.rules @@ -19,9 +19,43 @@ service cloud.firestore { return data.keys().hasAny(['expires_at']) && data.expires_at < request.time; } - // Users collection - match /users/{userId} { + // 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 isSignedIn() && resource.data.authUid == request.auth.uid; allow create: if isSignedIn() && request.resource.data.authUid == request.auth.uid @@ -31,8 +65,8 @@ service cloud.firestore { && 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, handles and the auth binding - // are immutable so identities cannot be hijacked or impersonated. + // 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 @@ -44,27 +78,16 @@ service cloud.firestore { 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} { - function isDirectRoom(data) { - return data.keys().hasAny(['isDirect']) && data.isDirect == true; - } - - function isParticipant(data) { - return data.keys().hasAny(['participants']) - && data.participants is list - && request.auth.uid in data.participants; - } - function isRoomOwner() { return resource.data.authUid == request.auth.uid; } - // Direct message threads — including their encryption code — are only - // visible to the two people in them. - allow read: if isSignedIn() - && (!isDirectRoom(resource.data) || isParticipant(resource.data)); + allow read: if isSignedIn(); allow create: if isSignedIn() && request.resource.data.authUid == request.auth.uid @@ -72,26 +95,17 @@ service cloud.firestore { && request.resource.data.name.size() > 0 && request.resource.data.name.size() <= 120 && request.resource.data.creator is string - && request.resource.data.expires_at is timestamp - && (!isDirectRoom(request.resource.data) - || (isParticipant(request.resource.data) && request.resource.data.participants.size() == 2)); + && ownsName(request.resource.data.creator) + && request.resource.data.expires_at is timestamp; - // Owners can manage their room (including handing it over on logout) but - // can never turn it into, or out of, a direct thread. Everyone else may - // only bump the conversation preview of a room they can post in. + // Owners manage their own room (including handing it over on logout). + // Everyone else may only bump the conversation preview. allow update: if isSignedIn() && ( - (isRoomOwner() && !changedKeys().hasAny(['isDirect', 'participants', 'code'])) - || (onlyChanged(['latestMessage', 'updated_at']) - && (!isDirectRoom(resource.data) || isParticipant(resource.data))) + isRoomOwner() || onlyChanged(['latestMessage', 'updated_at']) ); - allow delete: if isSignedIn() && ( - isRoomOwner() - || isExpired(resource.data) - || (isDirectRoom(resource.data) && isParticipant(resource.data)) - ); + allow delete: if isSignedIn() && (isRoomOwner() || isExpired(resource.data)); - // Subcollections under rooms match /typing/{doc} { allow read, write: if isSignedIn(); } @@ -103,15 +117,60 @@ service cloud.firestore { } } - // 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} { - function roomData(roomId) { - return get(/databases/$(database)/documents/rooms/$(roomId)).data; + function threadPath(roomId) { + return /databases/$(database)/documents/direct_threads/$(roomId); } - function roomExists(roomId) { - return exists(/databases/$(database)/documents/rooms/$(roomId)); + function roomPath(roomId) { + return /databases/$(database)/documents/rooms/$(roomId); } function isMessageAuthor() { @@ -119,14 +178,21 @@ service cloud.firestore { } function ownsParentRoom() { - return roomExists(resource.data.room_id) - && roomData(resource.data.room_id).authUid == request.auth.uid; + 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 roomExists(roomId) - && (roomData(roomId).get('isDirect', false) != true - || request.auth.uid in roomData(roomId).get('participants', [])); + return exists(threadPath(roomId)) ? isThreadMember(roomId) : exists(roomPath(roomId)); } // Direct message bodies are end-to-end encrypted with a key that lives on @@ -137,6 +203,7 @@ service cloud.firestore { && request.resource.data.authUid == request.auth.uid && 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 @@ -159,6 +226,7 @@ service cloud.firestore { || resource.data.keys().hasAny(['isBurnAfterReading']) || isExpired(resource.data) || ownsParentRoom() + || isThreadMember(resource.data.room_id) ); } diff --git a/src/App.jsx b/src/App.jsx index b55282b..7bd4fa3 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -10,8 +10,8 @@ import PrivacyModal from './components/PrivacyModal'; import DialogModal from './components/DialogModal'; import UserSettingsModal from './components/UserSettingsModal'; import ReleaseNotesModal from './components/ReleaseNotesModal'; -import { ensureUserProfile, setBetaPreference } from './utils/profile'; -import { isBetaUser } from './utils/beta'; +import { ensureUserProfile, setBetaPreference, getUsernameOwner, releaseIdentity } from './utils/profile'; +import { isBetaUser, DIRECT_THREADS } from './utils/beta'; import { CURRENT_RELEASE } from './releaseNotes'; export default function App() { @@ -31,6 +31,7 @@ export default function App() { 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' }; }); @@ -63,6 +64,16 @@ export default function App() { 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; @@ -89,7 +100,8 @@ export default function App() { localStorage.setItem('tempchats_user', JSON.stringify(updated)); setUser(updated); showSnackbar(enabled ? 'Direct messages beta enabled' : 'Direct messages beta disabled'); - if (!enabled && window.location.hash.slice(1) === 'dms') window.location.hash = 'home'; + 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'); @@ -109,6 +121,7 @@ export default function App() { 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' }); }; @@ -149,14 +162,14 @@ export default function App() { // Only the current user's own threads are queried; other people's // direct threads are not readable. if (currentAuthUid) { - const ownThreads = await db.collection('rooms') + 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.isDirect || !data.expires_at) return false; + if (!data.expires_at) return false; const exp = data.expires_at.toDate ? data.expires_at.toDate() : new Date(data.expires_at); return exp < now; }); @@ -222,17 +235,6 @@ export default function App() { for (const roomDoc of roomsSnap.docs) { const roomId = roomDoc.id; - // Direct threads belong to their two participants only — they are - // never handed over, they are destroyed with the account. - if (roomDoc.data().isDirect) { - const dmMsgs = await db.collection('messages').where('room_id', '==', roomId).get(); - const dmBatch = db.batch(); - dmMsgs.docs.forEach((d) => dmBatch.delete(d.ref)); - dmBatch.delete(roomDoc.ref); - await dmBatch.commit(); - continue; - } - // Check active chatters in typing/presence collection const typingSnap = await db.collection('rooms').doc(roomId).collection('typing').get(); const activeTypers = typingSnap.docs.filter((d) => d.id !== user.username); @@ -257,10 +259,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]; @@ -286,25 +286,22 @@ export default function App() { console.log(`Deleted abandoned room ${roomId} and its messages`); } - // 2. Remove direct threads the user takes part in but did not create. - // Their counterpart's messages expire on their own 24h schedule. - const dmSnap = await db.collection('rooms').where('participants', 'array-contains', user.authUid).get(); + // 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) { - if (!dmDoc.data().isDirect) continue; - const ownMsgs = await db.collection('messages') - .where('room_id', '==', dmDoc.id) - .where('authUid', '==', user.authUid) - .get(); + const dmMsgs = await db.collection('messages').where('room_id', '==', dmDoc.id).get(); const dmBatch = db.batch(); - ownMsgs.docs.forEach((d) => dmBatch.delete(d.ref)); + dmMsgs.docs.forEach((d) => dmBatch.delete(d.ref)); dmBatch.delete(dmDoc.ref); await dmBatch.commit(); } - // 3. Delete user's user document + // 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); // 4. Wiping auth session & local storage localStorage.removeItem('tempchats_user'); @@ -377,6 +374,32 @@ export default function App() { /> )} + {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' && (
Replying to {msg.reply_to.sender}
-
{msg.reply_to.content}
+
{msg.decryptedReply || msg.reply_to.content}
)} diff --git a/src/components/PrivacyModal.jsx b/src/components/PrivacyModal.jsx index 9b2b70e..54f3c86 100644 --- a/src/components/PrivacyModal.jsx +++ b/src/components/PrivacyModal.jsx @@ -16,6 +16,10 @@ const SECTIONS = [ { 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.' } ]; diff --git a/src/utils/beta.js b/src/utils/beta.js index f42830d..e6cc429 100644 --- a/src/utils/beta.js +++ b/src/utils/beta.js @@ -73,6 +73,16 @@ export function generateDmHandle() { 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(/^@/, ''); } @@ -81,5 +91,9 @@ 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 index 62c21ec..681cfc7 100644 --- a/src/utils/profile.js +++ b/src/utils/profile.js @@ -1,31 +1,68 @@ import { db } from '../firebase'; -import { generateDmHandle, rollSignupBetaFlag, shouldEnrolOnRefresh } from './beta'; +import { generateDmHandle, shouldEnrolOnRefresh } from './beta'; -// Handles are short and human readable, so collisions are possible — retry a -// few times before falling back to the last generated value. -export async function allocateDmHandle(attempts = 5) { +// 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 snap = await db.collection('users').where('dmHandle', '==', candidate).limit(1).get(); - if (snap.empty) return candidate; - } catch (err) { + 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 generateDmHandle(); + return null; } -export async function createUserProfileFields() { - return { - dmHandle: await allocateDmHandle(), - tags: { beta: rollSignupBetaFlag() } - }; -} - -// Backfills the direct-message handle for older accounts, re-rolls the beta -// enrolment for users who are not in it yet, and returns the freshest data. +// 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; @@ -37,31 +74,31 @@ export async function ensureUserProfile(user) { const data = doc.data() || {}; if (data.authUid !== user.authUid) return user; - const tags = (data.tags && typeof data.tags === 'object') ? data.tags : {}; - const updates = {}; - let nextTags = tags; - - if (!data.dmHandle) updates.dmHandle = await allocateDmHandle(); + const username = data.username || user.username; + const owned = await reserveUsername(username, user.authUid, user.uid); + if (!owned) return { ...user, usernameConflict: true }; - if (shouldEnrolOnRefresh(tags)) { - // Enrolment is one-way — the roll can only ever set the flag to true. - nextTags = { ...tags, beta: true }; - updates.tags = nextTags; - } else if (!data.tags || typeof data.tags !== 'object') { - nextTags = { beta: false }; - updates.tags = nextTags; + let dmHandle = data.dmHandle; + if (!dmHandle) { + dmHandle = await allocateDmHandle(user.authUid, username); + if (dmHandle) await ref.update({ dmHandle }); } - if (Object.keys(updates).length > 0) { - await ref.update(updates); - } + 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; + + if (freshTags && !shouldEnrolOnRefresh(freshTags)) return freshTags; + + // Enrolment is one-way — the roll can only ever set the flag to true. + const nextTags = freshTags ? { ...freshTags, beta: true } : { beta: false }; + tx.update(ref, { tags: nextTags }); + return nextTags; + }); - return { - ...user, - username: data.username || user.username, - dmHandle: updates.dmHandle || data.dmHandle, - tags: nextTags - }; + return { ...user, username, dmHandle, tags }; } catch (err) { console.error('Profile sync error:', err); return user; @@ -77,3 +114,11 @@ export async function setBetaPreference(user, 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 a5bb08c..8c07e10 100644 --- a/src/views/ChatRoomView.jsx +++ b/src/views/ChatRoomView.jsx @@ -6,6 +6,7 @@ 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. @@ -40,7 +41,10 @@ const themePalettes = { const DEFAULT_PRIMARY = '#a5b0ff'; const DEFAULT_PRIMARY_CONTAINER = '#3b37a8'; -export default function ChatRoomView({ roomId, user, onNavigate, onLogout, showSnackbar, onOpenSettings, settings }) { +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(''); @@ -82,7 +86,7 @@ export default function ChatRoomView({ roomId, user, onNavigate, onLogout, showS useEffect(() => { 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'); @@ -121,7 +125,7 @@ export default function ChatRoomView({ roomId, user, onNavigate, onLogout, showS useEffect(() => { if (!roomId || !user) return undefined; - const presenceRef = db.collection('rooms').doc(roomId).collection('presence').doc(user.username); + const presenceRef = roomRef.collection('presence').doc(user.username); const pingPresence = () => { if (document.hidden) return; presenceRef.set({ last_seen: Date.now() }).catch(() => {}); @@ -142,7 +146,7 @@ export default function ChatRoomView({ roomId, user, onNavigate, onLogout, showS useEffect(() => { if (!roomId) return undefined; - const unsub = db.collection('rooms').doc(roomId).collection('presence').onSnapshot((snapshot) => { + const unsub = roomRef.collection('presence').onSnapshot((snapshot) => { const now = Date.now(); const active = snapshot.docs .filter((d) => { @@ -218,12 +222,24 @@ export default function ChatRoomView({ roomId, user, onNavigate, onLogout, showS rawList.map(async (msg) => { const key = `${msg.id}:${msg.content}`; if (!cache.has(key)) cache.set(key, await decryptText(msg.content, roomCode)); - return { ...msg, decryptedContent: cache.get(key) }; + + 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 * 2) { - const live = new Set(rawList.map((m) => `${m.id}:${m.content}`)); + 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); }); } } @@ -264,7 +280,7 @@ export default function ChatRoomView({ roomId, user, onNavigate, onLogout, showS const now = Date.now(); if (!force && now - lastReceiptRef.current < READ_RECEIPT_THROTTLE_MS) return; lastReceiptRef.current = now; - db.collection('rooms').doc(roomId).collection('read_receipts').doc(user.username) + roomRef.collection('read_receipts').doc(user.username) .set({ timestamp: now }) .catch(() => {}); }, [room?.readReceipts, roomId, user?.username]); @@ -292,7 +308,7 @@ export default function ChatRoomView({ roomId, user, onNavigate, onLogout, showS useEffect(() => { 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) return; @@ -311,7 +327,7 @@ export default function ChatRoomView({ roomId, user, onNavigate, onLogout, showS useEffect(() => { if (!roomId || !room?.readReceipts) return undefined; - const unsub = db.collection('rooms').doc(roomId).collection('read_receipts').onSnapshot((snapshot) => { + const unsub = roomRef.collection('read_receipts').onSnapshot((snapshot) => { setReaders(snapshot.docs.filter((doc) => doc.id !== user.username).map((doc) => doc.id)); }); @@ -341,7 +357,7 @@ export default function ChatRoomView({ roomId, user, onNavigate, onLogout, showS 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(() => {}); } @@ -349,7 +365,7 @@ 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); } }; @@ -371,7 +387,7 @@ export default function ChatRoomView({ roomId, user, onNavigate, onLogout, showS 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 @@ -391,10 +407,13 @@ 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); } @@ -405,7 +424,7 @@ export default function ChatRoomView({ roomId, user, onNavigate, onLogout, showS await db.collection('messages').add(messageData); if (settings.soundEnabled) playChime('send'); - db.collection('rooms').doc(roomId).update({ + roomRef.update({ latestMessage: room.isPrivate ? '🔒 [Encrypted message]' : moderated, updated_at: firebase.firestore.FieldValue.serverTimestamp() }).catch(() => {}); diff --git a/src/views/CreateRoomView.jsx b/src/views/CreateRoomView.jsx index 3fdc5e5..6037653 100644 --- a/src/views/CreateRoomView.jsx +++ b/src/views/CreateRoomView.jsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import { db, firebase } from '../firebase'; import TopAppBar from '../components/TopAppBar'; +import { generateSecureCode } from '../utils/beta'; export default function CreateRoomView({ user, onNavigate, onLogout, showSnackbar, onOpenSettings }) { const [roomName, setRoomName] = useState(''); @@ -37,7 +38,7 @@ export default function CreateRoomView({ user, onNavigate, onLogout, showSnackba try { const expiresAt = new Date(); expiresAt.setHours(expiresAt.getHours() + finalDuration); - const roomCode = Math.random().toString(36).substring(2, 8).toUpperCase(); + const roomCode = generateSecureCode(6); const newRoomRef = await db.collection('rooms').add({ name: cleanName, diff --git a/src/views/DirectMessagesView.jsx b/src/views/DirectMessagesView.jsx index 9b225f0..c341f7a 100644 --- a/src/views/DirectMessagesView.jsx +++ b/src/views/DirectMessagesView.jsx @@ -1,7 +1,15 @@ import React, { useState, useEffect, useMemo } from 'react'; import { db, firebase } from '../firebase'; import TopAppBar from '../components/TopAppBar'; -import { normalizeDmHandle, isValidDmHandle, DM_LIFETIME_HOURS, DM_MAX_THREADS } from '../utils/beta'; +import { + normalizeDmHandle, + isValidDmHandle, + generateSecureCode, + DM_LIFETIME_HOURS, + DM_MAX_THREADS, + DIRECT_THREADS +} from '../utils/beta'; +import { getHandleOwner } from '../utils/profile'; function formatTimeLeft(expiresAt) { if (!expiresAt) return ''; @@ -22,9 +30,9 @@ export default function DirectMessagesView({ user, onNavigate, onLogout, onOpenS useEffect(() => { if (!user?.authUid) return undefined; - // Equality + inequality filters would need a composite index, so the - // expiry/direct filtering happens client side on this small result set. - const unsub = db.collection('rooms') + // Expiry filtering happens client side to avoid a composite index on a + // result set that is capped at a handful of threads. + const unsub = db.collection(DIRECT_THREADS) .where('participants', 'array-contains', user.authUid) .onSnapshot( (snapshot) => { @@ -32,7 +40,6 @@ export default function DirectMessagesView({ user, onNavigate, onLogout, onOpenS const list = snapshot.docs .map((doc) => ({ id: doc.id, ...doc.data() })) .filter((thread) => { - if (!thread.isDirect) return false; if (!thread.expires_at) return true; const exp = thread.expires_at.toDate ? thread.expires_at.toDate().getTime() : new Date(thread.expires_at).getTime(); return exp > now; @@ -81,13 +88,12 @@ export default function DirectMessagesView({ user, onNavigate, onLogout, onOpenS setStarting(true); try { - const usersSnap = await db.collection('users').where('dmHandle', '==', handle).limit(1).get(); - if (usersSnap.empty) { + const other = await getHandleOwner(handle); + if (!other) { showSnackbar('No user found with that handle', 'error'); return; } - const other = usersSnap.docs[0].data(); if (!other.authUid || other.authUid === user.authUid) { showSnackbar('That handle cannot be messaged', 'error'); return; @@ -102,14 +108,14 @@ export default function DirectMessagesView({ user, onNavigate, onLogout, onOpenS const now = Date.now(); const existing = threads.find((t) => Array.isArray(t.participants) && t.participants.includes(other.authUid)); if (existing) { - onNavigate(`chat/${existing.id}`); + onNavigate(`dm/${existing.id}`); return; } const expiresAt = new Date(now + DM_LIFETIME_HOURS * 60 * 60 * 1000); - const code = Math.random().toString(36).substring(2, 14).toUpperCase(); + const code = generateSecureCode(26); - const threadRef = await db.collection('rooms').add({ + const threadRef = await db.collection(DIRECT_THREADS).add({ name: `Direct message with ${handle}`, creator: user.username, authUid: user.authUid, @@ -130,7 +136,7 @@ export default function DirectMessagesView({ user, onNavigate, onLogout, onOpenS }); setHandleInput(''); - onNavigate(`chat/${threadRef.id}`); + onNavigate(`dm/${threadRef.id}`); } catch (err) { console.error('Start direct message error:', err); showSnackbar('Failed to start direct message', 'error'); @@ -146,7 +152,7 @@ export default function DirectMessagesView({ user, onNavigate, onLogout, onOpenS const msgs = await db.collection('messages').where('room_id', '==', threadId).get(); const batch = db.batch(); msgs.docs.forEach((doc) => batch.delete(doc.ref)); - batch.delete(db.collection('rooms').doc(threadId)); + batch.delete(db.collection(DIRECT_THREADS).doc(threadId)); await batch.commit(); showSnackbar('Direct chat deleted'); } catch (err) { @@ -244,7 +250,7 @@ export default function DirectMessagesView({ user, onNavigate, onLogout, onOpenS {thread.latestMessage &&

{thread.latestMessage}

}
- diff --git a/src/views/LoginView.jsx b/src/views/LoginView.jsx index e0f2447..b33336a 100644 --- a/src/views/LoginView.jsx +++ b/src/views/LoginView.jsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import { db, auth } from '../firebase'; -import { createUserProfileFields, ensureUserProfile } from '../utils/profile'; +import { allocateDmHandle, ensureUserProfile, getUsernameOwner, reserveUsername } from '../utils/profile'; +import { rollSignupBetaFlag } from '../utils/beta'; export default function LoginView({ onLoginSuccess, showSnackbar }) { const [username, setUsername] = useState(''); @@ -23,45 +24,60 @@ export default function LoginView({ onLoginSuccess, showSnackbar }) { const cred = await auth.signInAnonymously(); const authUser = cred.user; - // 2. Check if username is already claimed by someone else - const existing = await db.collection('users').where('username', '==', cleaned).get(); - if (!existing.empty) { - const doc = existing.docs[0]; - const docData = doc.data(); - if (docData.authUid !== authUser.uid) { - showSnackbar('Username is already taken by another user', 'error'); - setLoading(false); - return; - } + // 2. Claim the username. The reservation document is the single source + // of truth, so two people can never end up with the same name. + const owner = await getUsernameOwner(cleaned); + if (owner && owner.authUid !== authUser.uid) { + showSnackbar('Username is already taken by another user', 'error'); + setLoading(false); + return; + } + if (owner && owner.userId) { // Same account signing back in — reuse the existing profile const restored = await ensureUserProfile({ - username: cleaned, - uid: doc.id, + username: owner.username || cleaned, + uid: owner.userId, authUid: authUser.uid }); + if (restored.usernameConflict) { + showSnackbar('Username is already taken by another user', 'error'); + setLoading(false); + return; + } + localStorage.setItem('tempchats_user', JSON.stringify(restored)); onLoginSuccess(restored); return; } - // 3. Register user document - const profileFields = await createUserProfileFields(); + // 3. Register the user document, then claim the name and a handle + const tags = { beta: rollSignupBetaFlag() }; const userRef = await db.collection('users').add({ username: cleaned, authUid: authUser.uid, - dmHandle: profileFields.dmHandle, - tags: profileFields.tags, + tags, created_at: new Date() }); + const claimed = await reserveUsername(cleaned, authUser.uid, userRef.id); + if (!claimed) { + await db.collection('users').doc(userRef.id).delete().catch(() => {}); + showSnackbar('Username is already taken by another user', 'error'); + setLoading(false); + return; + } + + const dmHandle = await allocateDmHandle(authUser.uid, cleaned); + if (dmHandle) await userRef.update({ dmHandle }); + const userData = { username: cleaned, uid: userRef.id, authUid: authUser.uid, - dmHandle: profileFields.dmHandle, - tags: profileFields.tags + dmHandle, + tags }; localStorage.setItem('tempchats_user', JSON.stringify(userData)); From 4f0e8ce21155a8719555c3c9a45bfb13a74938cb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:59:27 +0000 Subject: [PATCH 6/6] Address review: seed missing tags, chunk batch deletes, document read scope Co-authored-by: DaDevMikey <169651161+DaDevMikey@users.noreply.github.com> --- firestore.rules | 9 +++++++-- src/App.jsx | 19 +++++-------------- src/utils/batch.js | 16 ++++++++++++++++ src/utils/profile.js | 12 ++++++++++-- src/views/DirectMessagesView.jsx | 9 +++++---- 5 files changed, 43 insertions(+), 22 deletions(-) create mode 100644 src/utils/batch.js diff --git a/firestore.rules b/firestore.rules index 9c6a389..aa536fe 100644 --- a/firestore.rules +++ b/firestore.rules @@ -195,8 +195,13 @@ service cloud.firestore { return exists(threadPath(roomId)) ? isThreadMember(roomId) : exists(roomPath(roomId)); } - // Direct message bodies are end-to-end encrypted with a key that lives on - // the thread document, which only the two participants can read. + // 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() diff --git a/src/App.jsx b/src/App.jsx index 7bd4fa3..3477572 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -11,6 +11,7 @@ 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'; @@ -151,9 +152,7 @@ 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`); } @@ -175,9 +174,7 @@ export default function App() { }); if (expiredDirect.length > 0) { - const roomBatch = db.batch(); - expiredDirect.forEach((doc) => roomBatch.delete(doc.ref)); - await roomBatch.commit(); + await deleteDocsInBatches(expiredDirect.map((doc) => doc.ref)); } } } catch (err) { @@ -279,10 +276,7 @@ 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`); } @@ -291,10 +285,7 @@ export default function App() { 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(); - const dmBatch = db.batch(); - dmMsgs.docs.forEach((d) => dmBatch.delete(d.ref)); - dmBatch.delete(dmDoc.ref); - await dmBatch.commit(); + await deleteDocsInBatches([...dmMsgs.docs.map((d) => d.ref), dmDoc.ref]); } // 3. Delete the user document and free the reserved name and handle 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/profile.js b/src/utils/profile.js index 681cfc7..03ea082 100644 --- a/src/utils/profile.js +++ b/src/utils/profile.js @@ -90,10 +90,18 @@ export async function ensureUserProfile(user) { ? fresh.data().tags : null; - if (freshTags && !shouldEnrolOnRefresh(freshTags)) return freshTags; + 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 = freshTags ? { ...freshTags, beta: true } : { beta: false }; + const nextTags = { ...current, beta: true }; tx.update(ref, { tags: nextTags }); return nextTags; }); diff --git a/src/views/DirectMessagesView.jsx b/src/views/DirectMessagesView.jsx index c341f7a..15d24f9 100644 --- a/src/views/DirectMessagesView.jsx +++ b/src/views/DirectMessagesView.jsx @@ -10,6 +10,7 @@ import { DIRECT_THREADS } from '../utils/beta'; import { getHandleOwner } from '../utils/profile'; +import { deleteDocsInBatches } from '../utils/batch'; function formatTimeLeft(expiresAt) { if (!expiresAt) return ''; @@ -150,10 +151,10 @@ export default function DirectMessagesView({ user, onNavigate, onLogout, onOpenS setDeletingId(threadId); try { const msgs = await db.collection('messages').where('room_id', '==', threadId).get(); - const batch = db.batch(); - msgs.docs.forEach((doc) => batch.delete(doc.ref)); - batch.delete(db.collection(DIRECT_THREADS).doc(threadId)); - await batch.commit(); + await deleteDocsInBatches([ + ...msgs.docs.map((doc) => doc.ref), + db.collection(DIRECT_THREADS).doc(threadId) + ]); showSnackbar('Direct chat deleted'); } catch (err) { console.error('Delete direct chat error:', err);