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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BFRyKxkKEjfAgpXygzoNiD
This commit is contained in:
parent
47fbf861ee
commit
7ab6b72866
13 changed files with 3240 additions and 901 deletions
|
|
@ -4,7 +4,7 @@ import type { Conversation, Message, MediaRef, PublicUser, User } from '@altrica
|
||||||
import { heartbeat, getAvatarUploadUrl, uploadToUrl, setAvatar } from '@altricade/core/api';
|
import { heartbeat, getAvatarUploadUrl, uploadToUrl, setAvatar } from '@altricade/core/api';
|
||||||
import { SessionProvider, useSession } from '../entities/session';
|
import { SessionProvider, useSession } from '../entities/session';
|
||||||
import { AuthForm } from '../features/auth';
|
import { AuthForm } from '../features/auth';
|
||||||
import { RealtimeProvider, useRealtime } from '../features/realtime';
|
import { RealtimeProvider } from '../features/realtime';
|
||||||
import { useConversations, ConversationSidebar } from '../features/conversations';
|
import { useConversations, ConversationSidebar } from '../features/conversations';
|
||||||
import { ContactsPanel } from '../features/contacts';
|
import { ContactsPanel } from '../features/contacts';
|
||||||
import { ChatView } from '../features/messaging';
|
import { ChatView } from '../features/messaging';
|
||||||
|
|
@ -12,8 +12,21 @@ import { NotificationsProvider, useNotifications, unregisterWebPush } from '../f
|
||||||
import { apiConfig } from '../shared/api';
|
import { apiConfig } from '../shared/api';
|
||||||
import { useTheme } from '../shared/theme';
|
import { useTheme } from '../shared/theme';
|
||||||
import type { ThemePreference } 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: <SunIcon size={17} /> },
|
||||||
|
{ value: 'dark', label: 'Dark', icon: <MoonIcon size={17} /> },
|
||||||
|
{ value: 'system', label: 'System', icon: <MonitorIcon size={17} /> },
|
||||||
|
];
|
||||||
|
|
||||||
const mediaLabel = (media: MediaRef | null): string => {
|
const mediaLabel = (media: MediaRef | null): string => {
|
||||||
if (media === null) {
|
if (media === null) {
|
||||||
|
|
@ -34,23 +47,85 @@ const mediaLabel = (media: MediaRef | null): string => {
|
||||||
const ThemeSwitch = (): ReactElement => {
|
const ThemeSwitch = (): ReactElement => {
|
||||||
const { preference, setPreference } = useTheme();
|
const { preference, setPreference } = useTheme();
|
||||||
return (
|
return (
|
||||||
<div className="theme-switch">
|
<div className="theme-switch" role="group" aria-label="Theme">
|
||||||
{PREFERENCES.map((option) => (
|
{PREFERENCES.map((option) => (
|
||||||
<button
|
<button
|
||||||
key={option}
|
key={option.value}
|
||||||
type="button"
|
type="button"
|
||||||
aria-pressed={preference === option}
|
className="theme-option"
|
||||||
|
title={option.label}
|
||||||
|
aria-label={option.label}
|
||||||
|
aria-pressed={preference === option.value}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPreference(option);
|
setPreference(option.value);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{option}
|
{option.icon}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const GroupComposer = ({
|
||||||
|
onCreate,
|
||||||
|
}: {
|
||||||
|
onCreate: (title: string, members: string[]) => Promise<void>;
|
||||||
|
}): ReactElement => {
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [members, setMembers] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const submit = async (event: SyntheticEvent): Promise<void> => {
|
||||||
|
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 (
|
||||||
|
<div className="compose-block">
|
||||||
|
<p className="rail-section-title">New group</p>
|
||||||
|
<form className="stack-form" onSubmit={(event) => void submit(event)}>
|
||||||
|
<input
|
||||||
|
className="field"
|
||||||
|
placeholder="Group name"
|
||||||
|
value={title}
|
||||||
|
onChange={(event) => {
|
||||||
|
setTitle(event.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="field"
|
||||||
|
placeholder="Members (comma-separated usernames)"
|
||||||
|
value={members}
|
||||||
|
onChange={(event) => {
|
||||||
|
setMembers(event.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn-primary btn-sm">
|
||||||
|
<UsersIcon size={17} />
|
||||||
|
Create group
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{error !== null ? <p className="form-error">{error}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): ReactElement => {
|
const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): ReactElement => {
|
||||||
const me: PublicUser = {
|
const me: PublicUser = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
|
|
@ -59,9 +134,9 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re
|
||||||
avatarUrl: user.avatarUrl,
|
avatarUrl: user.avatarUrl,
|
||||||
};
|
};
|
||||||
const { updateUser } = useSession();
|
const { updateUser } = useSession();
|
||||||
const { state } = useRealtime();
|
|
||||||
const { notify, setOpener } = useNotifications();
|
const { notify, setOpener } = useNotifications();
|
||||||
const [current, setCurrent] = useState<Conversation | null>(null);
|
const [current, setCurrent] = useState<Conversation | null>(null);
|
||||||
|
const [composing, setComposing] = useState(false);
|
||||||
const convRef = useRef<Conversation[]>([]);
|
const convRef = useRef<Conversation[]>([]);
|
||||||
|
|
||||||
// Toast (or OS notification when hidden) for messages arriving in other chats.
|
// 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;
|
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.
|
// Let notification taps (in-app toast or SW message) open the right chat.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setOpener((conversationId) => {
|
setOpener((conversationId) => {
|
||||||
const conversation = convRef.current.find((item) => item.id === conversationId);
|
const conversation = convRef.current.find((item) => item.id === conversationId);
|
||||||
if (conversation !== undefined) {
|
if (conversation !== undefined) {
|
||||||
setCurrent(conversation);
|
openConversation(conversation);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [setOpener]);
|
}, [setOpener, openConversation]);
|
||||||
|
|
||||||
// Deep-link: /?conversation=<id> (from a push opened in a fresh tab).
|
// Deep-link: /?conversation=<id> (from a push opened in a fresh tab).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -135,57 +215,89 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const startDirectAndOpen = async (username: string): Promise<void> => {
|
||||||
|
openConversation(await startDirect(username));
|
||||||
|
};
|
||||||
|
|
||||||
|
const createGroupAndOpen = async (title: string, members: string[]): Promise<void> => {
|
||||||
|
openConversation(await createGroupChat(title, members));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="layout">
|
<div className="app-shell">
|
||||||
<header className="topbar">
|
<aside className="rail">
|
||||||
<span className="topbar-user">
|
<div className="rail-header">
|
||||||
<label className="avatar-edit" title="Change avatar">
|
<span className="rail-brand">
|
||||||
{user.avatarUrl !== null ? (
|
<span className="rail-mark" aria-hidden="true">
|
||||||
<img src={user.avatarUrl} alt="avatar" className="avatar" />
|
A
|
||||||
) : (
|
</span>
|
||||||
<span className="avatar avatar-placeholder">{user.username.charAt(0)}</span>
|
<span className="rail-title">Altricade</span>
|
||||||
)}
|
|
||||||
<input type="file" accept="image/*" hidden onChange={onAvatar} />
|
|
||||||
</label>
|
|
||||||
<span>
|
|
||||||
<strong>@{user.username}</strong> · socket: {state}
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
<div className="rail-header-actions">
|
||||||
<span className="topbar-actions">
|
<ThemeSwitch />
|
||||||
<ThemeSwitch />
|
<button
|
||||||
<button type="button" onClick={onLogout}>
|
type="button"
|
||||||
Log out
|
className={composing ? 'icon-btn accent' : 'icon-btn'}
|
||||||
</button>
|
aria-label={composing ? 'Close new chat' : 'New chat'}
|
||||||
</span>
|
aria-pressed={composing}
|
||||||
</header>
|
onClick={() => {
|
||||||
<div className="workspace">
|
setComposing((value) => !value);
|
||||||
<div className="sidebar-column">
|
}}
|
||||||
|
>
|
||||||
|
{composing ? <CloseIcon /> : <PlusIcon />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{composing ? (
|
||||||
|
<div className="rail-scroll compose">
|
||||||
|
<GroupComposer onCreate={createGroupAndOpen} />
|
||||||
|
<ContactsPanel onStartDirect={startDirectAndOpen} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<ConversationSidebar
|
<ConversationSidebar
|
||||||
conversations={conversations}
|
conversations={conversations}
|
||||||
currentId={current?.id ?? null}
|
currentId={current?.id ?? null}
|
||||||
onlineMap={onlineMap}
|
onlineMap={onlineMap}
|
||||||
onSelect={setCurrent}
|
onSelect={openConversation}
|
||||||
onStartDirect={async (username) => {
|
onStartDirect={startDirectAndOpen}
|
||||||
setCurrent(await startDirect(username));
|
|
||||||
}}
|
|
||||||
onCreateGroup={async (title, members) => {
|
|
||||||
setCurrent(await createGroupChat(title, members));
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<ContactsPanel
|
|
||||||
onStartDirect={async (username) => {
|
|
||||||
setCurrent(await startDirect(username));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{current !== null ? (
|
|
||||||
<ChatView conversation={current} me={me} />
|
|
||||||
) : (
|
|
||||||
<section className="chat chat-empty">
|
|
||||||
<p>Search for a user or pick a contact to start chatting.</p>
|
|
||||||
</section>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
<div className="rail-footer">
|
||||||
|
<label className="rail-account" title="Change avatar">
|
||||||
|
{user.avatarUrl !== null ? (
|
||||||
|
<img src={user.avatarUrl} alt="avatar" className="avatar rail-account-avatar" />
|
||||||
|
) : (
|
||||||
|
<span className="avatar rail-account-avatar avatar-placeholder">
|
||||||
|
{user.displayName.charAt(0)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<input type="file" accept="image/*" hidden onChange={onAvatar} />
|
||||||
|
<span className="rail-account-names">
|
||||||
|
<span className="rail-account-name">{user.displayName}</span>
|
||||||
|
<span className="muted rail-account-handle">@{user.username}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<button type="button" className="icon-btn" aria-label="Log out" onClick={onLogout}>
|
||||||
|
<LogOutIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{current !== null ? (
|
||||||
|
<ChatView conversation={current} me={me} onlineMap={onlineMap} />
|
||||||
|
) : (
|
||||||
|
<section className="chat-pane chat-empty">
|
||||||
|
<div className="chat-empty-inner">
|
||||||
|
<span className="chat-empty-mark" aria-hidden="true">
|
||||||
|
A
|
||||||
|
</span>
|
||||||
|
<h2>Select a conversation</h2>
|
||||||
|
<p className="muted">Search for someone or start a new chat to begin messaging.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -195,8 +307,10 @@ const Shell = (): ReactElement => {
|
||||||
|
|
||||||
if (status === 'loading') {
|
if (status === 'loading') {
|
||||||
return (
|
return (
|
||||||
<main className="app">
|
<main className="splash">
|
||||||
<p>Loading…</p>
|
<span className="splash-mark" aria-hidden="true">
|
||||||
|
A
|
||||||
|
</span>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -32,56 +32,75 @@ export const AuthForm = (): ReactElement => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="app">
|
<main className="auth">
|
||||||
<h1>Altricade</h1>
|
<div className="auth-card">
|
||||||
<p>{mode === 'login' ? 'Log in' : 'Create an account'}</p>
|
<div className="auth-brand">
|
||||||
<form
|
<span className="auth-mark" aria-hidden="true">
|
||||||
className="auth-form"
|
A
|
||||||
onSubmit={(event) => {
|
</span>
|
||||||
void submit(event);
|
<h1 className="auth-wordmark">Altricade</h1>
|
||||||
}}
|
<p className="auth-tagline">
|
||||||
>
|
{mode === 'login' ? 'Welcome back.' : 'Create your account.'}
|
||||||
<input
|
</p>
|
||||||
placeholder="username"
|
</div>
|
||||||
autoComplete="username"
|
<form className="auth-form" onSubmit={(event) => void submit(event)}>
|
||||||
value={username}
|
<label className="field-label">
|
||||||
onChange={(event) => {
|
Username
|
||||||
setUsername(event.target.value);
|
<input
|
||||||
}}
|
className="field"
|
||||||
/>
|
placeholder="username"
|
||||||
{mode === 'register' ? (
|
autoComplete="username"
|
||||||
<input
|
value={username}
|
||||||
placeholder="display name"
|
onChange={(event) => {
|
||||||
value={displayName}
|
setUsername(event.target.value);
|
||||||
onChange={(event) => {
|
}}
|
||||||
setDisplayName(event.target.value);
|
/>
|
||||||
|
</label>
|
||||||
|
{mode === 'register' ? (
|
||||||
|
<label className="field-label">
|
||||||
|
Display name
|
||||||
|
<input
|
||||||
|
className="field"
|
||||||
|
placeholder="Your name"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(event) => {
|
||||||
|
setDisplayName(event.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
<label className="field-label">
|
||||||
|
Password
|
||||||
|
<input
|
||||||
|
className="field"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => {
|
||||||
|
setPassword(event.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error !== null ? <p className="form-error">{error}</p> : null}
|
||||||
|
<button type="submit" className="btn-primary" disabled={busy}>
|
||||||
|
{busy ? 'Please wait…' : mode === 'login' ? 'Log in' : 'Create account'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="auth-switch">
|
||||||
|
{mode === 'login' ? "Don't have an account?" : 'Already have an account?'}{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="link"
|
||||||
|
onClick={() => {
|
||||||
|
setMode(mode === 'login' ? 'register' : 'login');
|
||||||
|
setError(null);
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
) : null}
|
{mode === 'login' ? 'Sign up' : 'Log in'}
|
||||||
<input
|
</button>
|
||||||
type="password"
|
</p>
|
||||||
placeholder="password"
|
</div>
|
||||||
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
|
|
||||||
value={password}
|
|
||||||
onChange={(event) => {
|
|
||||||
setPassword(event.target.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{error !== null ? <p className="auth-error">{error}</p> : null}
|
|
||||||
<button type="submit" disabled={busy}>
|
|
||||||
{mode === 'login' ? 'Log in' : 'Register'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="auth-toggle"
|
|
||||||
onClick={() => {
|
|
||||||
setMode(mode === 'login' ? 'register' : 'login');
|
|
||||||
setError(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{mode === 'login' ? 'Need an account? Register' : 'Have an account? Log in'}
|
|
||||||
</button>
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { ReactElement, SyntheticEvent } from 'react';
|
import type { ReactElement, SyntheticEvent } from 'react';
|
||||||
import { ApiError } from '@altricade/core/api';
|
import { ApiError } from '@altricade/core/api';
|
||||||
|
import { PlusIcon, TrashIcon } from '../../../shared/ui';
|
||||||
import { useContacts } from '../model';
|
import { useContacts } from '../model';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -28,48 +29,61 @@ export const ContactsPanel = ({ onStartDirect }: Props): ReactElement => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="contacts">
|
<div className="compose-block">
|
||||||
<h3 className="sidebar-heading">Contacts</h3>
|
<p className="rail-section-title">Contacts</p>
|
||||||
<form
|
<form className="inline-add" onSubmit={(event) => void submit(event)}>
|
||||||
className="sidebar-form"
|
|
||||||
onSubmit={(event) => {
|
|
||||||
void submit(event);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
<input
|
||||||
placeholder="add contact username"
|
className="field"
|
||||||
|
placeholder="Add by username"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setName(event.target.value);
|
setName(event.target.value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button type="submit">Add</button>
|
<button type="submit" className="icon-btn accent" aria-label="Add contact">
|
||||||
|
<PlusIcon size={18} />
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<ul className="contact-list">
|
{error !== null ? <p className="form-error">{error}</p> : null}
|
||||||
{contacts.map((contact) => (
|
{contacts.length === 0 ? (
|
||||||
<li key={contact.userId}>
|
<p className="rail-empty">No contacts yet</p>
|
||||||
<button
|
) : (
|
||||||
type="button"
|
<ul className="contact-list">
|
||||||
onClick={() => {
|
{contacts.map((contact) => (
|
||||||
void onStartDirect(contact.user.username);
|
<li key={contact.userId} className="contact-row">
|
||||||
}}
|
<button
|
||||||
>
|
type="button"
|
||||||
@{contact.user.username}
|
className="contact-open"
|
||||||
</button>
|
onClick={() => {
|
||||||
<button
|
void onStartDirect(contact.user.username);
|
||||||
type="button"
|
}}
|
||||||
className="contact-remove"
|
>
|
||||||
aria-label="remove contact"
|
{contact.user.avatarUrl !== null ? (
|
||||||
onClick={() => {
|
<img src={contact.user.avatarUrl} alt="" className="avatar contact-avatar" />
|
||||||
void remove(contact.userId);
|
) : (
|
||||||
}}
|
<span className="avatar contact-avatar avatar-placeholder">
|
||||||
>
|
{contact.user.displayName.charAt(0)}
|
||||||
✕
|
</span>
|
||||||
</button>
|
)}
|
||||||
</li>
|
<span className="contact-names">
|
||||||
))}
|
<span className="contact-name">{contact.user.displayName}</span>
|
||||||
</ul>
|
<span className="muted contact-handle">@{contact.user.username}</span>
|
||||||
{error !== null ? <p className="auth-error">{error}</p> : null}
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-btn contact-remove"
|
||||||
|
aria-label="Remove contact"
|
||||||
|
onClick={() => {
|
||||||
|
void remove(contact.userId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TrashIcon size={17} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { ReactElement, SyntheticEvent } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import type { Conversation, PublicUser } from '@altricade/core';
|
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 { apiConfig } from '../../../shared/api';
|
||||||
|
import { SearchIcon, UsersIcon, CloseIcon } from '../../../shared/ui';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
conversations: Conversation[];
|
conversations: Conversation[];
|
||||||
|
|
@ -10,14 +11,55 @@ interface Props {
|
||||||
onlineMap: Record<string, boolean>;
|
onlineMap: Record<string, boolean>;
|
||||||
onSelect: (conversation: Conversation) => void;
|
onSelect: (conversation: Conversation) => void;
|
||||||
onStartDirect: (username: string) => Promise<void>;
|
onStartDirect: (username: string) => Promise<void>;
|
||||||
onCreateGroup: (title: string, members: string[]) => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const label = (conversation: Conversation): string => {
|
const title = (conversation: Conversation): string => {
|
||||||
if (conversation.type === 'group') {
|
if (conversation.type === 'group') {
|
||||||
return conversation.title ?? '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 <img src={peer.avatarUrl} alt="" className="avatar conv-avatar" />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="avatar conv-avatar avatar-placeholder">
|
||||||
|
{conversation.type === 'group' ? <UsersIcon size={20} /> : initial(conversation)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ConversationSidebar = ({
|
export const ConversationSidebar = ({
|
||||||
|
|
@ -26,13 +68,9 @@ export const ConversationSidebar = ({
|
||||||
onlineMap,
|
onlineMap,
|
||||||
onSelect,
|
onSelect,
|
||||||
onStartDirect,
|
onStartDirect,
|
||||||
onCreateGroup,
|
|
||||||
}: Props): ReactElement => {
|
}: Props): ReactElement => {
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [results, setResults] = useState<PublicUser[]>([]);
|
const [results, setResults] = useState<PublicUser[]>([]);
|
||||||
const [groupTitle, setGroupTitle] = useState('');
|
|
||||||
const [groupMembers, setGroupMembers] = useState('');
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const runSearch = async (value: string): Promise<void> => {
|
const runSearch = async (value: string): Promise<void> => {
|
||||||
setQuery(value);
|
setQuery(value);
|
||||||
|
|
@ -48,113 +86,117 @@ export const ConversationSidebar = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const start = async (username: string): Promise<void> => {
|
const start = async (username: string): Promise<void> => {
|
||||||
setError(null);
|
await onStartDirect(username);
|
||||||
try {
|
setQuery('');
|
||||||
await onStartDirect(username);
|
setResults([]);
|
||||||
setQuery('');
|
|
||||||
setResults([]);
|
|
||||||
} catch (caught) {
|
|
||||||
setError(caught instanceof ApiError ? caught.message : 'Could not start chat');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitGroup = async (event: SyntheticEvent): Promise<void> => {
|
const searching = query.trim().length > 0;
|
||||||
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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="sidebar">
|
<>
|
||||||
<input
|
<div className="rail-search">
|
||||||
className="search-input"
|
<SearchIcon size={18} className="rail-search-icon" />
|
||||||
placeholder="Search users to chat…"
|
|
||||||
value={query}
|
|
||||||
onChange={(event) => {
|
|
||||||
void runSearch(event.target.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{results.length > 0 ? (
|
|
||||||
<ul className="search-results">
|
|
||||||
{results.map((user) => (
|
|
||||||
<li key={user.id}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
void start(user.username);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{user.displayName} <span className="muted">@{user.username}</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<h3 className="sidebar-heading">Conversations</h3>
|
|
||||||
<ul className="room-list">
|
|
||||||
{conversations.map((conversation) => {
|
|
||||||
const online =
|
|
||||||
conversation.peer !== null && onlineMap[conversation.peer.id] === true;
|
|
||||||
return (
|
|
||||||
<li key={conversation.id}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-pressed={conversation.id === currentId}
|
|
||||||
onClick={() => {
|
|
||||||
onSelect(conversation);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{online ? <span className="online-dot" aria-label="online" /> : null}
|
|
||||||
{label(conversation)}
|
|
||||||
{conversation.unreadCount > 0 ? (
|
|
||||||
<span className="unread-badge">{conversation.unreadCount}</span>
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<form
|
|
||||||
className="sidebar-form group-form"
|
|
||||||
onSubmit={(event) => {
|
|
||||||
void submitGroup(event);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h3 className="sidebar-heading">New group</h3>
|
|
||||||
<input
|
<input
|
||||||
placeholder="group title"
|
className="rail-search-input"
|
||||||
value={groupTitle}
|
placeholder="Search people…"
|
||||||
|
value={query}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setGroupTitle(event.target.value);
|
void runSearch(event.target.value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<input
|
{searching ? (
|
||||||
placeholder="members (comma-separated usernames)"
|
<button
|
||||||
value={groupMembers}
|
type="button"
|
||||||
onChange={(event) => {
|
className="rail-search-clear"
|
||||||
setGroupMembers(event.target.value);
|
aria-label="Clear search"
|
||||||
}}
|
onClick={() => {
|
||||||
/>
|
setQuery('');
|
||||||
<button type="submit">Create group</button>
|
setResults([]);
|
||||||
</form>
|
}}
|
||||||
|
>
|
||||||
|
<CloseIcon size={16} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
{error !== null ? <p className="auth-error">{error}</p> : null}
|
<div className="rail-scroll">
|
||||||
</aside>
|
{searching ? (
|
||||||
|
<div className="rail-section">
|
||||||
|
<p className="rail-section-title">People</p>
|
||||||
|
{results.length === 0 ? (
|
||||||
|
<p className="rail-empty">No matches</p>
|
||||||
|
) : (
|
||||||
|
results.map((user) => (
|
||||||
|
<button
|
||||||
|
key={user.id}
|
||||||
|
type="button"
|
||||||
|
className="conv-row"
|
||||||
|
onClick={() => {
|
||||||
|
void start(user.username);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.avatarUrl !== null ? (
|
||||||
|
<img src={user.avatarUrl} alt="" className="avatar conv-avatar" />
|
||||||
|
) : (
|
||||||
|
<span className="avatar conv-avatar avatar-placeholder">
|
||||||
|
{user.displayName.charAt(0)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="conv-main">
|
||||||
|
<span className="conv-top">
|
||||||
|
<span className="conv-name">{user.displayName}</span>
|
||||||
|
</span>
|
||||||
|
<span className="conv-sub">
|
||||||
|
<span className="conv-preview">@{user.username}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="conv-list">
|
||||||
|
{conversations.length === 0 ? (
|
||||||
|
<p className="rail-empty">No conversations yet</p>
|
||||||
|
) : (
|
||||||
|
conversations.map((conversation) => {
|
||||||
|
const online =
|
||||||
|
conversation.peer !== null && onlineMap[conversation.peer.id] === true;
|
||||||
|
const active = conversation.id === currentId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={conversation.id}
|
||||||
|
type="button"
|
||||||
|
className="conv-row"
|
||||||
|
aria-pressed={active}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(conversation);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="conv-avatar-wrap">
|
||||||
|
<Avatar conversation={conversation} />
|
||||||
|
{online ? <span className="online-dot" aria-label="online" /> : null}
|
||||||
|
</span>
|
||||||
|
<span className="conv-main">
|
||||||
|
<span className="conv-top">
|
||||||
|
<span className="conv-name">{title(conversation)}</span>
|
||||||
|
<span className="conv-time">{formatTime(conversation.lastMessageAt)}</span>
|
||||||
|
</span>
|
||||||
|
<span className="conv-sub">
|
||||||
|
<span className="conv-preview">{subtitle(conversation)}</span>
|
||||||
|
{conversation.unreadCount > 0 ? (
|
||||||
|
<span className="unread-badge">{conversation.unreadCount}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import type { ReactElement, SyntheticEvent } from 'react';
|
import type { KeyboardEvent, ReactElement, SyntheticEvent } from 'react';
|
||||||
import type { Conversation, Message, PublicUser } from '@altricade/core';
|
import type { Conversation, Message, PublicUser } from '@altricade/core';
|
||||||
import { getMediaUrl } from '@altricade/core/api';
|
|
||||||
import { apiConfig } from '../../../shared/api';
|
|
||||||
import {
|
import {
|
||||||
PaperclipIcon,
|
PaperclipIcon,
|
||||||
MicIcon,
|
MicIcon,
|
||||||
|
|
@ -10,10 +8,19 @@ import {
|
||||||
StopIcon,
|
StopIcon,
|
||||||
SendIcon,
|
SendIcon,
|
||||||
CloseIcon,
|
CloseIcon,
|
||||||
FileIcon,
|
CheckIcon,
|
||||||
|
DoubleCheckIcon,
|
||||||
|
EditIcon,
|
||||||
|
TrashIcon,
|
||||||
|
UsersIcon,
|
||||||
} from '../../../shared/ui';
|
} from '../../../shared/ui';
|
||||||
import { useConversationMessages } from '../model';
|
import { useConversationMessages } from '../model';
|
||||||
import { useRecorder } from '../recorder';
|
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 formatElapsed = (ms: number): string => {
|
||||||
const total = Math.floor(ms / 1000);
|
const total = Math.floor(ms / 1000);
|
||||||
|
|
@ -22,73 +29,94 @@ const formatElapsed = (ms: number): string => {
|
||||||
return `${String(minutes)}:${seconds < 10 ? '0' : ''}${String(seconds)}`;
|
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 {
|
interface Props {
|
||||||
conversation: Conversation;
|
conversation: Conversation;
|
||||||
me: PublicUser;
|
me: PublicUser;
|
||||||
|
onlineMap: Record<string, boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const QUICK_REACTIONS = ['👍', '❤️', '😂', '🎉'];
|
|
||||||
|
|
||||||
const headerTitle = (conversation: Conversation): string => {
|
const headerTitle = (conversation: Conversation): string => {
|
||||||
if (conversation.type === 'group') {
|
if (conversation.type === 'group') {
|
||||||
return conversation.title ?? '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 = ({
|
const ChatHeader = ({
|
||||||
conversationId,
|
conversation,
|
||||||
message,
|
online,
|
||||||
|
typing,
|
||||||
}: {
|
}: {
|
||||||
conversationId: string;
|
conversation: Conversation;
|
||||||
message: Message;
|
online: boolean;
|
||||||
}): ReactElement | null => {
|
typing: boolean;
|
||||||
const [url, setUrl] = useState<string | null>(null);
|
}): ReactElement => {
|
||||||
const media = message.media;
|
const peer = conversation.peer;
|
||||||
|
const isGroup = conversation.type === 'group';
|
||||||
useEffect(() => {
|
const subtitle = typing
|
||||||
if (media === null) {
|
? 'typing…'
|
||||||
return undefined;
|
: isGroup
|
||||||
}
|
? 'Group'
|
||||||
let cancelled = false;
|
: online
|
||||||
getMediaUrl(apiConfig, conversationId, message.id)
|
? 'online'
|
||||||
.then((resolved) => {
|
: 'offline';
|
||||||
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 <span className="muted">loading media…</span>;
|
|
||||||
}
|
|
||||||
if (media.kind === 'image') {
|
|
||||||
return <img src={url} alt={media.name} className="media-img" />;
|
|
||||||
}
|
|
||||||
if (media.kind === 'video') {
|
|
||||||
return <video src={url} controls className="media-video" />;
|
|
||||||
}
|
|
||||||
if (media.kind === 'voice') {
|
|
||||||
return <audio src={url} controls />;
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<a href={url} download={media.name} className="media-file">
|
<header className="chat-header">
|
||||||
<FileIcon size={16} />
|
{peer !== null && peer.avatarUrl !== null ? (
|
||||||
<span>{media.name}</span>
|
<img src={peer.avatarUrl} alt="" className="avatar chat-header-avatar" />
|
||||||
</a>
|
) : (
|
||||||
|
<span className="avatar chat-header-avatar avatar-placeholder">
|
||||||
|
{isGroup ? <UsersIcon size={20} /> : headerTitle(conversation).charAt(0)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="chat-header-text">
|
||||||
|
<span className="chat-header-title">{headerTitle(conversation)}</span>
|
||||||
|
<span className={typing ? 'chat-header-sub typing-sub' : 'chat-header-sub'}>
|
||||||
|
{!isGroup && online && !typing ? <span className="online-inline" /> : null}
|
||||||
|
{subtitle}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ChatView = ({ conversation, me }: Props): ReactElement => {
|
export const ChatView = ({ conversation, me, onlineMap }: Props): ReactElement => {
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
loading,
|
loading,
|
||||||
|
|
@ -102,10 +130,16 @@ export const ChatView = ({ conversation, me }: Props): ReactElement => {
|
||||||
notifyTyping,
|
notifyTyping,
|
||||||
} = useConversationMessages(conversation, me);
|
} = useConversationMessages(conversation, me);
|
||||||
const [text, setText] = useState('');
|
const [text, setText] = useState('');
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [draft, setDraft] = useState('');
|
||||||
const recorder = useRecorder((file) => {
|
const recorder = useRecorder((file) => {
|
||||||
void sendMedia(file, '');
|
void sendMedia(file, '');
|
||||||
});
|
});
|
||||||
const previewRef = useRef<HTMLVideoElement>(null);
|
const previewRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const atBottomRef = useRef(true);
|
||||||
|
const isGroup = conversation.type === 'group';
|
||||||
|
const peerOnline = conversation.peer !== null && onlineMap[conversation.peer.id] === true;
|
||||||
|
|
||||||
// Mirror the live camera stream into the in-composer preview while recording.
|
// Mirror the live camera stream into the in-composer preview while recording.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -115,6 +149,27 @@ export const ChatView = ({ conversation, me }: Props): ReactElement => {
|
||||||
}
|
}
|
||||||
}, [recorder.previewStream]);
|
}, [recorder.previewStream]);
|
||||||
|
|
||||||
|
// Jump to the newest message when opening a conversation.
|
||||||
|
useEffect(() => {
|
||||||
|
atBottomRef.current = true;
|
||||||
|
}, [conversation.id]);
|
||||||
|
|
||||||
|
// Keep the scrollback pinned to the newest message — but only when the reader
|
||||||
|
// is already near the bottom, so scrolling up through history isn't yanked.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const node = scrollRef.current;
|
||||||
|
if (node !== null && atBottomRef.current) {
|
||||||
|
node.scrollTop = node.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [messages.length, typingUserIds.length]);
|
||||||
|
|
||||||
|
const onScroll = (): void => {
|
||||||
|
const node = scrollRef.current;
|
||||||
|
if (node !== null) {
|
||||||
|
atBottomRef.current = node.scrollHeight - node.scrollTop - node.clientHeight < 80;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const submit = async (event: SyntheticEvent): Promise<void> => {
|
const submit = async (event: SyntheticEvent): Promise<void> => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
|
|
@ -125,6 +180,13 @@ export const ChatView = ({ conversation, me }: Props): ReactElement => {
|
||||||
await send(trimmed);
|
await send(trimmed);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onComposerKey = (event: KeyboardEvent<HTMLTextAreaElement>): void => {
|
||||||
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
void submit(event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onAttach = (event: SyntheticEvent<HTMLInputElement>): void => {
|
const onAttach = (event: SyntheticEvent<HTMLInputElement>): void => {
|
||||||
const input = event.currentTarget;
|
const input = event.currentTarget;
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
|
|
@ -134,101 +196,242 @@ export const ChatView = ({ conversation, me }: Props): ReactElement => {
|
||||||
input.value = '';
|
input.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const onEdit = (message: Message): void => {
|
const startEdit = (message: Message): void => {
|
||||||
const next = window.prompt('Edit message', message.content);
|
setEditingId(message.id);
|
||||||
if (next !== null && next.trim() !== '') {
|
setDraft(message.content);
|
||||||
void edit(message.id, next.trim());
|
};
|
||||||
|
|
||||||
|
const commitEdit = async (messageId: string): Promise<void> => {
|
||||||
|
const next = draft.trim();
|
||||||
|
setEditingId(null);
|
||||||
|
if (next !== '') {
|
||||||
|
await edit(messageId, next);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const readMark = (message: Message): string => {
|
const tick = (message: Message): ReactElement | null => {
|
||||||
if (conversation.type !== 'direct' || message.senderId !== me.id || message.deletedAt !== null) {
|
if (conversation.type !== 'direct' || message.senderId !== me.id || message.deletedAt !== null) {
|
||||||
return '';
|
return null;
|
||||||
}
|
}
|
||||||
return peerReadSeq >= message.seq ? '✓✓' : '✓';
|
const read = peerReadSeq >= message.seq;
|
||||||
|
return (
|
||||||
|
<span className={read ? 'tick tick-read' : 'tick'}>
|
||||||
|
{read ? <DoubleCheckIcon size={15} /> : <CheckIcon size={15} />}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="chat">
|
<MediaViewerProvider>
|
||||||
<h2 className="chat-title">{headerTitle(conversation)}</h2>
|
<section className="chat-pane">
|
||||||
{loading ? <p className="chat-loading">Loading…</p> : null}
|
<ChatHeader
|
||||||
<ul className="message-list">
|
conversation={conversation}
|
||||||
{messages.map((message) => (
|
online={peerOnline}
|
||||||
<li key={message.id} className={message.senderId === me.id ? 'mine' : ''}>
|
typing={typingUserIds.length > 0}
|
||||||
<div className="msg-row">
|
/>
|
||||||
<span className="msg-author">@{message.sender.username}</span>
|
|
||||||
{message.deletedAt === null && message.media !== null ? (
|
<div className="message-scroll" ref={scrollRef} onScroll={onScroll}>
|
||||||
<MediaView conversationId={conversation.id} message={message} />
|
{loading ? <p className="chat-loading muted">Loading…</p> : null}
|
||||||
) : null}
|
<div className="message-list">
|
||||||
{message.content.length > 0 ? (
|
{messages.map((message, index) => {
|
||||||
<span className="msg-body">
|
const prev = index > 0 ? messages[index - 1] : undefined;
|
||||||
{message.deletedAt !== null ? (
|
const next = index < messages.length - 1 ? messages[index + 1] : undefined;
|
||||||
<em className="muted">message deleted</em>
|
const mine = message.senderId === me.id;
|
||||||
) : (
|
const newDay = prev === undefined || dayKey(prev.createdAt) !== dayKey(message.createdAt);
|
||||||
message.content
|
const groupStart = newDay || !inSameGroup(message, prev);
|
||||||
)}
|
const groupEnd = !inSameGroup(message, next);
|
||||||
</span>
|
const showAvatar = !mine && isGroup;
|
||||||
) : null}
|
const deleted = message.deletedAt !== null;
|
||||||
{message.deletedAt !== null && message.media === null && message.content.length === 0 ? (
|
const editing = editingId === message.id;
|
||||||
<em className="muted">message deleted</em>
|
const media = message.media;
|
||||||
) : null}
|
// Round video notes render without bubble chrome (like Telegram).
|
||||||
{message.editedAt !== null && message.deletedAt === null ? (
|
const bareMedia =
|
||||||
<span className="muted"> (edited)</span>
|
media !== null && !deleted && media.kind === 'video' && isRoundVideo(media.name);
|
||||||
) : null}
|
const hasMedia = media !== null && !deleted && !bareMedia;
|
||||||
<span className="read-mark">{readMark(message)}</span>
|
|
||||||
</div>
|
const lineClass = [
|
||||||
{message.deletedAt === null ? (
|
'msg-line',
|
||||||
<div className="msg-actions">
|
mine ? 'mine' : 'theirs',
|
||||||
{QUICK_REACTIONS.map((emoji) => (
|
groupStart ? 'group-start' : '',
|
||||||
<button
|
groupEnd ? 'group-end' : '',
|
||||||
key={emoji}
|
]
|
||||||
type="button"
|
.filter((token) => token !== '')
|
||||||
className="react-btn"
|
.join(' ');
|
||||||
onClick={() => {
|
|
||||||
void toggleReaction(message, emoji);
|
const bubbleClass = [
|
||||||
}}
|
'bubble',
|
||||||
>
|
mine ? 'bubble-mine' : 'bubble-theirs',
|
||||||
{emoji}
|
groupStart ? 'is-start' : '',
|
||||||
</button>
|
groupEnd ? 'is-end' : '',
|
||||||
))}
|
bareMedia ? 'bubble-bare' : '',
|
||||||
{message.senderId === me.id ? (
|
hasMedia ? 'has-media' : '',
|
||||||
<>
|
]
|
||||||
<button type="button" className="link-btn" onClick={() => { onEdit(message); }}>
|
.filter((token) => token !== '')
|
||||||
edit
|
.join(' ');
|
||||||
</button>
|
|
||||||
<button
|
return (
|
||||||
type="button"
|
<Fragment key={message.id}>
|
||||||
className="link-btn"
|
{newDay ? (
|
||||||
onClick={() => {
|
<div className="day-sep">
|
||||||
void remove(message.id);
|
<span>{dayLabel(message.createdAt)}</span>
|
||||||
}}
|
</div>
|
||||||
>
|
|
||||||
delete
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
<div className={lineClass}>
|
||||||
) : null}
|
{showAvatar ? (
|
||||||
{message.reactions.length > 0 ? (
|
groupEnd ? (
|
||||||
<div className="reactions">
|
message.sender.avatarUrl !== null ? (
|
||||||
{message.reactions.map((r) => (
|
<img src={message.sender.avatarUrl} alt="" className="avatar msg-avatar" />
|
||||||
<button
|
) : (
|
||||||
key={r.emoji}
|
<span className="avatar msg-avatar avatar-placeholder">
|
||||||
type="button"
|
{message.sender.displayName.charAt(0)}
|
||||||
className={r.mine ? 'reaction mine-reaction' : 'reaction'}
|
</span>
|
||||||
onClick={() => {
|
)
|
||||||
void toggleReaction(message, r.emoji);
|
) : (
|
||||||
}}
|
<span className="msg-avatar-spacer" />
|
||||||
>
|
)
|
||||||
{r.emoji} {r.count}
|
) : null}
|
||||||
</button>
|
|
||||||
))}
|
<div className="bubble-wrap">
|
||||||
</div>
|
{showAvatar && groupStart && !deleted ? (
|
||||||
) : null}
|
<span className="msg-sender">{message.sender.displayName}</span>
|
||||||
</li>
|
) : null}
|
||||||
))}
|
|
||||||
</ul>
|
<div className={bubbleClass}>
|
||||||
{typingUserIds.length > 0 ? <p className="typing muted">typing…</p> : null}
|
{editing ? (
|
||||||
|
<div className="bubble-edit">
|
||||||
|
<textarea
|
||||||
|
className="edit-input"
|
||||||
|
value={draft}
|
||||||
|
autoFocus
|
||||||
|
onChange={(event) => {
|
||||||
|
setDraft(event.target.value);
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
void commitEdit(message.id);
|
||||||
|
}
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
setEditingId(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="edit-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-btn edit-cancel"
|
||||||
|
aria-label="Cancel"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseIcon size={17} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-btn accent edit-save"
|
||||||
|
aria-label="Save"
|
||||||
|
onClick={() => {
|
||||||
|
void commitEdit(message.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckIcon size={17} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : deleted ? (
|
||||||
|
<span className="bubble-deleted">Message deleted</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{message.media !== null ? (
|
||||||
|
<MediaMessage
|
||||||
|
conversationId={conversation.id}
|
||||||
|
message={message}
|
||||||
|
mine={mine}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{message.content.length > 0 ? (
|
||||||
|
<span className="bubble-text">{message.content}</span>
|
||||||
|
) : null}
|
||||||
|
<span className="bubble-meta">
|
||||||
|
{message.editedAt !== null ? (
|
||||||
|
<span className="bubble-edited">edited</span>
|
||||||
|
) : null}
|
||||||
|
<span className="bubble-time">{clock(message.createdAt)}</span>
|
||||||
|
{tick(message)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!deleted && !editing ? (
|
||||||
|
<div className="msg-tools">
|
||||||
|
{QUICK_REACTIONS.map((emoji) => (
|
||||||
|
<button
|
||||||
|
key={emoji}
|
||||||
|
type="button"
|
||||||
|
className="tool-react"
|
||||||
|
aria-label={`React ${emoji}`}
|
||||||
|
onClick={() => {
|
||||||
|
void toggleReaction(message, emoji);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{emoji}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{mine ? (
|
||||||
|
<>
|
||||||
|
<span className="tool-divider" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tool-btn"
|
||||||
|
aria-label="Edit"
|
||||||
|
onClick={() => {
|
||||||
|
startEdit(message);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditIcon size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tool-btn tool-danger"
|
||||||
|
aria-label="Delete"
|
||||||
|
onClick={() => {
|
||||||
|
void remove(message.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TrashIcon size={16} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message.reactions.length > 0 ? (
|
||||||
|
<div className="reactions">
|
||||||
|
{message.reactions.map((reaction) => (
|
||||||
|
<button
|
||||||
|
key={reaction.emoji}
|
||||||
|
type="button"
|
||||||
|
className={reaction.mine ? 'reaction is-mine' : 'reaction'}
|
||||||
|
onClick={() => {
|
||||||
|
void toggleReaction(message, reaction.emoji);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="reaction-emoji">{reaction.emoji}</span>
|
||||||
|
<span className="reaction-count">{reaction.count}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{recorder.recording !== null ? (
|
{recorder.recording !== null ? (
|
||||||
<div className="composer recording-bar">
|
<div className="composer recording-bar">
|
||||||
{recorder.recording === 'video' ? (
|
{recorder.recording === 'video' ? (
|
||||||
|
|
@ -244,7 +447,7 @@ export const ChatView = ({ conversation, me }: Props): ReactElement => {
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-btn send"
|
className="icon-btn accent"
|
||||||
title="Stop and send"
|
title="Stop and send"
|
||||||
onClick={recorder.finish}
|
onClick={recorder.finish}
|
||||||
>
|
>
|
||||||
|
|
@ -252,16 +455,22 @@ export const ChatView = ({ conversation, me }: Props): ReactElement => {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form
|
<form className="composer" onSubmit={(event) => void submit(event)}>
|
||||||
className="composer"
|
|
||||||
onSubmit={(event) => {
|
|
||||||
void submit(event);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<label className="icon-btn" title="Attach file">
|
<label className="icon-btn" title="Attach file">
|
||||||
<PaperclipIcon />
|
<PaperclipIcon />
|
||||||
<input type="file" hidden onChange={onAttach} />
|
<input type="file" hidden onChange={onAttach} />
|
||||||
</label>
|
</label>
|
||||||
|
<textarea
|
||||||
|
className="composer-input"
|
||||||
|
rows={1}
|
||||||
|
value={text}
|
||||||
|
placeholder="Message…"
|
||||||
|
onChange={(event) => {
|
||||||
|
setText(event.target.value);
|
||||||
|
notifyTyping();
|
||||||
|
}}
|
||||||
|
onKeyDown={onComposerKey}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-btn"
|
className="icon-btn"
|
||||||
|
|
@ -282,19 +491,17 @@ export const ChatView = ({ conversation, me }: Props): ReactElement => {
|
||||||
>
|
>
|
||||||
<VideoIcon />
|
<VideoIcon />
|
||||||
</button>
|
</button>
|
||||||
<input
|
<button
|
||||||
value={text}
|
type="submit"
|
||||||
placeholder="Write a message…"
|
className="icon-btn accent composer-send"
|
||||||
onChange={(event) => {
|
title="Send"
|
||||||
setText(event.target.value);
|
disabled={text.trim() === ''}
|
||||||
notifyTyping();
|
>
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button type="submit" className="icon-btn send" title="Send">
|
|
||||||
<SendIcon />
|
<SendIcon />
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
</MediaViewerProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
114
packages/web/src/features/messaging/ui/MediaMessage.tsx
Normal file
114
packages/web/src/features/messaging/ui/MediaMessage.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import type { Message } from '@altricade/core';
|
||||||
|
import { getMediaUrl } from '@altricade/core/api';
|
||||||
|
import { apiConfig } from '../../../shared/api';
|
||||||
|
import { FileIcon, DownloadIcon, PlayIcon } from '../../../shared/ui';
|
||||||
|
import { VoiceMessage } from './VoiceMessage';
|
||||||
|
import { VideoMessage } from './VideoMessage';
|
||||||
|
import { useMediaViewer } from './MediaViewer';
|
||||||
|
|
||||||
|
const formatSize = (bytes: number): string => {
|
||||||
|
if (bytes < 1024) return `${String(bytes)} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Recorded video messages carry this fixed name (see features/messaging/recorder.ts)
|
||||||
|
// and render as a circular "video note"; any other video is a regular file player.
|
||||||
|
export const isRoundVideo = (name: string): boolean => name.startsWith('video-message');
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
conversationId: string;
|
||||||
|
message: Message;
|
||||||
|
mine: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MediaMessage = ({ conversationId, message, mine }: Props): ReactElement | null => {
|
||||||
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
|
const viewer = useMediaViewer();
|
||||||
|
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 <span className="media-loading">Loading…</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (media.kind === 'voice') {
|
||||||
|
return (
|
||||||
|
<VoiceMessage url={url} seed={message.id} durationSec={media.durationSec} mine={mine} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (media.kind === 'video') {
|
||||||
|
if (isRoundVideo(media.name)) {
|
||||||
|
return <VideoMessage url={url} durationSec={media.durationSec} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="media-video-frame"
|
||||||
|
onClick={() => {
|
||||||
|
viewer.open({ type: 'video', url, name: media.name });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<video src={url} className="media-video" preload="metadata" muted />
|
||||||
|
<span className="media-play-overlay">
|
||||||
|
<PlayIcon size={24} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (media.kind === 'image') {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="media-image-btn"
|
||||||
|
onClick={() => {
|
||||||
|
viewer.open({ type: 'image', url, name: media.name });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img src={url} alt={media.name} className="media-img" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
download={media.name}
|
||||||
|
className={mine ? 'media-file media-file-mine' : 'media-file'}
|
||||||
|
>
|
||||||
|
<span className="media-file-icon">
|
||||||
|
<FileIcon size={20} />
|
||||||
|
</span>
|
||||||
|
<span className="media-file-meta">
|
||||||
|
<span className="media-file-name">{media.name}</span>
|
||||||
|
<span className="media-file-size">{formatSize(media.size)}</span>
|
||||||
|
</span>
|
||||||
|
<DownloadIcon size={18} className="media-file-dl" />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
173
packages/web/src/features/messaging/ui/MediaViewer.tsx
Normal file
173
packages/web/src/features/messaging/ui/MediaViewer.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { ReactElement, ReactNode } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { CloseIcon, DownloadIcon } from '../../../shared/ui';
|
||||||
|
|
||||||
|
export interface ViewerItem {
|
||||||
|
type: 'image' | 'video';
|
||||||
|
url: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ViewerContextValue {
|
||||||
|
open: (item: ViewerItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ViewerContext = createContext<ViewerContextValue | null>(null);
|
||||||
|
|
||||||
|
export const useMediaViewer = (): ViewerContextValue => {
|
||||||
|
const context = useContext(ViewerContext);
|
||||||
|
if (context === null) {
|
||||||
|
throw new Error('useMediaViewer must be used within a MediaViewerProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Point {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_SCALE = 1;
|
||||||
|
const MAX_SCALE = 4;
|
||||||
|
|
||||||
|
const Overlay = ({ item, onClose }: { item: ViewerItem; onClose: () => void }): ReactElement => {
|
||||||
|
const [scale, setScale] = useState(1);
|
||||||
|
const [offset, setOffset] = useState<Point>({ x: 0, y: 0 });
|
||||||
|
const dragging = useRef<{ startX: number; startY: number; baseX: number; baseY: number } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const reset = useCallback((): void => {
|
||||||
|
setScale(1);
|
||||||
|
setOffset({ x: 0, y: 0 });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (event: KeyboardEvent): void => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKey);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const onWheel = (event: React.WheelEvent): void => {
|
||||||
|
if (item.type !== 'image') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale - event.deltaY * 0.0015));
|
||||||
|
setScale(nextScale);
|
||||||
|
if (nextScale === 1) {
|
||||||
|
setOffset({ x: 0, y: 0 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerDown = (event: React.PointerEvent): void => {
|
||||||
|
if (scale <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
dragging.current = {
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
baseX: offset.x,
|
||||||
|
baseY: offset.y,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerMove = (event: React.PointerEvent): void => {
|
||||||
|
const state = dragging.current;
|
||||||
|
if (state === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOffset({
|
||||||
|
x: state.baseX + (event.clientX - state.startX),
|
||||||
|
y: state.baseY + (event.clientY - state.startY),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerUp = (): void => {
|
||||||
|
dragging.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="viewer" role="dialog" aria-modal="true" onClick={onClose}>
|
||||||
|
<div className="viewer-toolbar" onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
}}>
|
||||||
|
<span className="viewer-name">{item.name}</span>
|
||||||
|
<div className="viewer-actions">
|
||||||
|
<a
|
||||||
|
className="icon-btn viewer-btn"
|
||||||
|
href={item.url}
|
||||||
|
download={item.name}
|
||||||
|
aria-label="Download"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DownloadIcon />
|
||||||
|
</a>
|
||||||
|
<button type="button" className="icon-btn viewer-btn" aria-label="Close" onClick={onClose}>
|
||||||
|
<CloseIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="viewer-stage" onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
}}>
|
||||||
|
{item.type === 'image' ? (
|
||||||
|
<img
|
||||||
|
src={item.url}
|
||||||
|
alt={item.name}
|
||||||
|
className="viewer-img"
|
||||||
|
draggable={false}
|
||||||
|
style={{
|
||||||
|
transform: `translate(${String(offset.x)}px, ${String(offset.y)}px) scale(${String(scale)})`,
|
||||||
|
cursor: scale > 1 ? 'grab' : 'zoom-in',
|
||||||
|
}}
|
||||||
|
onWheel={onWheel}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
onDoubleClick={reset}
|
||||||
|
onClick={() => {
|
||||||
|
if (scale === 1) {
|
||||||
|
setScale(2.2);
|
||||||
|
} else {
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<video src={item.url} className="viewer-video" controls autoPlay />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MediaViewerProvider = ({ children }: { children: ReactNode }): ReactElement => {
|
||||||
|
const [item, setItem] = useState<ViewerItem | null>(null);
|
||||||
|
|
||||||
|
const open = useCallback((next: ViewerItem): void => {
|
||||||
|
setItem(next);
|
||||||
|
}, []);
|
||||||
|
const close = useCallback((): void => {
|
||||||
|
setItem(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo<ViewerContextValue>(() => ({ open }), [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ViewerContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
{item !== null ? createPortal(<Overlay item={item} onClose={close} />, document.body) : null}
|
||||||
|
</ViewerContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
70
packages/web/src/features/messaging/ui/VideoMessage.tsx
Normal file
70
packages/web/src/features/messaging/ui/VideoMessage.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { PlayIcon } from '../../../shared/ui';
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number): string => {
|
||||||
|
if (!Number.isFinite(seconds) || seconds <= 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const whole = Math.floor(seconds);
|
||||||
|
const minutes = Math.floor(whole / 60);
|
||||||
|
const rest = whole % 60;
|
||||||
|
return `${String(minutes)}:${rest < 10 ? '0' : ''}${String(rest)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
url: string;
|
||||||
|
durationSec: number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Circular "video message" — tap to play with sound, tap again to pause.
|
||||||
|
export const VideoMessage = ({ url, durationSec }: Props): ReactElement => {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [remaining, setRemaining] = useState(durationSec ?? 0);
|
||||||
|
|
||||||
|
const toggle = (): void => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (video === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (video.paused) {
|
||||||
|
void video.play();
|
||||||
|
} else {
|
||||||
|
video.pause();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button type="button" className={playing ? 'round-video is-playing' : 'round-video'} onClick={toggle}>
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={url}
|
||||||
|
className="round-video-el"
|
||||||
|
playsInline
|
||||||
|
onPlay={() => {
|
||||||
|
setPlaying(true);
|
||||||
|
}}
|
||||||
|
onPause={() => {
|
||||||
|
setPlaying(false);
|
||||||
|
}}
|
||||||
|
onTimeUpdate={(event) => {
|
||||||
|
const video = event.currentTarget;
|
||||||
|
setRemaining(Math.max(0, video.duration - video.currentTime));
|
||||||
|
}}
|
||||||
|
onEnded={() => {
|
||||||
|
setPlaying(false);
|
||||||
|
setRemaining(durationSec ?? 0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{!playing ? (
|
||||||
|
<span className="round-video-overlay">
|
||||||
|
<PlayIcon size={26} />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{formatDuration(remaining) !== '' ? (
|
||||||
|
<span className="round-video-time">{formatDuration(remaining)}</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
169
packages/web/src/features/messaging/ui/VoiceMessage.tsx
Normal file
169
packages/web/src/features/messaging/ui/VoiceMessage.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { PlayIcon, PauseIcon } from '../../../shared/ui';
|
||||||
|
|
||||||
|
const BARS = 44;
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number): string => {
|
||||||
|
if (!Number.isFinite(seconds) || seconds < 0) {
|
||||||
|
return '0:00';
|
||||||
|
}
|
||||||
|
const whole = Math.floor(seconds);
|
||||||
|
const minutes = Math.floor(whole / 60);
|
||||||
|
const rest = whole % 60;
|
||||||
|
return `${String(minutes)}:${rest < 10 ? '0' : ''}${String(rest)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Real amplitude peaks from the decoded audio, normalized to 0..1.
|
||||||
|
const computePeaks = (buffer: AudioBuffer, bars: number): number[] => {
|
||||||
|
const channel = buffer.getChannelData(0);
|
||||||
|
const blockSize = Math.max(1, Math.floor(channel.length / bars));
|
||||||
|
const peaks: number[] = [];
|
||||||
|
for (let index = 0; index < bars; index += 1) {
|
||||||
|
const start = index * blockSize;
|
||||||
|
let sum = 0;
|
||||||
|
for (let offset = 0; offset < blockSize; offset += 1) {
|
||||||
|
const value = channel[start + offset] ?? 0;
|
||||||
|
sum += value * value;
|
||||||
|
}
|
||||||
|
peaks.push(Math.sqrt(sum / blockSize));
|
||||||
|
}
|
||||||
|
const max = Math.max(...peaks, 0.0001);
|
||||||
|
return peaks.map((peak) => peak / max);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deterministic stand-in waveform when decoding is unavailable (e.g. Safari/opus).
|
||||||
|
const pseudoPeaks = (seed: string, bars: number): number[] => {
|
||||||
|
let hash = 0;
|
||||||
|
for (let index = 0; index < seed.length; index += 1) {
|
||||||
|
hash = (hash * 31 + seed.charCodeAt(index)) >>> 0;
|
||||||
|
}
|
||||||
|
const peaks: number[] = [];
|
||||||
|
for (let index = 0; index < bars; index += 1) {
|
||||||
|
hash = (hash * 1103515245 + 12345) & 0x7fffffff;
|
||||||
|
peaks.push(0.25 + (hash % 1000) / 1000 * 0.7);
|
||||||
|
}
|
||||||
|
return peaks;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
url: string;
|
||||||
|
seed: string;
|
||||||
|
durationSec: number | undefined;
|
||||||
|
mine: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VoiceMessage = ({ url, seed, durationSec, mine }: Props): ReactElement => {
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
const [peaks, setPeaks] = useState<number[]>(() => pseudoPeaks(seed, BARS));
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [current, setCurrent] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(durationSec ?? 0);
|
||||||
|
|
||||||
|
// Decode the clip once to draw a true waveform; fall back silently.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const AudioCtor = window.AudioContext;
|
||||||
|
fetch(url)
|
||||||
|
.then((response) => response.arrayBuffer())
|
||||||
|
.then((raw) => {
|
||||||
|
const ctx = new AudioCtor();
|
||||||
|
return ctx.decodeAudioData(raw).then((buffer) => {
|
||||||
|
void ctx.close();
|
||||||
|
return buffer;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then((buffer) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setPeaks(computePeaks(buffer, BARS));
|
||||||
|
setDuration((prev) => (prev > 0 ? prev : buffer.duration));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* keep the pseudo waveform */
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
const toggle = (): void => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (audio === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (audio.paused) {
|
||||||
|
void audio.play();
|
||||||
|
} else {
|
||||||
|
audio.pause();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const seek = (event: React.MouseEvent<HTMLDivElement>): void => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (audio === null || duration <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
|
const ratio = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width));
|
||||||
|
audio.currentTime = ratio * duration;
|
||||||
|
setCurrent(audio.currentTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
const progress = duration > 0 ? current / duration : 0;
|
||||||
|
const elapsed = playing || current > 0 ? current : duration;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={mine ? 'voice voice-mine' : 'voice'}>
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
src={url}
|
||||||
|
preload="metadata"
|
||||||
|
onPlay={() => {
|
||||||
|
setPlaying(true);
|
||||||
|
}}
|
||||||
|
onPause={() => {
|
||||||
|
setPlaying(false);
|
||||||
|
}}
|
||||||
|
onTimeUpdate={(event) => {
|
||||||
|
setCurrent(event.currentTarget.currentTime);
|
||||||
|
}}
|
||||||
|
onLoadedMetadata={(event) => {
|
||||||
|
const value = event.currentTarget.duration;
|
||||||
|
if (Number.isFinite(value) && value > 0) {
|
||||||
|
setDuration((prev) => (prev > 0 ? prev : value));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onEnded={() => {
|
||||||
|
setPlaying(false);
|
||||||
|
setCurrent(0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="voice-play"
|
||||||
|
aria-label={playing ? 'Pause' : 'Play'}
|
||||||
|
onClick={toggle}
|
||||||
|
>
|
||||||
|
{playing ? <PauseIcon size={18} /> : <PlayIcon size={18} />}
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className="voice-wave"
|
||||||
|
role="presentation"
|
||||||
|
onClick={seek}
|
||||||
|
>
|
||||||
|
{peaks.map((peak, index) => {
|
||||||
|
const played = (index + 0.5) / BARS <= progress;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className={played ? 'wave-bar is-played' : 'wave-bar'}
|
||||||
|
style={{ height: `${String(Math.round(Math.max(0.14, peak) * 100))}%` }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<span className="voice-time">{formatDuration(elapsed)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,33 +1,87 @@
|
||||||
// Design tokens — the single source of truth for colors. No hard-coded colors
|
// Design tokens — the single source of truth for colors. No hard-coded colors
|
||||||
// live in feature/entity/widget code; everything reads these via CSS variables.
|
// live in feature/entity/widget code; everything reads these via CSS variables
|
||||||
|
// (`--color-<token>`), written onto <html> by the ThemeProvider.
|
||||||
|
//
|
||||||
|
// Non-color design primitives (spacing, radius, type scale, shadows, motion)
|
||||||
|
// live as static custom properties in app/index.css, keyed on [data-theme]
|
||||||
|
// where they must differ between light and dark.
|
||||||
|
|
||||||
export type ThemeName = 'light' | 'dark';
|
export type ThemeName = 'light' | 'dark';
|
||||||
|
|
||||||
export interface ThemeColors {
|
export interface ThemeColors {
|
||||||
|
/** App base background. */
|
||||||
background: string;
|
background: string;
|
||||||
|
/** Sidebar / header / composer chrome. */
|
||||||
|
surfacePanel: string;
|
||||||
|
/** Incoming bubbles, chips, inputs. */
|
||||||
surface: string;
|
surface: string;
|
||||||
|
/** Hovered rows / controls. */
|
||||||
|
surfaceHover: string;
|
||||||
|
/** The chat scrollback backdrop, one step off the base. */
|
||||||
|
chatBackdrop: string;
|
||||||
text: string;
|
text: string;
|
||||||
textMuted: string;
|
textMuted: string;
|
||||||
|
textFaint: string;
|
||||||
accent: string;
|
accent: string;
|
||||||
|
accentHover: string;
|
||||||
|
/** Tinted accent wash — selected conversation row, own reaction pill. */
|
||||||
|
accentSoft: string;
|
||||||
|
/** Foreground on an accent fill. */
|
||||||
|
onAccent: string;
|
||||||
|
/** Muted foreground on an accent fill (timestamps on own bubbles). */
|
||||||
|
onAccentMuted: string;
|
||||||
|
/** Own (outgoing) message bubble fill. */
|
||||||
|
bubbleOut: string;
|
||||||
|
/** Incoming message bubble fill (elevated off the chat backdrop). */
|
||||||
|
bubbleIn: string;
|
||||||
border: string;
|
border: string;
|
||||||
|
borderStrong: string;
|
||||||
|
online: string;
|
||||||
|
danger: string;
|
||||||
[token: string]: string;
|
[token: string]: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const themes: Record<ThemeName, ThemeColors> = {
|
export const themes: Record<ThemeName, ThemeColors> = {
|
||||||
light: {
|
light: {
|
||||||
background: '#ffffff',
|
background: '#ffffff',
|
||||||
surface: '#f4f5f7',
|
surfacePanel: '#f7f8fa',
|
||||||
text: '#0b0c0f',
|
surface: '#eef0f4',
|
||||||
textMuted: '#5b6472',
|
surfaceHover: '#f0f2f6',
|
||||||
accent: '#2f6fed',
|
chatBackdrop: '#f4f5f8',
|
||||||
border: '#e2e5ea',
|
text: '#0c0d10',
|
||||||
|
textMuted: '#606a7b',
|
||||||
|
textFaint: '#9aa3b2',
|
||||||
|
accent: '#4c6fff',
|
||||||
|
accentHover: '#3a5cf5',
|
||||||
|
accentSoft: '#eaeeff',
|
||||||
|
onAccent: '#ffffff',
|
||||||
|
onAccentMuted: 'rgba(255, 255, 255, 0.72)',
|
||||||
|
bubbleOut: '#4c6fff',
|
||||||
|
bubbleIn: '#ffffff',
|
||||||
|
border: '#e6e8ee',
|
||||||
|
borderStrong: '#d4d8e0',
|
||||||
|
online: '#22c55e',
|
||||||
|
danger: '#ef4444',
|
||||||
},
|
},
|
||||||
dark: {
|
dark: {
|
||||||
background: '#0b0c0f',
|
background: '#0e0f13',
|
||||||
surface: '#15171c',
|
surfacePanel: '#15171d',
|
||||||
text: '#f4f5f7',
|
surface: '#1e222b',
|
||||||
textMuted: '#9aa3b2',
|
surfaceHover: '#242833',
|
||||||
accent: '#5b8bff',
|
chatBackdrop: '#0b0c10',
|
||||||
border: '#242833',
|
text: '#f3f4f7',
|
||||||
|
textMuted: '#98a1b2',
|
||||||
|
textFaint: '#5f6675',
|
||||||
|
accent: '#5b7cff',
|
||||||
|
accentHover: '#6f8bff',
|
||||||
|
accentSoft: '#1b2540',
|
||||||
|
onAccent: '#ffffff',
|
||||||
|
onAccentMuted: 'rgba(255, 255, 255, 0.72)',
|
||||||
|
bubbleOut: '#3b5cf5',
|
||||||
|
bubbleIn: '#22262f',
|
||||||
|
border: '#262a33',
|
||||||
|
borderStrong: '#333844',
|
||||||
|
online: '#22c55e',
|
||||||
|
danger: '#f87171',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement, ReactNode } from 'react';
|
||||||
|
|
||||||
// Inline stroke icons so glyphs render identically on every OS/browser (no
|
// Inline stroke icons so glyphs render identically on every OS/browser (no
|
||||||
// emoji font variance). `currentColor` lets callers theme them via CSS.
|
// emoji font variance). `currentColor` lets callers theme them via CSS.
|
||||||
|
|
@ -7,14 +7,19 @@ interface IconProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const base = (size: number, className: string | undefined, children: ReactElement): ReactElement => (
|
const base = (
|
||||||
|
size: number,
|
||||||
|
className: string | undefined,
|
||||||
|
children: ReactNode,
|
||||||
|
strokeWidth = 2,
|
||||||
|
): ReactElement => (
|
||||||
<svg
|
<svg
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth={2}
|
strokeWidth={strokeWidth}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
|
@ -24,6 +29,20 @@ const base = (size: number, className: string | undefined, children: ReactElemen
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Solid-fill variant helper (play triangle, filled dots, etc.).
|
||||||
|
const solid = (size: number, className: string | undefined, children: ReactNode): ReactElement => (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export const PaperclipIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
export const PaperclipIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
||||||
base(
|
base(
|
||||||
size,
|
size,
|
||||||
|
|
@ -54,17 +73,10 @@ export const VideoIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const StopIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
export const StopIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
||||||
base(size, className, <rect x="6" y="6" width="12" height="12" rx="2" />);
|
solid(size, className, <rect x="6" y="6" width="12" height="12" rx="2" />);
|
||||||
|
|
||||||
export const SendIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
export const SendIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
||||||
base(
|
solid(size, className, <path d="M3.4 20.4l17.45-8.3a1 1 0 0 0 0-1.8L3.4 2A.7.7 0 0 0 2.4 2.7L4.5 11 2.4 21.3a.7.7 0 0 0 1 .1z" />);
|
||||||
size,
|
|
||||||
className,
|
|
||||||
<>
|
|
||||||
<line x1="22" y1="2" x2="11" y2="13" />
|
|
||||||
<path d="M22 2l-7 20-4-9-9-4 20-7z" />
|
|
||||||
</>,
|
|
||||||
);
|
|
||||||
|
|
||||||
export const CloseIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
export const CloseIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
||||||
base(
|
base(
|
||||||
|
|
@ -85,3 +97,163 @@ export const FileIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
||||||
<polyline points="14 2 14 8 20 8" />
|
<polyline points="14 2 14 8 20 8" />
|
||||||
</>,
|
</>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const SearchIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
||||||
|
base(
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
<>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const PlayIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
||||||
|
solid(size, className, <path d="M7 4.5v15a1 1 0 0 0 1.53.85l12-7.5a1 1 0 0 0 0-1.7l-12-7.5A1 1 0 0 0 7 4.5z" />);
|
||||||
|
|
||||||
|
export const PauseIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
||||||
|
solid(
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
<>
|
||||||
|
<rect x="6" y="4.5" width="4" height="15" rx="1.2" />
|
||||||
|
<rect x="14" y="4.5" width="4" height="15" rx="1.2" />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DownloadIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
||||||
|
base(
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
<>
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="7 10 12 15 17 10" />
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3" />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ImageIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
||||||
|
base(
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
<>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||||
|
<path d="M21 15l-5-5L5 21" />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CheckIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
||||||
|
base(size, className, <polyline points="20 6 9 17 4 12" />, 2.4);
|
||||||
|
|
||||||
|
export const DoubleCheckIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
||||||
|
base(
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
<>
|
||||||
|
<path d="M2 12.5l4.5 4.5L16 7" />
|
||||||
|
<path d="M11 16.5l1 1L22 7" />
|
||||||
|
</>,
|
||||||
|
2.4,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const PlusIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
||||||
|
base(
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
<>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ChevronLeftIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
||||||
|
base(size, className, <polyline points="15 18 9 12 15 6" />);
|
||||||
|
|
||||||
|
export const SunIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
||||||
|
base(
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const MoonIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
||||||
|
base(size, className, <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />);
|
||||||
|
|
||||||
|
export const MonitorIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
||||||
|
base(
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
<>
|
||||||
|
<rect x="2" y="3" width="20" height="14" rx="2" />
|
||||||
|
<line x1="8" y1="21" x2="16" y2="21" />
|
||||||
|
<line x1="12" y1="17" x2="12" y2="21" />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const LogOutIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
||||||
|
base(
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
<>
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||||
|
<polyline points="16 17 21 12 16 7" />
|
||||||
|
<line x1="21" y1="12" x2="9" y2="12" />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const TrashIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
||||||
|
base(
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
<>
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const EditIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
||||||
|
base(
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
<>
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||||
|
<path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SmileIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
||||||
|
base(
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
|
||||||
|
<line x1="9" y1="9" x2="9.01" y2="9" />
|
||||||
|
<line x1="15" y1="9" x2="15.01" y2="9" />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const UsersIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
||||||
|
base(
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
<>
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="9" cy="7" r="4" />
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ReplyIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
||||||
|
base(
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
<>
|
||||||
|
<polyline points="9 17 4 12 9 7" />
|
||||||
|
<path d="M20 18v-2a4 4 0 0 0-4-4H4" />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,22 @@ export {
|
||||||
SendIcon,
|
SendIcon,
|
||||||
CloseIcon,
|
CloseIcon,
|
||||||
FileIcon,
|
FileIcon,
|
||||||
|
SearchIcon,
|
||||||
|
PlayIcon,
|
||||||
|
PauseIcon,
|
||||||
|
DownloadIcon,
|
||||||
|
ImageIcon,
|
||||||
|
CheckIcon,
|
||||||
|
DoubleCheckIcon,
|
||||||
|
PlusIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
SunIcon,
|
||||||
|
MoonIcon,
|
||||||
|
MonitorIcon,
|
||||||
|
LogOutIcon,
|
||||||
|
TrashIcon,
|
||||||
|
EditIcon,
|
||||||
|
SmileIcon,
|
||||||
|
UsersIcon,
|
||||||
|
ReplyIcon,
|
||||||
} from './icons';
|
} from './icons';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue