From 7ab6b72866198ee2f5e2922729d3b28f9c35384e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=97=D0=B0=D0=B8=D0=B4=20=D0=9E=D0=BC=D0=B0=D1=80=20?= =?UTF-8?q?=D0=9C=D0=B5=D0=B4=D1=85=D0=B0=D1=82=20=7C=20Zaid=20Omar=20Medh?= =?UTF-8?q?at?= Date: Sat, 11 Jul 2026 00:30:49 +0500 Subject: [PATCH] Redesign web client: modern Telegram-shaped chat UI Full presentation-layer rebuild of packages/web (no core/API/model changes). - Design system: indigo light+dark token sets, 25 inline SVG icons, CSS foundation (spacing/radius/type/motion scales, per-theme elevation, reset, scrollbars). - Shell: two-pane rail (brand, icon theme switch, live search, rich conversation rows, account footer, group/contacts compose) + auth card. - Chat pane: sticky header with presence/typing, grouped bubbles with date separators, icon read-ticks, hover toolbar, inline edit, reaction pills, auto-growing composer with Enter-to-send, near-bottom-aware autoscroll. - Media renderers: waveform voice player (WebAudio decode + pseudo-waveform fallback), circular video notes (rendered bare), image lightbox with zoom/pan, framed video->lightbox player, file cards. - Restore + modernize notification toast styles. Emoji kept only as reaction content; all chrome is icons. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BFRyKxkKEjfAgpXygzoNiD --- packages/web/src/app/App.tsx | 224 +- packages/web/src/app/index.css | 2115 +++++++++++++---- .../web/src/features/auth/ui/AuthForm.tsx | 117 +- .../features/contacts/ui/ContactsPanel.tsx | 84 +- .../conversations/ui/ConversationSidebar.tsx | 258 +- .../src/features/messaging/ui/ChatView.tsx | 525 ++-- .../features/messaging/ui/MediaMessage.tsx | 114 + .../src/features/messaging/ui/MediaViewer.tsx | 173 ++ .../features/messaging/ui/VideoMessage.tsx | 70 + .../features/messaging/ui/VoiceMessage.tsx | 169 ++ packages/web/src/shared/theme/tokens.ts | 78 +- packages/web/src/shared/ui/icons.tsx | 196 +- packages/web/src/shared/ui/index.ts | 18 + 13 files changed, 3240 insertions(+), 901 deletions(-) create mode 100644 packages/web/src/features/messaging/ui/MediaMessage.tsx create mode 100644 packages/web/src/features/messaging/ui/MediaViewer.tsx create mode 100644 packages/web/src/features/messaging/ui/VideoMessage.tsx create mode 100644 packages/web/src/features/messaging/ui/VoiceMessage.tsx diff --git a/packages/web/src/app/App.tsx b/packages/web/src/app/App.tsx index 558fc63..20e9c3b 100644 --- a/packages/web/src/app/App.tsx +++ b/packages/web/src/app/App.tsx @@ -4,7 +4,7 @@ import type { Conversation, Message, MediaRef, PublicUser, User } from '@altrica import { heartbeat, getAvatarUploadUrl, uploadToUrl, setAvatar } from '@altricade/core/api'; import { SessionProvider, useSession } from '../entities/session'; import { AuthForm } from '../features/auth'; -import { RealtimeProvider, useRealtime } from '../features/realtime'; +import { RealtimeProvider } from '../features/realtime'; import { useConversations, ConversationSidebar } from '../features/conversations'; import { ContactsPanel } from '../features/contacts'; import { ChatView } from '../features/messaging'; @@ -12,8 +12,21 @@ import { NotificationsProvider, useNotifications, unregisterWebPush } from '../f import { apiConfig } from '../shared/api'; import { useTheme } from '../shared/theme'; import type { ThemePreference } from '../shared/theme'; +import { + SunIcon, + MoonIcon, + MonitorIcon, + PlusIcon, + CloseIcon, + LogOutIcon, + UsersIcon, +} from '../shared/ui'; -const PREFERENCES: readonly ThemePreference[] = ['light', 'dark', 'system']; +const PREFERENCES: readonly { value: ThemePreference; label: string; icon: ReactElement }[] = [ + { value: 'light', label: 'Light', icon: }, + { value: 'dark', label: 'Dark', icon: }, + { value: 'system', label: 'System', icon: }, +]; const mediaLabel = (media: MediaRef | null): string => { if (media === null) { @@ -34,23 +47,85 @@ const mediaLabel = (media: MediaRef | null): string => { const ThemeSwitch = (): ReactElement => { const { preference, setPreference } = useTheme(); return ( -
+
{PREFERENCES.map((option) => ( ))}
); }; +const GroupComposer = ({ + onCreate, +}: { + onCreate: (title: string, members: string[]) => Promise; +}): ReactElement => { + const [title, setTitle] = useState(''); + const [members, setMembers] = useState(''); + const [error, setError] = useState(null); + + const submit = async (event: SyntheticEvent): Promise => { + event.preventDefault(); + const trimmed = title.trim(); + if (trimmed === '') { + return; + } + const list = members + .split(',') + .map((name) => name.trim()) + .filter((name) => name.length > 0); + setError(null); + try { + await onCreate(trimmed, list); + setTitle(''); + setMembers(''); + } catch { + setError('Could not create group'); + } + }; + + return ( +
+

New group

+
void submit(event)}> + { + setTitle(event.target.value); + }} + /> + { + setMembers(event.target.value); + }} + /> + +
+ {error !== null ?

{error}

: null} +
+ ); +}; + const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): ReactElement => { const me: PublicUser = { id: user.id, @@ -59,9 +134,9 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re avatarUrl: user.avatarUrl, }; const { updateUser } = useSession(); - const { state } = useRealtime(); const { notify, setOpener } = useNotifications(); const [current, setCurrent] = useState(null); + const [composing, setComposing] = useState(false); const convRef = useRef([]); // Toast (or OS notification when hidden) for messages arriving in other chats. @@ -86,15 +161,20 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re ); convRef.current = conversations; + const openConversation = useCallback((conversation: Conversation): void => { + setCurrent(conversation); + setComposing(false); + }, []); + // Let notification taps (in-app toast or SW message) open the right chat. useEffect(() => { setOpener((conversationId) => { const conversation = convRef.current.find((item) => item.id === conversationId); if (conversation !== undefined) { - setCurrent(conversation); + openConversation(conversation); } }); - }, [setOpener]); + }, [setOpener, openConversation]); // Deep-link: /?conversation= (from a push opened in a fresh tab). useEffect(() => { @@ -135,57 +215,89 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re }; }, []); + const startDirectAndOpen = async (username: string): Promise => { + openConversation(await startDirect(username)); + }; + + const createGroupAndOpen = async (title: string, members: string[]): Promise => { + openConversation(await createGroupChat(title, members)); + }; + return ( -
-
- - - - @{user.username} · socket: {state} +
+
-
-
+
+ + +
+
+ + {composing ? ( +
+ + +
+ ) : ( { - setCurrent(await startDirect(username)); - }} - onCreateGroup={async (title, members) => { - setCurrent(await createGroupChat(title, members)); - }} + onSelect={openConversation} + onStartDirect={startDirectAndOpen} /> - { - setCurrent(await startDirect(username)); - }} - /> -
- {current !== null ? ( - - ) : ( -
-

Search for a user or pick a contact to start chatting.

-
)} -
+ +
+ + +
+ + + {current !== null ? ( + + ) : ( +
+
+ +

Select a conversation

+

Search for someone or start a new chat to begin messaging.

+
+
+ )}
); }; @@ -195,8 +307,10 @@ const Shell = (): ReactElement => { if (status === 'loading') { return ( -
-

Loading…

+
+
); } diff --git a/packages/web/src/app/index.css b/packages/web/src/app/index.css index 3fab69a..e5df004 100644 --- a/packages/web/src/app/index.css +++ b/packages/web/src/app/index.css @@ -1,532 +1,1458 @@ +/* ============================================================================= + Altricade — design system foundation + Colors come from ThemeProvider as --color- (see shared/theme/tokens.ts). + Everything below is the static primitive layer + base element styling. + Component sections are appended further down, grouped by feature. + ========================================================================== */ + :root { color-scheme: light dark; - /* Fallbacks until ThemeProvider sets the resolved tokens on . */ + /* Fallback colors (light) until ThemeProvider writes the resolved tokens. */ --color-background: #ffffff; - --color-surface: #f4f5f7; - --color-text: #0b0c0f; - --color-textMuted: #5b6472; - --color-accent: #2f6fed; - --color-border: #e2e5ea; + --color-surfacePanel: #f7f8fa; + --color-surface: #eef0f4; + --color-surfaceHover: #f0f2f6; + --color-chatBackdrop: #f4f5f8; + --color-text: #0c0d10; + --color-textMuted: #606a7b; + --color-textFaint: #9aa3b2; + --color-accent: #4c6fff; + --color-accentHover: #3a5cf5; + --color-accentSoft: #eaeeff; + --color-onAccent: #ffffff; + --color-onAccentMuted: rgba(255, 255, 255, 0.72); + --color-bubbleOut: #4c6fff; + --color-bubbleIn: #ffffff; + --color-border: #e6e8ee; + --color-borderStrong: #d4d8e0; + --color-online: #22c55e; + --color-danger: #ef4444; + + /* Spacing scale (4px base). */ + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.5rem; + --space-6: 2rem; + --space-8: 3rem; + + /* Radius scale. */ + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 22px; + --radius-full: 999px; + + /* Type. */ + --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', + Arial, sans-serif; + --font-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', 'Fira Code', monospace; + --text-xs: 0.75rem; + --text-sm: 0.8125rem; + --text-base: 0.9375rem; + --text-md: 1rem; + --text-lg: 1.125rem; + --text-xl: 1.375rem; + --text-2xl: 1.75rem; + + /* Layout. */ + --rail-width: 340px; + --header-height: 68px; + + /* Motion. */ + --ease: cubic-bezier(0.22, 0.61, 0.36, 1); + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --dur-fast: 120ms; + --dur: 200ms; + --dur-slow: 320ms; + + /* Elevation (light). */ + --shadow-sm: 0 1px 2px rgba(16, 18, 27, 0.06), 0 1px 1px rgba(16, 18, 27, 0.04); + --shadow-md: 0 6px 20px rgba(16, 18, 27, 0.1); + --shadow-lg: 0 24px 60px rgba(16, 18, 27, 0.22); } -* { +:root[data-theme='dark'] { + /* Elevation (dark) — deeper, since surfaces are near-black. */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow-md: 0 8px 28px rgba(0, 0, 0, 0.5); + --shadow-lg: 0 28px 70px rgba(0, 0, 0, 0.7); +} + +*, +*::before, +*::after { box-sizing: border-box; } +html, +body, +#root { + height: 100%; +} + body { margin: 0; - font-family: system-ui, -apple-system, sans-serif; + font-family: var(--font-sans); + font-size: var(--text-base); + line-height: 1.5; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; background: var(--color-background); color: var(--color-text); transition: - background 0.2s ease, - color 0.2s ease; + background var(--dur) var(--ease), + color var(--dur) var(--ease); } -.app { - max-width: 640px; - margin: 0 auto; - padding: 3rem 1.5rem; -} - -.app h1 { - color: var(--color-accent); - margin-bottom: 0.25rem; -} - -.app dl { - display: grid; - grid-template-columns: max-content 1fr; - gap: 0.5rem 1rem; - padding: 1rem; - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: 12px; -} - -.app dt { - color: var(--color-textMuted); -} - -.app dd { +h1, +h2, +h3, +h4, +p { margin: 0; - font-family: ui-monospace, monospace; } -.theme-switch { - display: flex; - gap: 0.5rem; - margin-top: 1.5rem; -} - -.theme-switch button { - padding: 0.5rem 1rem; - border-radius: 8px; - border: 1px solid var(--color-border); - background: var(--color-surface); - color: var(--color-text); +button { + font: inherit; + color: inherit; cursor: pointer; } -.theme-switch button[aria-pressed='true'] { - border-color: var(--color-accent); +input, +textarea { + font: inherit; + color: inherit; +} + +a { color: var(--color-accent); + text-decoration: none; } -.auth-form, -.echo-form { - display: flex; - flex-direction: column; - gap: 0.5rem; - max-width: 320px; - margin: 1rem 0; +:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; } -.echo-form { - flex-direction: row; +/* Scrollbars — thin, theme-aware, only on hover-capable surfaces. */ +* { + scrollbar-width: thin; + scrollbar-color: var(--color-borderStrong) transparent; } -.auth-form input, -.echo-form input { - padding: 0.5rem; - border-radius: 8px; - border: 1px solid var(--color-border); - background: var(--color-surface); - color: var(--color-text); +*::-webkit-scrollbar { + width: 10px; + height: 10px; } -.auth-form button, -.echo-form button, -.actions button { - padding: 0.5rem 1rem; - border-radius: 8px; - border: 1px solid var(--color-accent); - background: var(--color-accent); - color: #fff; - cursor: pointer; +*::-webkit-scrollbar-thumb { + background: transparent; + border: 3px solid transparent; + border-radius: var(--radius-full); + background-clip: padding-box; } -.auth-form button:disabled, -.echo-form button:disabled { - opacity: 0.5; - cursor: not-allowed; +*:hover::-webkit-scrollbar-thumb { + background: var(--color-borderStrong); + background-clip: padding-box; } -.auth-toggle { - background: none; - border: none; - color: var(--color-accent); - cursor: pointer; - padding: 0; +*::-webkit-scrollbar-track { + background: transparent; } -.auth-error { - color: #e5484d; +::selection { + background: color-mix(in srgb, var(--color-accent) 26%, transparent); } -.realtime dl { - display: grid; - grid-template-columns: max-content 1fr; - gap: 0.25rem 1rem; -} - -.event-log { - font-family: ui-monospace, monospace; - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: 8px; - padding: 0.75rem 1.25rem; - max-height: 240px; - overflow-y: auto; -} - -.actions { - margin: 1rem 0; -} - -/* Chat layout */ -.layout { - display: flex; - flex-direction: column; - height: 100vh; -} - -.topbar { - display: flex; - justify-content: space-between; - align-items: center; - padding: 0.75rem 1rem; - border-bottom: 1px solid var(--color-border); - background: var(--color-surface); -} - -.topbar-actions { - display: flex; - align-items: center; - gap: 0.75rem; -} - -.workspace { - display: flex; - flex: 1; - min-height: 0; -} - -.sidebar { - width: 260px; - border-right: 1px solid var(--color-border); - padding: 1rem; - overflow-y: auto; -} - -.sidebar-form { - display: flex; - gap: 0.5rem; - margin-bottom: 1rem; -} - -.sidebar-form input { - flex: 1; - min-width: 0; - padding: 0.4rem; - border-radius: 8px; - border: 1px solid var(--color-border); - background: var(--color-background); - color: var(--color-text); -} - -.room-list { - list-style: none; - padding: 0; - margin: 0 0 1rem; - display: flex; - flex-direction: column; - gap: 0.25rem; -} - -.room-list button { - width: 100%; - text-align: left; - padding: 0.5rem 0.75rem; - border-radius: 8px; - border: 1px solid transparent; - background: none; - color: var(--color-text); - cursor: pointer; -} - -.room-list button[aria-pressed='true'] { - background: var(--color-surface); - border-color: var(--color-accent); - color: var(--color-accent); -} - -.chat { - flex: 1; - display: flex; - flex-direction: column; - min-height: 0; - padding: 1rem; -} - -.chat-empty { - align-items: center; - justify-content: center; - color: var(--color-textMuted); -} - -.message-list { - list-style: none; - margin: 0; - padding: 0; - flex: 1; - overflow-y: auto; - display: flex; - flex-direction: column; - gap: 0.4rem; -} - -.message-list li { - padding: 0.4rem 0.6rem; - border-radius: 8px; - background: var(--color-surface); -} - -.message-list li.mine { - align-self: flex-end; - background: var(--color-accent); - color: #fff; -} - -.msg-author { - font-weight: 600; - margin-right: 0.5rem; -} - -.composer { - display: flex; - gap: 0.5rem; - margin-top: 0.75rem; -} - -.composer input { - flex: 1; - padding: 0.6rem; - border-radius: 8px; - border: 1px solid var(--color-border); - background: var(--color-surface); - color: var(--color-text); -} - -.sidebar-column { - width: 280px; - border-right: 1px solid var(--color-border); - overflow-y: auto; - display: flex; - flex-direction: column; -} - -.sidebar-column .sidebar { - width: auto; - border-right: none; - border-bottom: 1px solid var(--color-border); -} - -.sidebar-heading { - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--color-textMuted); - margin: 1rem 0 0.5rem; -} - -.search-input { - width: 100%; - padding: 0.5rem; - border-radius: 8px; - border: 1px solid var(--color-border); - background: var(--color-background); - color: var(--color-text); -} - -.search-results { - list-style: none; - padding: 0; - margin: 0.5rem 0; - border: 1px solid var(--color-border); - border-radius: 8px; - overflow: hidden; -} - -.search-results button, -.contact-list button { - width: 100%; - text-align: left; - padding: 0.5rem 0.75rem; - border: none; - background: none; - color: var(--color-text); - cursor: pointer; -} +/* -------- shared primitives -------- */ .muted { color: var(--color-textMuted); } -.contacts { - padding: 1rem; -} - -.contact-list { - list-style: none; - padding: 0; - margin: 0; -} - -.contact-list li { - display: flex; - align-items: center; - justify-content: space-between; -} - -.contact-remove { - border: none; - background: none; - color: var(--color-textMuted); - cursor: pointer; -} - -.chat-title { - margin: 0 0 0.75rem; - padding-bottom: 0.5rem; - border-bottom: 1px solid var(--color-border); -} - -.group-form { - flex-direction: column; - align-items: stretch; -} - -.online-dot { - display: inline-block; - width: 8px; - height: 8px; - border-radius: 50%; - background: #30a46c; - margin-right: 0.4rem; -} - -.unread-badge { - display: inline-block; - min-width: 1.2rem; - padding: 0 0.35rem; - margin-left: 0.4rem; - border-radius: 999px; - background: var(--color-accent); - color: #fff; - font-size: 0.7rem; - text-align: center; -} - -.msg-row { - display: flex; - align-items: baseline; - gap: 0.4rem; -} - -.read-mark { - margin-left: auto; - font-size: 0.75rem; - opacity: 0.8; -} - -.msg-actions { - display: none; - gap: 0.25rem; - margin-top: 0.2rem; -} - -.message-list li:hover .msg-actions { - display: flex; -} - -.react-btn, -.link-btn { - border: none; - background: none; - cursor: pointer; - font-size: 0.8rem; - padding: 0 0.15rem; - color: inherit; -} - -.link-btn { - color: var(--color-textMuted); - text-decoration: underline; -} - -.reactions { - display: flex; - gap: 0.25rem; - margin-top: 0.25rem; -} - -.reaction { - border: 1px solid var(--color-border); - background: var(--color-background); - color: var(--color-text); - border-radius: 999px; - padding: 0 0.5rem; - font-size: 0.8rem; - cursor: pointer; -} - -.reaction.mine-reaction { - border-color: var(--color-accent); - color: var(--color-accent); -} - -.typing { - font-style: italic; - padding: 0 0.25rem; -} - -.topbar-user { - display: flex; - align-items: center; - gap: 0.6rem; -} - .avatar { - width: 32px; - height: 32px; - border-radius: 50%; + flex: 0 0 auto; + width: 42px; + height: 42px; + border-radius: var(--radius-full); object-fit: cover; display: inline-flex; align-items: center; justify-content: center; + font-weight: 600; + user-select: none; } .avatar-placeholder { - background: var(--color-accent); - color: #fff; + background: linear-gradient(140deg, var(--color-accent), var(--color-accentHover)); + color: var(--color-onAccent); text-transform: uppercase; - font-weight: 600; -} - -.avatar-edit { - cursor: pointer; } +/* Generic square icon button used across chrome (header, composer, rows). */ .icon-btn { display: inline-flex; align-items: center; justify-content: center; - width: 38px; - height: 38px; + width: 40px; + height: 40px; flex: 0 0 auto; padding: 0; - border: 1px solid transparent; - border-radius: 8px; + border: none; + border-radius: var(--radius-full); background: none; color: var(--color-textMuted); - cursor: pointer; + transition: + background var(--dur-fast) var(--ease), + color var(--dur-fast) var(--ease), + transform var(--dur-fast) var(--ease); } .icon-btn:hover { - background: var(--color-surface); + background: var(--color-surfaceHover); color: var(--color-text); } -.icon-btn.send { - background: var(--color-accent); - border-color: var(--color-accent); - color: #fff; +.icon-btn:active { + transform: scale(0.94); } +.icon-btn.accent { + background: var(--color-accent); + color: var(--color-onAccent); +} + +.icon-btn.accent:hover { + background: var(--color-accentHover); +} + +/* ============================================================================= + Form primitives — fields & buttons + ========================================================================== */ + +.field { + width: 100%; + padding: 0.65rem 0.85rem; + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + background: var(--color-background); + color: var(--color-text); + transition: + border-color var(--dur-fast) var(--ease), + box-shadow var(--dur-fast) var(--ease); +} + +.field::placeholder { + color: var(--color-textFaint); +} + +.field:focus { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 20%, transparent); +} + +.field-label { + display: flex; + flex-direction: column; + gap: 0.4rem; + font-size: var(--text-sm); + font-weight: 500; + color: var(--color-textMuted); +} + +.btn-primary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.7rem 1.1rem; + border: none; + border-radius: var(--radius-md); + background: var(--color-accent); + color: var(--color-onAccent); + font-weight: 600; + transition: + background var(--dur-fast) var(--ease), + transform var(--dur-fast) var(--ease), + opacity var(--dur-fast) var(--ease); +} + +.btn-primary:hover:not(:disabled) { + background: var(--color-accentHover); +} + +.btn-primary:active:not(:disabled) { + transform: scale(0.99); +} + +.btn-primary:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-sm { + padding: 0.55rem 0.9rem; + font-size: var(--text-sm); +} + +.link { + border: none; + background: none; + padding: 0; + color: var(--color-accent); + font-weight: 600; +} + +.link:hover { + text-decoration: underline; +} + +.form-error { + color: var(--color-danger); + font-size: var(--text-sm); +} + +/* ============================================================================= + Splash + Auth + ========================================================================== */ + +.splash { + display: grid; + place-items: center; + height: 100%; + background: var(--color-background); +} + +.splash-mark, +.auth-mark, +.rail-mark, +.chat-empty-mark { + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 800; + color: var(--color-onAccent); + background: linear-gradient(140deg, var(--color-accent), var(--color-accentHover)); + border-radius: 30%; + user-select: none; +} + +.splash-mark { + width: 64px; + height: 64px; + font-size: 2rem; + animation: splash-pulse 1.4s var(--ease) infinite; +} + +@keyframes splash-pulse { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.65; + transform: scale(0.94); + } +} + +.auth { + display: grid; + place-items: center; + height: 100%; + padding: 1.5rem; + background: + radial-gradient(120% 120% at 15% 0%, color-mix(in srgb, var(--color-accent) 12%, transparent), transparent 45%), + var(--color-chatBackdrop); +} + +.auth-card { + width: 100%; + max-width: 400px; + padding: 2.25rem; + border-radius: var(--radius-xl); + background: var(--color-background); + border: 1px solid var(--color-border); + box-shadow: var(--shadow-lg); +} + +.auth-brand { + text-align: center; + margin-bottom: 1.75rem; +} + +.auth-mark { + width: 52px; + height: 52px; + font-size: 1.6rem; + margin-bottom: 0.9rem; +} + +.auth-wordmark { + font-size: var(--text-2xl); + font-weight: 700; + letter-spacing: -0.02em; +} + +.auth-tagline { + color: var(--color-textMuted); + margin-top: 0.25rem; +} + +.auth-form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.auth-form .btn-primary { + margin-top: 0.35rem; +} + +.auth-switch { + margin-top: 1.5rem; + text-align: center; + font-size: var(--text-sm); + color: var(--color-textMuted); +} + +/* ============================================================================= + App shell + conversation rail + ========================================================================== */ + +.app-shell { + display: grid; + grid-template-columns: var(--rail-width) 1fr; + height: 100%; + min-height: 0; +} + +.rail { + display: flex; + flex-direction: column; + min-height: 0; + background: var(--color-surfacePanel); + border-right: 1px solid var(--color-border); +} + +.rail-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0 0.85rem; + height: var(--header-height); + flex: 0 0 auto; +} + +.rail-brand { + display: inline-flex; + align-items: center; + gap: 0.6rem; + min-width: 0; +} + +.rail-mark { + width: 34px; + height: 34px; + font-size: 1.05rem; +} + +.rail-title { + font-weight: 700; + font-size: var(--text-lg); + letter-spacing: -0.02em; +} + +.rail-header-actions { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.theme-switch { + display: inline-flex; + padding: 3px; + gap: 2px; + border-radius: var(--radius-full); + background: var(--color-surface); +} + +.theme-option { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border: none; + border-radius: var(--radius-full); + background: none; + color: var(--color-textFaint); + transition: + background var(--dur-fast) var(--ease), + color var(--dur-fast) var(--ease); +} + +.theme-option:hover { + color: var(--color-text); +} + +.theme-option[aria-pressed='true'] { + background: var(--color-background); + color: var(--color-accent); + box-shadow: var(--shadow-sm); +} + +.rail-search { + position: relative; + display: flex; + align-items: center; + padding: 0 0.85rem 0.5rem; + flex: 0 0 auto; +} + +.rail-search-icon { + position: absolute; + left: 1.55rem; + color: var(--color-textFaint); + pointer-events: none; +} + +.rail-search-input { + width: 100%; + padding: 0.6rem 0.85rem 0.6rem 2.35rem; + border-radius: var(--radius-full); + border: 1px solid transparent; + background: var(--color-surface); + color: var(--color-text); + transition: + background var(--dur-fast) var(--ease), + border-color var(--dur-fast) var(--ease); +} + +.rail-search-input::placeholder { + color: var(--color-textFaint); +} + +.rail-search-input:focus { + outline: none; + background: var(--color-background); + border-color: var(--color-accent); +} + +.rail-search-clear { + position: absolute; + right: 1.35rem; + display: inline-flex; + border: none; + background: none; + color: var(--color-textFaint); + padding: 0.15rem; + border-radius: var(--radius-full); +} + +.rail-search-clear:hover { + color: var(--color-text); +} + +.rail-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 0.25rem 0 0.5rem; +} + +.rail-section-title { + padding: 0.5rem 0.6rem; + font-size: var(--text-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-textFaint); +} + +.rail-section { + padding: 0 0.6rem; +} + +.rail-empty { + padding: 1.25rem 1rem; + text-align: center; + color: var(--color-textFaint); + font-size: var(--text-sm); +} + +.conv-list { + padding: 0 0.5rem; +} + +.conv-row { + display: flex; + align-items: center; + gap: 0.7rem; + width: 100%; + padding: 0.55rem 0.6rem; + border: none; + border-radius: var(--radius-lg); + background: none; + text-align: left; + transition: background var(--dur-fast) var(--ease); +} + +.conv-row:hover { + background: var(--color-surfaceHover); +} + +.conv-row[aria-pressed='true'] { + background: var(--color-accentSoft); +} + +.conv-avatar-wrap { + position: relative; + flex: 0 0 auto; + line-height: 0; +} + +.online-dot { + position: absolute; + right: -1px; + bottom: -1px; + width: 12px; + height: 12px; + border-radius: var(--radius-full); + background: var(--color-online); + border: 2.5px solid var(--color-surfacePanel); +} + +.conv-row[aria-pressed='true'] .online-dot { + border-color: color-mix(in srgb, var(--color-accentSoft) 100%, var(--color-surfacePanel)); +} + +.conv-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.conv-top { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem; +} + +.conv-name { + font-weight: 600; + font-size: var(--text-base); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.conv-time { + flex: 0 0 auto; + font-size: var(--text-xs); + color: var(--color-textFaint); +} + +.conv-sub { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.conv-preview { + min-width: 0; + font-size: var(--text-sm); + color: var(--color-textMuted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.unread-badge { + flex: 0 0 auto; + min-width: 20px; + height: 20px; + padding: 0 6px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-full); + background: var(--color-accent); + color: var(--color-onAccent); + font-size: var(--text-xs); + font-weight: 600; +} + +/* Compose panel (new group + contacts) */ +.compose { + padding: 0.25rem 0.85rem 1rem; +} + +.compose-block { + padding: 0.5rem 0; +} + +.compose-block + .compose-block { + border-top: 1px solid var(--color-border); + margin-top: 0.5rem; +} + +.stack-form { + display: flex; + flex-direction: column; + gap: 0.55rem; +} + +.inline-add { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.contact-list { + list-style: none; + margin: 0.5rem 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.contact-row { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.contact-open { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.4rem 0.5rem; + border: none; + border-radius: var(--radius-md); + background: none; + text-align: left; + transition: background var(--dur-fast) var(--ease); +} + +.contact-open:hover { + background: var(--color-surfaceHover); +} + +.contact-avatar { + width: 34px; + height: 34px; + font-size: var(--text-sm); +} + +.contact-names { + min-width: 0; + display: flex; + flex-direction: column; +} + +.contact-name { + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.contact-handle { + font-size: var(--text-xs); +} + +.contact-remove { + width: 34px; + height: 34px; + color: var(--color-textFaint); +} + +.contact-remove:hover { + color: var(--color-danger); + background: color-mix(in srgb, var(--color-danger) 12%, transparent); +} + +/* Rail footer (account) */ +.rail-footer { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.6rem 0.85rem; + flex: 0 0 auto; + border-top: 1px solid var(--color-border); +} + +.rail-account { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.35rem; + border-radius: var(--radius-md); + cursor: pointer; + transition: background var(--dur-fast) var(--ease); +} + +.rail-account:hover { + background: var(--color-surfaceHover); +} + +.rail-account-avatar { + width: 38px; + height: 38px; + font-size: var(--text-base); +} + +.rail-account-names { + min-width: 0; + display: flex; + flex-direction: column; +} + +.rail-account-name { + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.rail-account-handle { + font-size: var(--text-xs); +} + +/* ============================================================================= + Chat pane — empty state (full chat UI added in a later section) + ========================================================================== */ + +.chat-pane { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + background: var(--color-chatBackdrop); +} + +.chat-empty { + align-items: center; + justify-content: center; + text-align: center; +} + +.chat-empty-inner { + max-width: 320px; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; +} + +.chat-empty-mark { + width: 56px; + height: 56px; + font-size: 1.6rem; + margin-bottom: 0.5rem; + opacity: 0.9; +} + +/* ============================================================================= + Chat pane — header, scrollback, bubbles, composer + ========================================================================== */ + +.chat-pane { + position: relative; +} + +/* Subtle depth on the chat backdrop — barely-there, not a busy pattern. */ +.chat-pane::before { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background: + radial-gradient(60% 55% at 78% 0%, color-mix(in srgb, var(--color-accent) 6%, transparent), transparent 70%), + radial-gradient(50% 50% at 0% 100%, color-mix(in srgb, var(--color-accent) 5%, transparent), transparent 70%); +} + +.chat-header { + position: relative; + z-index: 1; + display: flex; + align-items: center; + gap: 0.75rem; + height: var(--header-height); + flex: 0 0 auto; + padding: 0 1.1rem; + background: color-mix(in srgb, var(--color-surfacePanel) 82%, transparent); + backdrop-filter: blur(12px); + border-bottom: 1px solid var(--color-border); +} + +.chat-header-avatar { + width: 40px; + height: 40px; +} + +.chat-header-text { + display: flex; + flex-direction: column; + min-width: 0; + line-height: 1.25; +} + +.chat-header-title { + font-weight: 600; + font-size: var(--text-md); +} + +.chat-header-sub { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-size: var(--text-xs); + color: var(--color-textMuted); +} + +.chat-header-sub.typing-sub { + color: var(--color-accent); + font-style: italic; +} + +.online-inline { + width: 7px; + height: 7px; + border-radius: var(--radius-full); + background: var(--color-online); +} + +.chat-loading { + text-align: center; + padding: 0.5rem; + font-size: var(--text-sm); +} + +.message-scroll { + position: relative; + z-index: 1; + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 1rem clamp(0.75rem, 4vw, 3rem); +} + +.message-list { + display: flex; + flex-direction: column; + max-width: 900px; + margin: 0 auto; +} + +/* Date separator */ +.day-sep { + display: flex; + justify-content: center; + margin: 0.75rem 0; +} + +.day-sep span { + padding: 0.2rem 0.75rem; + border-radius: var(--radius-full); + background: color-mix(in srgb, var(--color-surfacePanel) 85%, transparent); + backdrop-filter: blur(6px); + font-size: var(--text-xs); + font-weight: 600; + color: var(--color-textMuted); + box-shadow: var(--shadow-sm); +} + +/* Message line + grouping */ +.msg-line { + display: flex; + align-items: flex-end; + gap: 0.5rem; + padding: 1px 0; +} + +.msg-line.mine { + justify-content: flex-end; +} + +.msg-line.group-end { + margin-bottom: 0.5rem; +} + +.msg-avatar { + width: 30px; + height: 30px; + font-size: var(--text-xs); + align-self: flex-end; +} + +.msg-avatar-spacer { + width: 30px; + flex: 0 0 auto; +} + +.bubble-wrap { + display: flex; + flex-direction: column; + max-width: min(74%, 560px); + min-width: 0; +} + +.msg-line.mine .bubble-wrap { + align-items: flex-end; +} + +.msg-sender { + margin: 0 0 2px 0.75rem; + font-size: var(--text-xs); + font-weight: 600; + color: var(--color-accent); +} + +/* Bubble */ +.bubble { + position: relative; + padding: 0.42rem 0.7rem; + border-radius: var(--radius-lg); + font-size: var(--text-base); + line-height: 1.4; +} + +.bubble::after { + content: ''; + display: block; + clear: both; +} + +.bubble-mine { + background: var(--color-bubbleOut); + color: var(--color-onAccent); +} + +.bubble-mine:not(.is-start) { + border-top-right-radius: 6px; +} + +.bubble-mine:not(.is-end) { + border-bottom-right-radius: 6px; +} + +.bubble-theirs { + background: var(--color-bubbleIn); + color: var(--color-text); + box-shadow: var(--shadow-sm); +} + +.bubble-theirs:not(.is-start) { + border-top-left-radius: 6px; +} + +.bubble-theirs:not(.is-end) { + border-bottom-left-radius: 6px; +} + +.bubble.has-media { + padding: 0.28rem; +} + +.bubble.has-media .bubble-text { + display: block; + padding: 0.25rem 0.45rem 0.1rem; +} + +.bubble-text { + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.bubble-deleted { + font-style: italic; + opacity: 0.7; +} + +.bubble-meta { + float: right; + display: inline-flex; + align-items: center; + gap: 0.2rem; + margin: 0.35rem 0 -0.1rem 0.6rem; + font-size: 0.68rem; + line-height: 1; + user-select: none; +} + +.bubble-mine .bubble-meta { + color: var(--color-onAccentMuted); +} + +.bubble-theirs .bubble-meta { + color: var(--color-textFaint); +} + +.bubble.has-media .bubble-meta { + padding: 0 0.45rem 0.2rem 0.6rem; +} + +.bubble-edited { + font-style: italic; +} + +.tick { + display: inline-flex; + color: var(--color-onAccentMuted); +} + +.tick-read { + color: var(--color-onAccent); +} + +/* Hover toolbar */ +.msg-tools { + position: absolute; + top: -1rem; + z-index: 3; + display: flex; + align-items: center; + gap: 2px; + padding: 3px; + border-radius: var(--radius-full); + background: var(--color-bubbleIn); + border: 1px solid var(--color-border); + box-shadow: var(--shadow-md); + opacity: 0; + transform: translateY(0.3rem) scale(0.96); + pointer-events: none; + transition: + opacity var(--dur-fast) var(--ease), + transform var(--dur-fast) var(--ease-out); +} + +.msg-line:hover .msg-tools { + opacity: 1; + transform: none; + pointer-events: auto; +} + +.msg-line.mine .msg-tools { + right: 0.25rem; +} + +.msg-line.theirs .msg-tools { + left: 0.25rem; +} + +.tool-react { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border: none; + border-radius: var(--radius-full); + background: none; + font-size: 1rem; + line-height: 1; + transition: + background var(--dur-fast) var(--ease), + transform var(--dur-fast) var(--ease-out); +} + +.tool-react:hover { + background: var(--color-surfaceHover); + transform: scale(1.25); +} + +.tool-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border: none; + border-radius: var(--radius-full); + background: none; + color: var(--color-textMuted); + transition: + background var(--dur-fast) var(--ease), + color var(--dur-fast) var(--ease); +} + +.tool-btn:hover { + background: var(--color-surfaceHover); + color: var(--color-text); +} + +.tool-danger:hover { + color: var(--color-danger); + background: color-mix(in srgb, var(--color-danger) 12%, transparent); +} + +.tool-divider { + width: 1px; + height: 18px; + margin: 0 2px; + background: var(--color-border); +} + +/* Reactions */ +.reactions { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 5px; +} + +.msg-line.mine .reactions { + justify-content: flex-end; +} + +.reaction { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.1rem 0.5rem; + border-radius: var(--radius-full); + border: 1px solid var(--color-border); + background: var(--color-bubbleIn); + color: var(--color-text); + font-size: var(--text-sm); + line-height: 1.4; + transition: + background var(--dur-fast) var(--ease), + border-color var(--dur-fast) var(--ease), + transform var(--dur-fast) var(--ease-out); +} + +.reaction:hover { + transform: translateY(-1px); +} + +.reaction.is-mine { + background: var(--color-accentSoft); + border-color: color-mix(in srgb, var(--color-accent) 45%, transparent); + color: var(--color-accent); +} + +.reaction-count { + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +/* Inline edit */ +.bubble-edit { + display: flex; + flex-direction: column; + gap: 0.4rem; + min-width: 220px; +} + +.edit-input { + width: 100%; + min-height: 2.4rem; + resize: vertical; + border: none; + background: color-mix(in srgb, currentColor 8%, transparent); + color: inherit; + border-radius: var(--radius-sm); + padding: 0.35rem 0.5rem; + font: inherit; +} + +.edit-input:focus { + outline: none; +} + +.edit-actions { + display: flex; + justify-content: flex-end; + gap: 0.3rem; +} + +.edit-actions .icon-btn { + width: 30px; + height: 30px; +} + +.edit-cancel { + color: var(--color-onAccentMuted); +} + +.edit-cancel:hover { + background: color-mix(in srgb, currentColor 15%, transparent); + color: var(--color-onAccent); +} + +/* ============================================================================= + Media (Part-3 baseline — enriched in the media section below) + ========================================================================== */ + +.media-loading { + display: inline-block; + padding: 0.35rem 0.5rem; + font-size: var(--text-sm); + opacity: 0.7; +} + +.media-img { + max-width: min(300px, 68vw); + max-height: 340px; + border-radius: var(--radius-md); + display: block; + cursor: zoom-in; +} + +.media-video { + max-width: min(340px, 72vw); + border-radius: var(--radius-md); + display: block; +} + +.media-audio { + min-width: 240px; + height: 40px; +} + +.media-file { + display: flex; + align-items: center; + gap: 0.65rem; + min-width: 220px; + padding: 0.5rem 0.6rem; + border-radius: var(--radius-md); + color: inherit; + text-decoration: none; + background: color-mix(in srgb, currentColor 8%, transparent); +} + +.media-file-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + flex: 0 0 auto; + border-radius: var(--radius-sm); + background: color-mix(in srgb, currentColor 14%, transparent); +} + +.media-file-meta { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +.media-file-name { + font-size: var(--text-sm); + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.media-file-size { + font-size: var(--text-xs); + opacity: 0.7; +} + +.media-file-dl { + flex: 0 0 auto; + opacity: 0.75; +} + +/* ============================================================================= + Composer + ========================================================================== */ + +.composer { + position: relative; + z-index: 1; + display: flex; + align-items: flex-end; + gap: 0.35rem; + padding: 0.6rem clamp(0.6rem, 3vw, 1.5rem); + background: var(--color-surfacePanel); + border-top: 1px solid var(--color-border); +} + +.composer-input { + flex: 1; + min-width: 0; + max-height: 8.5rem; + resize: none; + field-sizing: content; + padding: 0.55rem 1rem; + border-radius: var(--radius-xl); + border: 1px solid var(--color-border); + background: var(--color-background); + color: var(--color-text); + line-height: 1.4; +} + +.composer-input::placeholder { + color: var(--color-textFaint); +} + +.composer-input:focus { + outline: none; + border-color: var(--color-accent); +} + +.composer-send { + transition: + background var(--dur-fast) var(--ease), + opacity var(--dur-fast) var(--ease), + transform var(--dur-fast) var(--ease); +} + +.composer-send:disabled { + opacity: 0.45; + background: var(--color-surface); + color: var(--color-textFaint); + cursor: not-allowed; +} + +/* Recording bar */ .recording-bar { align-items: center; + gap: 0.6rem; } .record-dot { width: 10px; height: 10px; - border-radius: 50%; - background: #e5484d; - animation: record-pulse 1s ease-in-out infinite; + flex: 0 0 auto; + border-radius: var(--radius-full); + background: var(--color-danger); + animation: record-pulse 1s var(--ease) infinite; } .record-label { flex: 1; + font-size: var(--text-sm); color: var(--color-text); - font-size: 0.9rem; } .record-preview { - width: 72px; - height: 72px; - border-radius: 8px; + width: 64px; + height: 64px; + border-radius: var(--radius-md); object-fit: cover; background: #000; } @@ -537,63 +1463,310 @@ body { opacity: 1; } 50% { - opacity: 0.3; + opacity: 0.25; } } -.media-img { - max-width: 260px; - max-height: 260px; - border-radius: 8px; - display: block; -} +/* ============================================================================= + Media renderers — voice waveform, round video, thumbnails, lightbox + ========================================================================== */ -.media-video { - max-width: 320px; - border-radius: 8px; - display: block; -} - -.media-file { - display: inline-flex; +/* Voice note — custom waveform player */ +.voice { + display: flex; align-items: center; - gap: 0.35rem; - color: var(--color-accent); + gap: 0.6rem; + min-width: 232px; + max-width: 300px; + padding: 0.15rem 0.15rem 0.15rem 0.05rem; } -/* In-app notification toasts */ +.voice-play { + display: grid; + place-items: center; + width: 40px; + height: 40px; + flex: 0 0 auto; + border: none; + border-radius: var(--radius-full); + background: var(--color-accent); + color: var(--color-onAccent); + transition: transform var(--dur-fast) var(--ease); +} + +.voice-play:active { + transform: scale(0.92); +} + +.voice-mine .voice-play { + background: var(--color-onAccent); + color: var(--color-bubbleOut); +} + +.voice-wave { + flex: 1; + display: flex; + align-items: center; + gap: 2px; + height: 30px; + cursor: pointer; +} + +.wave-bar { + flex: 1; + min-width: 2px; + min-height: 3px; + border-radius: 2px; + background: var(--color-textFaint); + transition: background var(--dur-fast) var(--ease); +} + +.wave-bar.is-played { + background: var(--color-accent); +} + +.voice-mine .wave-bar { + background: var(--color-onAccentMuted); +} + +.voice-mine .wave-bar.is-played { + background: var(--color-onAccent); +} + +.voice-time { + flex: 0 0 auto; + min-width: 30px; + font-size: 0.72rem; + font-variant-numeric: tabular-nums; + opacity: 0.85; +} + +/* Round video message */ +.round-video { + position: relative; + display: block; + width: 208px; + height: 208px; + padding: 0; + border: none; + border-radius: var(--radius-full); + overflow: hidden; + background: #000; + cursor: pointer; +} + +.round-video-el { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.round-video-overlay { + position: absolute; + inset: 0; + display: grid; + place-items: center; + color: #fff; + background: rgba(0, 0, 0, 0.28); + transition: background var(--dur) var(--ease); +} + +.round-video-time { + position: absolute; + bottom: 0.6rem; + left: 50%; + transform: translateX(-50%); + padding: 0.1rem 0.5rem; + border-radius: var(--radius-full); + background: rgba(0, 0, 0, 0.5); + color: #fff; + font-size: 0.7rem; + font-variant-numeric: tabular-nums; +} + +/* Image + video-file thumbnails */ +.media-image-btn, +.media-video-frame { + display: block; + position: relative; + padding: 0; + border: none; + background: none; + line-height: 0; + cursor: pointer; + border-radius: var(--radius-md); +} + +.media-video-frame .media-video { + pointer-events: none; +} + +.media-play-overlay { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: grid; + place-items: center; + width: 54px; + height: 54px; + border-radius: var(--radius-full); + background: rgba(0, 0, 0, 0.5); + color: #fff; + backdrop-filter: blur(2px); +} + +/* Lightbox viewer */ +.viewer { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + background: rgba(8, 9, 13, 0.86); + backdrop-filter: blur(16px); + animation: viewer-in var(--dur) var(--ease); +} + +@keyframes viewer-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.viewer-toolbar { + position: absolute; + top: 0; + left: 0; + right: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.9rem 1.1rem; + color: #fff; +} + +.viewer-name { + min-width: 0; + font-size: var(--text-sm); + opacity: 0.85; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.viewer-actions { + display: flex; + gap: 0.35rem; + flex: 0 0 auto; +} + +.viewer-btn { + color: #fff; +} + +.viewer-btn:hover { + background: rgba(255, 255, 255, 0.16); + color: #fff; +} + +.viewer-stage { + max-width: 92vw; + max-height: 88vh; + display: flex; + align-items: center; + justify-content: center; +} + +.viewer-img { + max-width: 92vw; + max-height: 88vh; + object-fit: contain; + border-radius: var(--radius-sm); + user-select: none; + touch-action: none; + will-change: transform; +} + +.viewer-video { + max-width: 92vw; + max-height: 88vh; + border-radius: var(--radius-md); +} + +/* Reduced-motion: drop the non-essential animations. */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } +} + +/* Bare media (round video notes) — no bubble chrome. */ +.bubble.bubble-bare { + background: transparent; + box-shadow: none; + padding: 0; + border-radius: 0; +} + +.bubble.bubble-bare .bubble-meta { + color: var(--color-textFaint); + padding: 0; + margin-top: 0.3rem; +} + +/* ============================================================================= + Notification toasts + ========================================================================== */ + .toast-stack { position: fixed; top: 1rem; right: 1rem; + z-index: 1100; display: flex; flex-direction: column; - gap: 0.5rem; - z-index: 1000; + gap: 0.6rem; max-width: min(360px, calc(100vw - 2rem)); } .toast { display: flex; flex-direction: column; - gap: 0.15rem; + gap: 0.2rem; text-align: left; padding: 0.75rem 1rem; - border-radius: 12px; + border-radius: var(--radius-md); border: 1px solid var(--color-border); - background: var(--color-surface); + background: var(--color-surfacePanel); color: var(--color-text); - box-shadow: 0 6px 20px rgb(0 0 0 / 18%); + box-shadow: var(--shadow-md); cursor: pointer; - animation: toast-in 0.18s ease; + animation: toast-in var(--dur) var(--ease-out); + transition: transform var(--dur-fast) var(--ease); +} + +.toast:hover { + transform: translateY(-1px); } .toast-title { - font-size: 0.9rem; + font-size: var(--text-sm); + font-weight: 600; } .toast-body { - font-size: 0.85rem; + font-size: var(--text-sm); color: var(--color-textMuted); overflow: hidden; text-overflow: ellipsis; @@ -603,7 +1776,7 @@ body { @keyframes toast-in { from { opacity: 0; - transform: translateY(-8px); + transform: translateY(-0.5rem); } to { opacity: 1; diff --git a/packages/web/src/features/auth/ui/AuthForm.tsx b/packages/web/src/features/auth/ui/AuthForm.tsx index b25abc0..170eb00 100644 --- a/packages/web/src/features/auth/ui/AuthForm.tsx +++ b/packages/web/src/features/auth/ui/AuthForm.tsx @@ -32,56 +32,75 @@ export const AuthForm = (): ReactElement => { }; return ( -
-

Altricade

-

{mode === 'login' ? 'Log in' : 'Create an account'}

-
{ - void submit(event); - }} - > - { - setUsername(event.target.value); - }} - /> - {mode === 'register' ? ( - { - setDisplayName(event.target.value); +
+
+
+ +

Altricade

+

+ {mode === 'login' ? 'Welcome back.' : 'Create your account.'} +

+
+ void submit(event)}> + + {mode === 'register' ? ( + + ) : null} + + {error !== null ?

{error}

: null} + + +

+ {mode === 'login' ? "Don't have an account?" : 'Already have an account?'}{' '} + - - + > + {mode === 'login' ? 'Sign up' : 'Log in'} + +

+
); }; diff --git a/packages/web/src/features/contacts/ui/ContactsPanel.tsx b/packages/web/src/features/contacts/ui/ContactsPanel.tsx index c34eaa6..cfe7f7a 100644 --- a/packages/web/src/features/contacts/ui/ContactsPanel.tsx +++ b/packages/web/src/features/contacts/ui/ContactsPanel.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import type { ReactElement, SyntheticEvent } from 'react'; import { ApiError } from '@altricade/core/api'; +import { PlusIcon, TrashIcon } from '../../../shared/ui'; import { useContacts } from '../model'; interface Props { @@ -28,48 +29,61 @@ export const ContactsPanel = ({ onStartDirect }: Props): ReactElement => { }; return ( -
-

Contacts

-
{ - void submit(event); - }} - > +
+

Contacts

+ void submit(event)}> { setName(event.target.value); }} /> - + -
    - {contacts.map((contact) => ( -
  • - - -
  • - ))} -
- {error !== null ?

{error}

: null} + {error !== null ?

{error}

: null} + {contacts.length === 0 ? ( +

No contacts yet

+ ) : ( +
    + {contacts.map((contact) => ( +
  • + + +
  • + ))} +
+ )}
); }; diff --git a/packages/web/src/features/conversations/ui/ConversationSidebar.tsx b/packages/web/src/features/conversations/ui/ConversationSidebar.tsx index 1f3feb5..2cf88d5 100644 --- a/packages/web/src/features/conversations/ui/ConversationSidebar.tsx +++ b/packages/web/src/features/conversations/ui/ConversationSidebar.tsx @@ -1,8 +1,9 @@ import { useState } from 'react'; -import type { ReactElement, SyntheticEvent } from 'react'; +import type { ReactElement } from 'react'; import type { Conversation, PublicUser } from '@altricade/core'; -import { searchUsers, ApiError } from '@altricade/core/api'; +import { searchUsers } from '@altricade/core/api'; import { apiConfig } from '../../../shared/api'; +import { SearchIcon, UsersIcon, CloseIcon } from '../../../shared/ui'; interface Props { conversations: Conversation[]; @@ -10,14 +11,55 @@ interface Props { onlineMap: Record; onSelect: (conversation: Conversation) => void; onStartDirect: (username: string) => Promise; - onCreateGroup: (title: string, members: string[]) => Promise; } -const label = (conversation: Conversation): string => { +const title = (conversation: Conversation): string => { if (conversation.type === 'group') { return conversation.title ?? 'Group'; } - return conversation.peer === null ? 'Direct' : `@${conversation.peer.username}`; + return conversation.peer === null ? 'Direct' : conversation.peer.displayName; +}; + +const subtitle = (conversation: Conversation): string => { + if (conversation.type === 'group') { + return 'Group'; + } + return conversation.peer === null ? '' : `@${conversation.peer.username}`; +}; + +const initial = (conversation: Conversation): string => { + const source = + conversation.type === 'group' + ? (conversation.title ?? 'G') + : (conversation.peer?.displayName ?? conversation.peer?.username ?? '?'); + return source.charAt(0); +}; + +// Time as HH:MM today, weekday this week, else DD.MM.YY. +const formatTime = (iso: string): string => { + const then = new Date(iso); + const now = new Date(); + const sameDay = then.toDateString() === now.toDateString(); + if (sameDay) { + return then.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + const dayMs = 86_400_000; + if (now.getTime() - then.getTime() < 6 * dayMs) { + return then.toLocaleDateString([], { weekday: 'short' }); + } + return then.toLocaleDateString([], { day: '2-digit', month: '2-digit', year: '2-digit' }); +}; + +const Avatar = ({ conversation }: { conversation: Conversation }): ReactElement => { + const peer = conversation.peer; + if (peer !== null && peer.avatarUrl !== null) { + return ; + } + return ( + + {conversation.type === 'group' ? : initial(conversation)} + + ); }; export const ConversationSidebar = ({ @@ -26,13 +68,9 @@ export const ConversationSidebar = ({ onlineMap, onSelect, onStartDirect, - onCreateGroup, }: Props): ReactElement => { const [query, setQuery] = useState(''); const [results, setResults] = useState([]); - const [groupTitle, setGroupTitle] = useState(''); - const [groupMembers, setGroupMembers] = useState(''); - const [error, setError] = useState(null); const runSearch = async (value: string): Promise => { setQuery(value); @@ -48,113 +86,117 @@ export const ConversationSidebar = ({ }; const start = async (username: string): Promise => { - setError(null); - try { - await onStartDirect(username); - setQuery(''); - setResults([]); - } catch (caught) { - setError(caught instanceof ApiError ? caught.message : 'Could not start chat'); - } + await onStartDirect(username); + setQuery(''); + setResults([]); }; - const submitGroup = async (event: SyntheticEvent): Promise => { - event.preventDefault(); - const title = groupTitle.trim(); - if (title === '') { - return; - } - const members = groupMembers - .split(',') - .map((name) => name.trim()) - .filter((name) => name.length > 0); - setError(null); - try { - await onCreateGroup(title, members); - setGroupTitle(''); - setGroupMembers(''); - } catch (caught) { - setError(caught instanceof ApiError ? caught.message : 'Could not create group'); - } - }; + const searching = query.trim().length > 0; return ( - +
+ {searching ? ( +
+

People

+ {results.length === 0 ? ( +

No matches

+ ) : ( + results.map((user) => ( + + )) + )} +
+ ) : ( +
+ {conversations.length === 0 ? ( +

No conversations yet

+ ) : ( + conversations.map((conversation) => { + const online = + conversation.peer !== null && onlineMap[conversation.peer.id] === true; + const active = conversation.id === currentId; + return ( + + ); + }) + )} +
+ )} +
+ ); }; diff --git a/packages/web/src/features/messaging/ui/ChatView.tsx b/packages/web/src/features/messaging/ui/ChatView.tsx index d55e7ba..62a73a0 100644 --- a/packages/web/src/features/messaging/ui/ChatView.tsx +++ b/packages/web/src/features/messaging/ui/ChatView.tsx @@ -1,8 +1,6 @@ -import { useEffect, useRef, useState } from 'react'; -import type { ReactElement, SyntheticEvent } from 'react'; +import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import type { KeyboardEvent, ReactElement, SyntheticEvent } from 'react'; import type { Conversation, Message, PublicUser } from '@altricade/core'; -import { getMediaUrl } from '@altricade/core/api'; -import { apiConfig } from '../../../shared/api'; import { PaperclipIcon, MicIcon, @@ -10,10 +8,19 @@ import { StopIcon, SendIcon, CloseIcon, - FileIcon, + CheckIcon, + DoubleCheckIcon, + EditIcon, + TrashIcon, + UsersIcon, } from '../../../shared/ui'; import { useConversationMessages } from '../model'; import { useRecorder } from '../recorder'; +import { MediaMessage, isRoundVideo } from './MediaMessage'; +import { MediaViewerProvider } from './MediaViewer'; + +const QUICK_REACTIONS = ['👍', '❤️', '😂', '🎉']; +const GROUP_WINDOW_MS = 5 * 60 * 1000; const formatElapsed = (ms: number): string => { const total = Math.floor(ms / 1000); @@ -22,73 +29,94 @@ const formatElapsed = (ms: number): string => { return `${String(minutes)}:${seconds < 10 ? '0' : ''}${String(seconds)}`; }; +const clock = (iso: string): string => + new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + +const dayKey = (iso: string): string => new Date(iso).toDateString(); + +const dayLabel = (iso: string): string => { + const then = new Date(iso); + const now = new Date(); + if (then.toDateString() === now.toDateString()) { + return 'Today'; + } + const yesterday = new Date(now); + yesterday.setDate(now.getDate() - 1); + if (then.toDateString() === yesterday.toDateString()) { + return 'Yesterday'; + } + return then.toLocaleDateString([], { day: 'numeric', month: 'long' }); +}; + +// Two messages belong to the same visual group when they share a sender and +// day and land within the grouping window (order-agnostic). +const inSameGroup = (base: Message, other: Message | undefined): boolean => { + if (other === undefined) { + return false; + } + return ( + other.senderId === base.senderId && + dayKey(other.createdAt) === dayKey(base.createdAt) && + Math.abs(new Date(base.createdAt).getTime() - new Date(other.createdAt).getTime()) < + GROUP_WINDOW_MS + ); +}; + interface Props { conversation: Conversation; me: PublicUser; + onlineMap: Record; } -const QUICK_REACTIONS = ['👍', '❤️', '😂', '🎉']; - const headerTitle = (conversation: Conversation): string => { if (conversation.type === 'group') { return conversation.title ?? 'Group'; } - return conversation.peer === null ? 'Direct' : `@${conversation.peer.username}`; + if (conversation.peer === null) { + return 'Direct'; + } + return conversation.peer.displayName; }; -const MediaView = ({ - conversationId, - message, +const ChatHeader = ({ + conversation, + online, + typing, }: { - conversationId: string; - message: Message; -}): ReactElement | null => { - const [url, setUrl] = useState(null); - const media = message.media; - - useEffect(() => { - if (media === null) { - return undefined; - } - let cancelled = false; - getMediaUrl(apiConfig, conversationId, message.id) - .then((resolved) => { - if (!cancelled) { - setUrl(resolved); - } - }) - .catch(() => { - /* media may be unavailable */ - }); - return () => { - cancelled = true; - }; - }, [conversationId, message.id, media]); - - if (media === null) { - return null; - } - if (url === null) { - return loading media…; - } - if (media.kind === 'image') { - return {media.name}; - } - if (media.kind === 'video') { - return