-
-
Message Chimes
-
- Play a subtle chime when sending & receiving messages
+
+ {SETTINGS.map((item) => (
+
+
+
+ {item.icon}
+
+
+
{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 && (
+
+ timer
+ {timeLeft}
+
+ )}
+
+
{chatVelocity}
+
+ {room.isPrivate && (
+ <>
+
+
+
+ lock
+ 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:
-
setVanishTimer(null)}
- style={{ height: '26px', padding: '0 10px', fontSize: '0.75rem' }}
- >
- Off
-
-
setVanishTimer(10)}
- style={{ height: '26px', padding: '0 10px', fontSize: '0.75rem' }}
- >
- 10s
-
-
setVanishTimer(30)}
- style={{ height: '26px', padding: '0 10px', fontSize: '0.75rem' }}
- >
- 30s
-
-
-
+ {/* Composer options */}
+
+ Vanish
+ {[null, 10, 30].map((opt) => (
+ setVanishTimer(opt)}
+ style={{ minHeight: '32px' }}
+ >
+ {opt === null ? 'Off' : `${opt}s`}
+
+ ))}
setIsBurnAfterReading(!isBurnAfterReading)}
- style={{ height: '26px', padding: '0 10px', fontSize: '0.75rem' }}
+ style={{ minHeight: '32px' }}
>
- visibility_off
- One-Time View
+ visibility_off
+ One-time view
@@ -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
+
+
+ fiber_manual_record
+
{name}
))
@@ -602,18 +683,21 @@ export default function ChatRoomView({ roomId, user, onNavigate, onLogout, showS
{/* QR Code Sharing Modal */}
+
{room?.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} />
-