Files
linkra/frontend/src/main.jsx
2026-05-10 09:50:39 +03:00

798 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React,{useCallback,useEffect,useMemo,useRef,useState}from'react';
import{createRoot}from'react-dom/client';
import{Room,RoomEvent,Track}from'livekit-client';
import QRCode from'qrcode';
import'./styles.css';
const ICONS = {
attachment: '/icons/attachment.png',
cameraOff: '/icons/camera_off.png',
cameraOn: '/icons/camera_on.png',
micOff: '/icons/micro_off.png',
micOn: '/icons/micro_on.png'
};
function apiUrl(path) {
return path;
}
async function readJson(response) {
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.detail || 'Ошибка запроса');
return data;
}
function routeFromLocation() {
const path = window.location.pathname;
if (path === '/') return { name: 'home' };
if (path === '/call/overloaded') return { name: 'error', code: '503' };
const match = path.match(/^\/call\/([^/]+)$/);
if (match) return { name: 'call', roomName: decodeURIComponent(match[1]) };
return { name: 'error', code: '404' };
}
function getInviteToken() {
return new URL(window.location.href).searchParams.get('invite') || '';
}
function randomGuestName() {
const a = Date.now().toString(36).slice(-4);
const b = Math.random().toString(36).slice(2, 6);
return `Гость-${a}${b}`.slice(0, 24);
}
function instaRoomTitle() {
return `Инста-${Math.random().toString(36).slice(2, 8)}`;
}
function Toasts({ items }) {
return (
<div className='toast-stack'>
{items.map((toast) => (
<div className={`toast ${toast.type}`} key={toast.id}>{toast.message}</div>
))}
</div>
);
}
function useToasts() {
const [items, setItems] = useState([]);
const showToast = useCallback((message, type = 'info') => {
const id = crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random());
setItems((current) => current.concat({ id, message, type }));
window.setTimeout(() => {
setItems((current) => current.filter((item) => item.id !== id));
}, 2600);
}, []);
return [items, showToast];
}
function ErrorPage({ code }) {
const copy = {
'403': ['Приглашение недействительно', 'Ссылка приглашения недействительна или была заменена. Запросите новую ссылку у организатора.'],
'410': ['Звонок завершён', 'Эта встреча уже закончена. Попросите организатора прислать новую ссылку.'],
'503': ['Сервис временно недоступен', 'Сервер перегружен. Подождите немного и обновите страницу или зайдите позже.'],
'404': ['Комната не найдена', 'Такой страницы нет или ссылка устарела. Проверьте адрес или создайте новый звонок на главной странице.']
};
const [title, message] = copy[code] || copy['404'];
useEffect(() => {
document.title = `${title} · Linkra`;
}, [title]);
return (
<main className='container error-page'>
<section className='card error-card'>
<p className='error-code'>{code}</p>
<h1>{title}</h1>
<p className='muted'>{message}</p>
<div className='row centered'>
<a className='btn primary' href='/'>На главную</a>
{code === '503' ? <button className='btn secondary' type='button' onClick={() => window.location.reload()}>Обновить страницу</button> : null}
</div>
</section>
</main>
);
}
function HomePage({ showToast }) {
const [roomTitle, setRoomTitle] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [instaLoading, setInstaLoading] = useState(false);
const [result, setResult] = useState(null);
const qrRef = useRef(null);
useEffect(() => {
document.title = 'Созвон по ссылке · Linkra';
}, []);
useEffect(() => {
if (!result || !qrRef.current) return;
const target = result.short_invite_link || result.invite_link;
QRCode.toCanvas(qrRef.current, target, { width: 200, margin: 1 }).catch(() => {});
}, [result]);
const createCall = async ({ quickJoin = false } = {}) => {
const title = quickJoin ? instaRoomTitle() : roomTitle.trim();
const roomPassword = quickJoin ? '' : password.trim();
if (!quickJoin && title.length < 4) {
showToast('Название комнаты должно содержать минимум 4 символа', 'error');
return null;
}
if (!quickJoin && roomPassword.length > 0 && roomPassword.length < 4) {
showToast('Пароль должен содержать минимум 4 символа', 'error');
return null;
}
const setBusy = quickJoin ? setInstaLoading : setLoading;
setBusy(true);
try {
const response = await fetch(apiUrl('/api/calls'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
room_title: title,
password: roomPassword,
quick_join: quickJoin
})
});
return await readJson(response);
} catch (error) {
showToast(error.message || 'Не удалось создать звонок', 'error');
return null;
} finally {
setBusy(false);
}
};
const handleCreate = async () => {
const data = await createCall();
if (!data) return;
setResult(data);
showToast('Ссылка на звонок готова', 'success');
};
const handleInsta = async () => {
const data = await createCall({ quickJoin: true });
if (!data) return;
const target = data.short_invite_link || data.invite_link;
sessionStorage.setItem(`linkra-insta:${data.room_name}`, JSON.stringify({
invite_token: data.invite_token,
display_name: randomGuestName(),
mic_enabled: false,
camera_enabled: false
}));
await navigator.clipboard.writeText(target).catch(() => {});
window.location.href = target;
};
const copyText = async (value) => {
await navigator.clipboard.writeText(value);
showToast('Ссылка скопирована', 'success');
};
return (
<main className='container home-shell'>
<div className='home-grid'>
<header className='home-hero'>
<h1>Созвон по ссылке</h1>
<p className='muted home-hero-lead'>Создай комнату, отправь ссылку собеседнику: видео, чат и демонстрация экрана в браузере, без приложений.</p>
<ul className='home-features'>
<li>Камера и микрофон без установки</li>
<li>Чат и файлы в комнате</li>
<li>Короткая ссылка и QR для телефона</li>
<li>Кнопка «Инста» для быстрого входа</li>
</ul>
</header>
<section className='card home-card'>
<div className='home-card-header'>
<h2>Новая комната</h2>
</div>
{!result ? (
<div className='stack'>
<label className='field'>
<span>Название комнаты</span>
<input value={roomTitle} onChange={(event) => setRoomTitle(event.target.value)} type='text' placeholder='Например, Планёрка команды' maxLength='100' />
</label>
<label className='field'>
<span>Пароль комнаты <small className='muted'>(необязательно)</small></span>
<input value={password} onChange={(event) => setPassword(event.target.value)} type='text' placeholder='Например, team2026' maxLength='100' />
</label>
<div className='home-actions'>
<button className='btn primary' type='button' disabled={loading} onClick={handleCreate}>{loading ? 'Создаю...' : 'Создать звонок'}</button>
<button className='btn secondary' type='button' disabled={instaLoading} onClick={handleInsta}>{instaLoading ? 'Создаём...' : 'Инста'}</button>
</div>
</div>
) : (
<div className='stack'>
<label className='field'>
<span>Ссылка приглашения</span>
<input value={result.short_invite_link || result.invite_link} readOnly onFocus={(event) => event.target.select()} />
</label>
<button className='btn primary' type='button' onClick={() => copyText(result.short_invite_link || result.invite_link)}>Копировать</button>
<details>
<summary>Показать полную ссылку и QR-код</summary>
<label className='field'>
<span>Полная ссылка</span>
<input value={result.invite_link} readOnly onFocus={(event) => event.target.select()} />
</label>
<button className='btn secondary' type='button' onClick={() => copyText(result.invite_link)}>Копировать полную</button>
<div className='qr-box'><canvas ref={qrRef}></canvas></div>
</details>
<p className='muted'>Название комнаты: {result.room_title}</p>
<a className='btn primary link-btn' href={result.short_invite_link || result.invite_link}>Перейти в звонок</a>
</div>
)}
</section>
</div>
</main>
);
}
function MediaTrack({ track, muted = false, className = '' }) {
const ref = useRef(null);
useEffect(() => {
if (!track || !ref.current) return undefined;
track.attach(ref.current);
return () => {
track.detach(ref.current);
};
}, [track]);
return <video ref={ref} autoPlay playsInline muted={muted} className={className} />;
}
function RemoteAudioTrack({ track, volume }) {
const gainRef = useRef(null);
useEffect(() => {
if (!track?.mediaStreamTrack) return undefined;
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
if (!AudioContextClass) return undefined;
const context = new AudioContextClass();
const stream = new MediaStream([track.mediaStreamTrack]);
const source = context.createMediaStreamSource(stream);
const gain = context.createGain();
gain.gain.value = volume;
source.connect(gain);
gain.connect(context.destination);
gainRef.current = gain;
context.resume().catch(() => {});
return () => {
source.disconnect();
gain.disconnect();
gainRef.current = null;
context.close().catch(() => {});
};
}, [track]);
useEffect(() => {
if (gainRef.current) gainRef.current.gain.value = volume;
}, [volume]);
return null;
}
function VideoTile({ item, active, volume, onVolumeChange }) {
const camera = item.tracks.find((entry) => entry.kind === 'video' && entry.source !== Track.Source.ScreenShare);
const screen = item.tracks.find((entry) => entry.kind === 'video' && entry.source === Track.Source.ScreenShare);
const audio = item.tracks.filter((entry) => entry.kind === 'audio');
const initial = (item.name || '?').trim().charAt(0).toUpperCase() || '?';
const volumePercent = Math.round(volume * 100);
return (
<div className={`video-tile${active ? ' speaking' : ''}${screen ? ' has-screen' : ''}`}>
<div className='video-label-row'>
<span>{item.name}</span>
<span className='muted'>{item.isLocal ? 'ты' : ''}</span>
</div>
{!item.isLocal ? (
<label className='participant-volume'>
<span>Громкость: {volumePercent}%</span>
<input
aria-label={`Громкость участника ${item.name}`}
max='2'
min='0'
onChange={(event) => onVolumeChange(item.identity, Number(event.target.value))}
step='0.05'
type='range'
value={volume}
/>
</label>
) : null}
{screen ? (
<div className='screen-slot'>
<MediaTrack track={screen.track} className='screen-share-video' muted={item.isLocal} />
</div>
) : null}
<div className='media-slot'>
{camera ? <MediaTrack track={camera.track} className={item.isLocal ? 'local-video' : ''} muted={item.isLocal} /> : <div className='avatar-placeholder'>{initial}</div>}
{audio.map((entry) => item.isLocal ? null : <RemoteAudioTrack key={entry.id} track={entry.track} volume={volume} />)}
</div>
</div>
);
}
function CallPage({ roomName, showToast }) {
const [call, setCall] = useState(null);
const [loadError, setLoadError] = useState(null);
const [displayName, setDisplayName] = useState('');
const [password, setPassword] = useState('');
const [micEnabled, setMicEnabled] = useState(true);
const [cameraEnabled, setCameraEnabled] = useState(true);
const [joining, setJoining] = useState(false);
const [room, setRoom] = useState(null);
const [participants, setParticipants] = useState([]);
const [participantVolumes, setParticipantVolumes] = useState({});
const [activeSpeakers, setActiveSpeakers] = useState([]);
const [messages, setMessages] = useState([]);
const [chatText, setChatText] = useState('');
const [config, setConfig] = useState({ max_attachment_size_mb: 100, turn_ice_servers: [] });
const [elapsed, setElapsed] = useState(0);
const previewRef = useRef(null);
const previewStreamRef = useRef(null);
const fileRef = useRef(null);
const inviteToken = useMemo(getInviteToken, []);
const joined = !!room;
const refreshParticipants = useCallback((nextRoom) => {
if (!nextRoom) return;
const mapParticipant = (participant, isLocal) => {
const tracks = Array.from(participant.trackPublications.values())
.filter((publication) => publication.track)
.map((publication) => ({
id: publication.trackSid || publication.track.sid || `${participant.identity}-${publication.source}`,
track: publication.track,
source: publication.source,
kind: publication.kind || publication.track.kind
}));
return {
identity: participant.identity,
name: participant.name || participant.identity,
isLocal,
tracks
};
};
setParticipants([
mapParticipant(nextRoom.localParticipant, true),
...Array.from(nextRoom.remoteParticipants.values()).map((participant) => mapParticipant(participant, false))
]);
}, []);
useEffect(() => {
fetch(apiUrl(`/api/calls/${encodeURIComponent(roomName)}`))
.then(readJson)
.then((data) => {
if (!data.is_active) {
setLoadError('410');
return;
}
setCall(data);
document.title = `${data.room_title} · Linkra`;
})
.catch(() => setLoadError('404'));
fetch(apiUrl('/api/config')).then(readJson).then(setConfig).catch(() => {});
}, [roomName]);
useEffect(() => {
const raw = sessionStorage.getItem(`linkra-insta:${roomName}`);
if (!raw) return;
try {
const data = JSON.parse(raw);
if (data.invite_token && data.invite_token !== inviteToken) return;
setDisplayName(data.display_name || randomGuestName());
setMicEnabled(Boolean(data.mic_enabled));
setCameraEnabled(Boolean(data.camera_enabled));
} catch {
return;
}
}, [inviteToken, roomName]);
useEffect(() => {
if (joined || !navigator.mediaDevices) return undefined;
let cancelled = false;
const run = async () => {
if (previewStreamRef.current) {
previewStreamRef.current.getTracks().forEach((track) => track.stop());
previewStreamRef.current = null;
}
if (!cameraEnabled && !micEnabled) {
if (previewRef.current) previewRef.current.srcObject = null;
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: micEnabled, video: cameraEnabled });
if (cancelled) {
stream.getTracks().forEach((track) => track.stop());
return;
}
previewStreamRef.current = stream;
if (previewRef.current) previewRef.current.srcObject = cameraEnabled ? new MediaStream(stream.getVideoTracks()) : null;
} catch {
showToast('Не удалось получить доступ к камере или микрофону', 'error');
}
};
run();
return () => {
cancelled = true;
};
}, [cameraEnabled, joined, micEnabled, showToast]);
useEffect(() => {
if (!joined) return undefined;
const started = Date.now();
const interval = window.setInterval(() => {
setElapsed(Math.floor((Date.now() - started) / 1000));
}, 1000);
return () => window.clearInterval(interval);
}, [joined]);
useEffect(() => () => {
if (previewStreamRef.current) previewStreamRef.current.getTracks().forEach((track) => track.stop());
if (room) room.disconnect();
}, [room]);
const publishData = async (payload) => {
if (!room) return;
const encoded = new TextEncoder().encode(JSON.stringify(payload));
await room.localParticipant.publishData(encoded, { reliable: true });
};
const updateParticipantVolume = useCallback((identity, volume) => {
const safeVolume = Math.min(2, Math.max(0, volume));
setParticipantVolumes((current) => ({
...current,
[identity]: safeVolume
}));
}, []);
const joinCall = async () => {
const name = displayName.trim();
if (!name) {
showToast('Введи имя', 'error');
return;
}
if (!inviteToken) {
setLoadError('403');
return;
}
setJoining(true);
try {
const response = await fetch(apiUrl(`/api/calls/${encodeURIComponent(roomName)}/join`), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
invite_token: inviteToken,
display_name: name,
password,
with_video: cameraEnabled,
with_audio: micEnabled
})
});
const data = await readJson(response);
if (previewStreamRef.current) {
previewStreamRef.current.getTracks().forEach((track) => track.stop());
previewStreamRef.current = null;
}
const nextRoom = new Room({ adaptiveStream: true, dynacast: true });
const refresh = () => refreshParticipants(nextRoom);
nextRoom
.on(RoomEvent.TrackSubscribed, refresh)
.on(RoomEvent.TrackUnsubscribed, refresh)
.on(RoomEvent.LocalTrackPublished, refresh)
.on(RoomEvent.LocalTrackUnpublished, refresh)
.on(RoomEvent.ParticipantConnected, refresh)
.on(RoomEvent.ParticipantDisconnected, refresh)
.on(RoomEvent.ActiveSpeakersChanged, (speakers) => setActiveSpeakers(speakers.map((speaker) => speaker.identity)))
.on(RoomEvent.DataReceived, (payload, participant) => {
const text = new TextDecoder().decode(payload);
const message = JSON.parse(text);
if (message.type === 'chat') {
setMessages((current) => current.concat({
id: crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random()),
author: participant?.name || participant?.identity || 'Участник',
text: message.text,
own: false
}));
}
if (message.type === 'file') {
setMessages((current) => current.concat({
id: crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random()),
author: participant?.name || participant?.identity || 'Участник',
attachment: message.attachment,
own: false
}));
}
})
.on(RoomEvent.Disconnected, () => {
setRoom(null);
setParticipants([]);
});
const connectOptions = {};
if (config.turn_ice_servers?.length) {
connectOptions.rtcConfig = {
iceServers: config.turn_ice_servers
};
}
await nextRoom.connect(data.server_url, data.participant_token, connectOptions);
await nextRoom.localParticipant.setMicrophoneEnabled(micEnabled);
await nextRoom.localParticipant.setCameraEnabled(cameraEnabled);
setRoom(nextRoom);
refreshParticipants(nextRoom);
sessionStorage.removeItem(`linkra-insta:${roomName}`);
showToast('Ты в комнате', 'success');
} catch (error) {
showToast(error.message || 'Не удалось войти в звонок', 'error');
} finally {
setJoining(false);
}
};
const leaveCall = async () => {
if (!room) return;
await room.disconnect();
setRoom(null);
setParticipants([]);
};
const toggleMic = async () => {
if (!room) {
setMicEnabled((value) => !value);
return;
}
const next = !micEnabled;
await room.localParticipant.setMicrophoneEnabled(next);
setMicEnabled(next);
refreshParticipants(room);
};
const toggleCamera = async () => {
if (!room) {
setCameraEnabled((value) => !value);
return;
}
const next = !cameraEnabled;
await room.localParticipant.setCameraEnabled(next);
setCameraEnabled(next);
refreshParticipants(room);
};
const toggleScreen = async () => {
if (!room) return;
const enabled = Array.from(room.localParticipant.trackPublications.values()).some((publication) => publication.source === Track.Source.ScreenShare && publication.track);
await room.localParticipant.setScreenShareEnabled(!enabled);
refreshParticipants(room);
};
const finishCall = async () => {
if (!window.confirm('Завершить звонок для всех участников?')) return;
try {
await fetch(apiUrl(`/api/calls/${encodeURIComponent(roomName)}/finish`), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invite_token: inviteToken })
}).then(readJson);
await leaveCall();
setLoadError('410');
} catch (error) {
showToast(error.message || 'Не удалось завершить звонок', 'error');
}
};
const sendChat = async () => {
const text = chatText.trim();
if (!text || !room) return;
try {
await fetch(apiUrl(`/api/calls/${encodeURIComponent(roomName)}/chat-events`), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
invite_token: inviteToken,
display_name: displayName.trim(),
participant_identity: room.localParticipant.identity,
text
})
}).catch(() => {});
await publishData({ type: 'chat', text });
setMessages((current) => current.concat({
id: crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random()),
author: `${displayName.trim()} (ты)`,
text,
own: true
}));
setChatText('');
} catch (error) {
showToast(error.message || 'Не удалось отправить сообщение', 'error');
}
};
const sendFile = async (file) => {
if (!file || !room) return;
if (file.size > config.max_attachment_size_mb * 1024 * 1024) {
showToast(`Максимальный размер файла — ${config.max_attachment_size_mb} МБ`, 'error');
return;
}
const formData = new FormData();
formData.append('file', file);
formData.append('display_name', displayName.trim() || 'Участник');
formData.append('participant_identity', room.localParticipant.identity);
try {
const attachment = await fetch(apiUrl(`/api/calls/${encodeURIComponent(roomName)}/attachments?invite_token=${encodeURIComponent(inviteToken)}`), {
method: 'POST',
body: formData
}).then(readJson);
await publishData({ type: 'file', attachment });
setMessages((current) => current.concat({
id: crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random()),
author: `${displayName.trim()} (ты)`,
attachment,
own: true
}));
showToast('Файл отправлен', 'success');
} catch (error) {
showToast(error.message || 'Не удалось загрузить файл', 'error');
} finally {
if (fileRef.current) fileRef.current.value = '';
}
};
const copyInvite = async () => {
await navigator.clipboard.writeText(window.location.href);
showToast('Ссылка скопирована', 'success');
};
const time = `${Math.floor(elapsed / 60).toString().padStart(2, '0')}:${(elapsed % 60).toString().padStart(2, '0')}`;
if (loadError) return <ErrorPage code={loadError} />;
if (!call) return <main className='container'><section className='card'>Загрузка...</section></main>;
return (
<main className='container wide'>
{!joined ? (
<section className='card join-card'>
<div className='prejoin-layout'>
<div className='preview-stage'>
{cameraEnabled ? <video ref={previewRef} className='prejoin-preview-video' autoPlay playsInline muted></video> : <div className='preview-placeholder'><div className='avatar-placeholder'>?</div><p>Камера выключена</p></div>}
</div>
<div className='stack'>
<h1>{call.room_title}</h1>
<p className='muted'>Проверь камеру и микрофон перед входом в комнату.</p>
<label className='field'>
<span>Твоё имя</span>
<input value={displayName} onChange={(event) => setDisplayName(event.target.value)} type='text' placeholder='Например, Артём' maxLength='50' />
</label>
{call.has_password ? (
<label className='field'>
<span>Пароль комнаты</span>
<input value={password} onChange={(event) => setPassword(event.target.value)} type='text' placeholder='Введите пароль комнаты' maxLength='100' />
</label>
) : null}
<div className='row'>
<button className={`toggle-btn ${micEnabled ? 'active' : 'inactive'}`} type='button' onClick={toggleMic}>
<img className='control-icon' src={micEnabled ? ICONS.micOn : ICONS.micOff} alt='' />
<span>{micEnabled ? 'Микрофон включён' : 'Микрофон выключен'}</span>
</button>
<button className={`toggle-btn ${cameraEnabled ? 'active' : 'inactive'}`} type='button' onClick={toggleCamera}>
<img className='control-icon' src={cameraEnabled ? ICONS.cameraOn : ICONS.cameraOff} alt='' />
<span>{cameraEnabled ? 'Камера включена' : 'Камера выключена'}</span>
</button>
</div>
<button className='btn primary' type='button' disabled={joining} onClick={joinCall}>{joining ? 'Подключаем...' : 'Войти в звонок'}</button>
</div>
</div>
</section>
) : (
<>
<section className='card meeting-card'>
<div className='meeting-topbar'>
<div>
<div className='meeting-title'>Созвон</div>
<h1 className='meeting-room-name'>{call.room_title}</h1>
<p className='muted'>В комнате: {participants.length}</p>
</div>
<div className='meeting-controls'>
<button className={`icon-btn ${micEnabled ? '' : 'is-off'}`} type='button' onClick={toggleMic} aria-label={micEnabled ? 'Выключить микрофон' : 'Включить микрофон'}>
<img className='control-icon' src={micEnabled ? ICONS.micOn : ICONS.micOff} alt='' />
</button>
<button className={`icon-btn ${cameraEnabled ? '' : 'is-off'}`} type='button' onClick={toggleCamera} aria-label={cameraEnabled ? 'Выключить камеру' : 'Включить камеру'}>
<img className='control-icon' src={cameraEnabled ? ICONS.cameraOn : ICONS.cameraOff} alt='' />
</button>
<button className='icon-btn' type='button' onClick={toggleScreen}>Screen</button>
<button className='btn secondary' type='button' onClick={copyInvite}>Ссылка</button>
<div className='meeting-timer'>{time}</div>
</div>
</div>
<div className='videos'>
{participants.map((item) => (
<VideoTile
active={activeSpeakers.includes(item.identity)}
item={item}
key={item.identity}
onVolumeChange={updateParticipantVolume}
volume={participantVolumes[item.identity] ?? 1}
/>
))}
</div>
</section>
<aside className='card chat-card'>
<h2>Чат комнаты</h2>
<div className='chat-messages'>
{messages.map((message) => (
<div className={`chat-message${message.own ? ' own' : ''}`} key={message.id}>
<div className='chat-author'>{message.author}</div>
{message.attachment ? (
<a className='file-link' href={`${message.attachment.download_url}?invite_token=${encodeURIComponent(inviteToken)}`} target='_blank' rel='noreferrer'>{message.attachment.file_name}</a>
) : <div className='chat-text'>{message.text}</div>}
</div>
))}
</div>
<div className='chat-form'>
<input value={chatText} onChange={(event) => setChatText(event.target.value)} onKeyDown={(event) => event.key === 'Enter' ? sendChat() : null} placeholder='Напиши сообщение...' maxLength='1000' />
<input className='hidden-file-input' ref={fileRef} type='file' onChange={(event) => sendFile(event.target.files?.[0])} />
<button className='icon-btn attachment-btn' type='button' onClick={() => fileRef.current?.click()} aria-label='Прикрепить файл'>
<img className='control-icon' src={ICONS.attachment} alt='' />
</button>
<button className='btn primary' type='button' onClick={sendChat}>Отправить</button>
</div>
</aside>
<div className='call-bottom-actions is-visible'>
<button className='btn secondary' type='button' onClick={leaveCall}>Выйти</button>
<button className='btn danger' type='button' onClick={finishCall}>Завершить</button>
</div>
</>
)}
</main>
);
}
function App() {
const [route, setRoute] = useState(routeFromLocation);
const [toasts, showToast] = useToasts();
useEffect(() => {
const onPop = () => setRoute(routeFromLocation());
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, []);
return (
<>
<Toasts items={toasts} />
{route.name === 'home' ? <HomePage showToast={showToast} /> : null}
{route.name === 'call' ? <CallPage roomName={route.roomName} showToast={showToast} /> : null}
{route.name === 'error' ? <ErrorPage code={route.code} /> : null}
</>
);
}
createRoot(document.getElementById('root')).render(<App />);