Add waiting queue for guest join with sound
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m26s
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m26s
This commit is contained in:
BIN
public/sounds/meeting-started.mp3
Normal file
BIN
public/sounds/meeting-started.mp3
Normal file
Binary file not shown.
@@ -212,6 +212,11 @@
|
||||
"guestModeratorPlaceholder": "Nur wenn Sie Moderator sind",
|
||||
"guestJoinButton": "Meeting beitreten",
|
||||
"guestWaitingMessage": "Das Meeting wurde noch nicht gestartet. Bitte warten Sie, bis der Moderator es startet.",
|
||||
"guestWaitingTitle": "Warte auf Meeting-Start...",
|
||||
"guestWaitingHint": "Du wirst automatisch beigetreten, sobald das Meeting gestartet wird.",
|
||||
"guestCancelWaiting": "Abbrechen",
|
||||
"guestMeetingStartedJoining": "Meeting gestartet! Trete jetzt bei...",
|
||||
"waitingToJoin": "Warten...",
|
||||
"guestAccessDenied": "Zugang nicht möglich",
|
||||
"guestNameRequired": "Name ist erforderlich",
|
||||
"guestJoinFailed": "Beitritt fehlgeschlagen",
|
||||
|
||||
@@ -212,6 +212,11 @@
|
||||
"guestModeratorPlaceholder": "Only if you are a moderator",
|
||||
"guestJoinButton": "Join meeting",
|
||||
"guestWaitingMessage": "The meeting has not started yet. Please wait for the moderator to start it.",
|
||||
"guestWaitingTitle": "Waiting for meeting to start...",
|
||||
"guestWaitingHint": "You will be joined automatically as soon as the meeting starts.",
|
||||
"guestCancelWaiting": "Cancel",
|
||||
"guestMeetingStartedJoining": "Meeting started! Joining now...",
|
||||
"waitingToJoin": "Waiting...",
|
||||
"guestAccessDenied": "Access denied",
|
||||
"guestNameRequired": "Name is required",
|
||||
"guestJoinFailed": "Join failed",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, Link, useSearchParams } from 'react-router-dom';
|
||||
import { Video, User, Lock, Shield, ArrowRight, Loader2, Users, Radio, AlertCircle, FileText } from 'lucide-react';
|
||||
import { Video, User, Lock, Shield, ArrowRight, Loader2, Users, Radio, AlertCircle, FileText, Clock, X } from 'lucide-react';
|
||||
import BrandLogo from '../components/BrandLogo';
|
||||
import api from '../services/api';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -24,6 +24,33 @@ export default function GuestJoin() {
|
||||
const [moderatorCode, setModeratorCode] = useState('');
|
||||
const [status, setStatus] = useState({ running: false });
|
||||
const [recordingConsent, setRecordingConsent] = useState(false);
|
||||
const [waiting, setWaiting] = useState(false);
|
||||
const prevRunningRef = useRef(false);
|
||||
|
||||
const joinMeeting = async () => {
|
||||
setJoining(true);
|
||||
try {
|
||||
const res = await api.post(`/rooms/${uid}/guest-join`, {
|
||||
name: name.trim(),
|
||||
access_code: accessCode || undefined,
|
||||
moderator_code: moderatorCode || undefined,
|
||||
});
|
||||
if (res.data.joinUrl) {
|
||||
window.location.href = res.data.joinUrl;
|
||||
}
|
||||
} catch (err) {
|
||||
const errStatus = err.response?.status;
|
||||
if (errStatus === 403) {
|
||||
toast.error(t('room.guestWrongAccessCode'));
|
||||
setWaiting(false);
|
||||
} else {
|
||||
toast.error(t('room.guestJoinFailed'));
|
||||
setWaiting(false);
|
||||
}
|
||||
} finally {
|
||||
setJoining(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRoom = async () => {
|
||||
@@ -31,6 +58,7 @@ export default function GuestJoin() {
|
||||
const res = await api.get(`/rooms/${uid}/public`);
|
||||
setRoomInfo(res.data.room);
|
||||
setStatus({ running: res.data.running });
|
||||
prevRunningRef.current = res.data.running;
|
||||
} catch (err) {
|
||||
const status = err.response?.status;
|
||||
if (status === 403) {
|
||||
@@ -53,45 +81,36 @@ export default function GuestJoin() {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 10000);
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [uid]);
|
||||
|
||||
// Auto-join when meeting starts while waiting
|
||||
useEffect(() => {
|
||||
if (!prevRunningRef.current && status.running && waiting) {
|
||||
new Audio('/sounds/meeting-started.mp3').play().catch(() => {});
|
||||
toast.success(t('room.guestMeetingStartedJoining'));
|
||||
joinMeeting();
|
||||
}
|
||||
prevRunningRef.current = status.running;
|
||||
}, [status.running]);
|
||||
|
||||
const handleJoin = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) {
|
||||
toast.error(t('room.guestNameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (roomInfo?.allow_recording && !recordingConsent) {
|
||||
toast.error(t('room.guestRecordingConsent'));
|
||||
return;
|
||||
}
|
||||
|
||||
setJoining(true);
|
||||
try {
|
||||
const res = await api.post(`/rooms/${uid}/guest-join`, {
|
||||
name: name.trim(),
|
||||
access_code: accessCode || undefined,
|
||||
moderator_code: moderatorCode || undefined,
|
||||
});
|
||||
if (res.data.joinUrl) {
|
||||
window.location.href = res.data.joinUrl;
|
||||
}
|
||||
} catch (err) {
|
||||
const status = err.response?.status;
|
||||
if (status === 403) {
|
||||
toast.error(t('room.guestWrongAccessCode'));
|
||||
} else if (status === 400) {
|
||||
toast.error(t('room.guestWaitingMessage'));
|
||||
} else {
|
||||
toast.error(t('room.guestJoinFailed'));
|
||||
}
|
||||
} finally {
|
||||
setJoining(false);
|
||||
if (!status.running) {
|
||||
setWaiting(true);
|
||||
return;
|
||||
}
|
||||
await joinMeeting();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
@@ -164,6 +183,33 @@ export default function GuestJoin() {
|
||||
</div>
|
||||
|
||||
{/* Join form */}
|
||||
{waiting ? (
|
||||
<div className="flex flex-col items-center gap-5 py-4">
|
||||
<div className="w-16 h-16 rounded-full flex items-center justify-center bg-th-accent/10">
|
||||
<Clock size={28} className="text-th-accent animate-pulse" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="font-semibold text-th-text mb-1">{t('room.guestWaitingTitle')}</p>
|
||||
<p className="text-sm text-th-text-s">{t('room.guestWaitingHint')}</p>
|
||||
</div>
|
||||
{joining && (
|
||||
<div className="flex items-center gap-2 text-sm text-th-success font-medium">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
{t('room.guestMeetingStartedJoining')}
|
||||
</div>
|
||||
)}
|
||||
{!joining && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWaiting(false)}
|
||||
className="btn-ghost flex items-center gap-2 text-sm"
|
||||
>
|
||||
<X size={16} />
|
||||
{t('room.guestCancelWaiting')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleJoin} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestYourName')} *</label>
|
||||
@@ -236,7 +282,7 @@ export default function GuestJoin() {
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={joining || (!status.running && !roomInfo.anyone_can_start) || (roomInfo.allow_recording && !recordingConsent)}
|
||||
disabled={joining || (roomInfo.allow_recording && !recordingConsent)}
|
||||
className="btn-primary w-full py-3"
|
||||
>
|
||||
{joining ? (
|
||||
@@ -255,6 +301,7 @@ export default function GuestJoin() {
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
{!isLoggedIn && (
|
||||
<div className="mt-6 pt-4 border-t border-th-border text-center">
|
||||
|
||||
@@ -31,6 +31,8 @@ export default function RoomDetail() {
|
||||
const [shareSearch, setShareSearch] = useState('');
|
||||
const [shareResults, setShareResults] = useState([]);
|
||||
const [shareSearching, setShareSearching] = useState(false);
|
||||
const [waitingToJoin, setWaitingToJoin] = useState(false);
|
||||
const prevRunningRef = useRef(false);
|
||||
|
||||
// Federation invite state
|
||||
const [showFedInvite, setShowFedInvite] = useState(false);
|
||||
@@ -87,6 +89,21 @@ export default function RoomDetail() {
|
||||
return () => clearInterval(interval);
|
||||
}, [uid]);
|
||||
|
||||
// Auto-join when meeting starts while waiting
|
||||
useEffect(() => {
|
||||
if (!prevRunningRef.current && status.running && waitingToJoin) {
|
||||
new Audio('/sounds/meeting-started.mp3').play().catch(() => {});
|
||||
toast.success(t('room.meetingStarted'));
|
||||
setWaitingToJoin(false);
|
||||
setActionLoading('join');
|
||||
api.post(`/rooms/${uid}/join`, {})
|
||||
.then(res => { if (res.data.joinUrl) window.open(res.data.joinUrl, '_blank'); })
|
||||
.catch(err => toast.error(err.response?.data?.error || t('room.joinFailed')))
|
||||
.finally(() => setActionLoading(null));
|
||||
}
|
||||
prevRunningRef.current = status.running;
|
||||
}, [status.running]);
|
||||
|
||||
const handleStart = async () => {
|
||||
setActionLoading('start');
|
||||
try {
|
||||
@@ -104,6 +121,12 @@ export default function RoomDetail() {
|
||||
};
|
||||
|
||||
const handleJoin = async () => {
|
||||
if (!status.running) {
|
||||
setWaitingToJoin(true);
|
||||
toast(t('room.guestWaitingTitle'), { icon: '🕐' });
|
||||
return;
|
||||
}
|
||||
setWaitingToJoin(false);
|
||||
setActionLoading('join');
|
||||
try {
|
||||
const data = room.access_code ? { access_code: prompt(t('room.enterAccessCode')) } : {};
|
||||
@@ -351,18 +374,21 @@ export default function RoomDetail() {
|
||||
<span className="hidden sm:inline">{t('federation.inviteRemote')}</span>
|
||||
</button>
|
||||
)}
|
||||
{canManage && !status.running && (
|
||||
{canManage && !status.running && !waitingToJoin && (
|
||||
<button onClick={handleStart} disabled={actionLoading === 'start'} className="btn-primary">
|
||||
{actionLoading === 'start' ? <Loader2 size={16} className="animate-spin" /> : <Play size={16} />}
|
||||
{t('room.start')}
|
||||
</button>
|
||||
)}
|
||||
{status.running && (
|
||||
<button onClick={handleJoin} disabled={actionLoading === 'join'} className="btn-primary">
|
||||
{actionLoading === 'join' ? <Loader2 size={16} className="animate-spin" /> : <ExternalLink size={16} />}
|
||||
{t('room.join')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={waitingToJoin ? () => setWaitingToJoin(false) : handleJoin}
|
||||
disabled={actionLoading === 'join'}
|
||||
className={waitingToJoin ? 'btn-ghost' : 'btn-primary'}
|
||||
title={waitingToJoin ? t('room.guestCancelWaiting') : undefined}
|
||||
>
|
||||
{(actionLoading === 'join' || waitingToJoin) ? <Loader2 size={16} className="animate-spin" /> : <ExternalLink size={16} />}
|
||||
{waitingToJoin ? t('room.waitingToJoin') : t('room.join')}
|
||||
</button>
|
||||
{canManage && status.running && (
|
||||
<button onClick={handleEnd} disabled={actionLoading === 'end'} className="btn-danger">
|
||||
{actionLoading === 'end' ? <Loader2 size={16} className="animate-spin" /> : <Square size={16} />}
|
||||
|
||||
Reference in New Issue
Block a user