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 (
{items.map((toast) => (
{toast.message}
))}
);
}
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 (
{code}
{title}
{message}
На главную
{code === '503' ?
: null}
);
}
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 (
Созвон по ссылке
Создай комнату, отправь ссылку собеседнику: видео, чат и демонстрация экрана в браузере, без приложений.
- Камера и микрофон без установки
- Чат и файлы в комнате
- Короткая ссылка и QR для телефона
- Кнопка «Инста» для быстрого входа
Новая комната
{!result ? (
) : (
)}
);
}
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 ;
}
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 (
{item.name}
{item.isLocal ? 'ты' : ''}
{!item.isLocal ? (
) : null}
{screen ? (
) : null}
{camera ?
:
{initial}
}
{audio.map((entry) => item.isLocal ? null :
)}
);
}
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 ;
if (!call) return ;
return (
{!joined ? (
) : (
<>
Созвон
{call.room_title}
В комнате: {participants.length}
{time}
{participants.map((item) => (
))}
>
)}
);
}
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 (
<>
{route.name === 'home' ? : null}
{route.name === 'call' ? : null}
{route.name === 'error' ? : null}
>
);
}
createRoot(document.getElementById('root')).render();