diff --git a/server/reflector/worker/ics_sync.py b/server/reflector/worker/ics_sync.py
index 6e126309..eb220a0c 100644
--- a/server/reflector/worker/ics_sync.py
+++ b/server/reflector/worker/ics_sync.py
@@ -83,9 +83,6 @@ def _should_sync(room) -> bool:
return time_since_sync.total_seconds() >= room.ics_fetch_interval
-MEETING_DEFAULT_DURATION = timedelta(hours=1)
-
-
async def create_upcoming_meetings_for_event(event, create_window, room: Room):
if event.start_time <= create_window:
return
@@ -102,7 +99,9 @@ async def create_upcoming_meetings_for_event(event, create_window, room: Room):
)
try:
- end_date = event.end_time or (event.start_time + MEETING_DEFAULT_DURATION)
+ # 8h rejoin window matches manual on-the-fly meetings; the scheduled
+ # DTEND stays in calendar_events.end_time for reference.
+ end_date = event.start_time + timedelta(hours=8)
client = create_platform_client(room.platform)
diff --git a/server/tests/test_ics_background_tasks.py b/server/tests/test_ics_background_tasks.py
index c2bf5c87..bac80563 100644
--- a/server/tests/test_ics_background_tasks.py
+++ b/server/tests/test_ics_background_tasks.py
@@ -5,11 +5,14 @@ import pytest
from icalendar import Calendar, Event
from reflector.db import get_database
-from reflector.db.calendar_events import calendar_events_controller
+from reflector.db.calendar_events import CalendarEvent, calendar_events_controller
+from reflector.db.meetings import meetings_controller
from reflector.db.rooms import rooms, rooms_controller
from reflector.services.ics_sync import ics_sync_service
+from reflector.video_platforms.models import MeetingData
from reflector.worker.ics_sync import (
_should_sync,
+ create_upcoming_meetings_for_event,
sync_room_ics,
)
@@ -225,6 +228,68 @@ async def test_sync_respects_fetch_interval():
assert mock_delay.call_args[0][0] == room2.id
+@pytest.mark.asyncio
+async def test_create_upcoming_meeting_uses_8h_end_date():
+ # ICS-pre-created meetings get an 8h rejoin window anchored to the
+ # scheduled start, ignoring the calendar event's DTEND. Regression
+ # guard for the "Meeting has ended" bug when participants run over a
+ # short scheduled window.
+ room = await rooms_controller.add(
+ name="ics-8h-room",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=False,
+ ics_url="https://calendar.example.com/ics-8h.ics",
+ ics_enabled=True,
+ )
+
+ now = datetime.now(timezone.utc)
+ event_start = now + timedelta(minutes=1)
+ event_end = event_start + timedelta(minutes=30)
+
+ event = await calendar_events_controller.upsert(
+ CalendarEvent(
+ room_id=room.id,
+ ics_uid="ics-8h-evt",
+ title="Short meeting that runs over",
+ start_time=event_start,
+ end_time=event_end,
+ )
+ )
+
+ create_window = now - timedelta(minutes=6)
+
+ fake_client = MagicMock()
+ fake_client.create_meeting = AsyncMock(
+ return_value=MeetingData(
+ meeting_id="ics-8h-meeting",
+ room_name=room.name,
+ room_url="https://daily.example/ics-8h",
+ host_room_url="https://daily.example/ics-8h",
+ platform=room.platform,
+ extra_data={},
+ )
+ )
+ fake_client.upload_logo = AsyncMock(return_value=True)
+
+ with patch(
+ "reflector.worker.ics_sync.create_platform_client",
+ return_value=fake_client,
+ ):
+ await create_upcoming_meetings_for_event(event, create_window, room)
+
+ meeting = await meetings_controller.get_by_calendar_event(event.id, room)
+ assert meeting is not None
+ assert meeting.start_date == event_start
+ assert meeting.end_date == event_start + timedelta(hours=8)
+
+
@pytest.mark.asyncio
async def test_sync_handles_errors_gracefully():
room = await rooms_controller.add(
diff --git a/www/app/[roomName]/MeetingSelection.tsx b/www/app/[roomName]/MeetingSelection.tsx
index e2356810..7295757d 100644
--- a/www/app/[roomName]/MeetingSelection.tsx
+++ b/www/app/[roomName]/MeetingSelection.tsx
@@ -24,6 +24,7 @@ import {
} from "../lib/apiHooks";
import { useRouter } from "next/navigation";
import { formatDateTime, formatStartedAgo } from "../lib/timeUtils";
+import { formatJoinError } from "../lib/errorUtils";
import MeetingMinimalHeader from "../components/MeetingMinimalHeader";
import { NonEmptyString } from "../lib/utils";
import { MeetingId, assertMeetingId } from "../lib/types";
@@ -188,6 +189,19 @@ export default function MeetingSelection({
flex="1"
gap={{ base: 4, md: 6 }}
>
+ {joinMeetingMutation.isError && (
+
+
+ {formatJoinError(joinMeetingMutation.error)}
+
+
+ )}
{/* Current Ongoing Meetings - BIG DISPLAY */}
{currentMeetings.length > 0 ? (
diff --git a/www/app/[roomName]/components/DailyRoom.tsx b/www/app/[roomName]/components/DailyRoom.tsx
index 5498f0ac..f02f6687 100644
--- a/www/app/[roomName]/components/DailyRoom.tsx
+++ b/www/app/[roomName]/components/DailyRoom.tsx
@@ -28,6 +28,7 @@ import {
useRoomJoinMeeting,
useMeetingStartRecording,
} from "../../lib/apiHooks";
+import { formatJoinError } from "../../lib/errorUtils";
import { omit } from "remeda";
import {
assertExists,
@@ -428,7 +429,7 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) {
if (joinMutation.isError) {
return (
- Failed to join meeting. Please try again.
+ {formatJoinError(joinMutation.error)}
);
}
diff --git a/www/app/[roomName]/components/LiveKitRoom.tsx b/www/app/[roomName]/components/LiveKitRoom.tsx
index e6d419fa..5ab130ce 100644
--- a/www/app/[roomName]/components/LiveKitRoom.tsx
+++ b/www/app/[roomName]/components/LiveKitRoom.tsx
@@ -13,6 +13,7 @@ import {
import type { components } from "../../reflector-api";
import { useAuth } from "../../lib/AuthProvider";
import { useRoomJoinMeeting } from "../../lib/apiHooks";
+import { formatJoinError } from "../../lib/errorUtils";
import { assertMeetingId } from "../../lib/types";
import {
ConsentDialogButton,
@@ -66,7 +67,6 @@ export default function LiveKitRoom({ meeting, room }: LiveKitRoomProps) {
const joinMutation = useRoomJoinMeeting();
const [joinedMeeting, setJoinedMeeting] = useState(null);
- const [connectionError, setConnectionError] = useState(false);
const [userChoices, setUserChoices] = useState(null);
// ── Consent dialog (same hooks as Daily/Whereby) ──────────
@@ -99,7 +99,7 @@ export default function LiveKitRoom({ meeting, room }: LiveKitRoomProps) {
}
return "";
})();
- const isJoining = !!userChoices && !joinedMeeting && !connectionError;
+ const isJoining = !!userChoices && !joinedMeeting && !joinMutation.isError;
// ── Join meeting via backend API after PreJoin submit ─────
useEffect(() => {
@@ -123,7 +123,6 @@ export default function LiveKitRoom({ meeting, room }: LiveKitRoomProps) {
if (!cancelled) setJoinedMeeting(result);
} catch (err) {
console.error("Failed to join LiveKit meeting:", err);
- if (!cancelled) setConnectionError(true);
}
}
@@ -182,10 +181,10 @@ export default function LiveKitRoom({ meeting, room }: LiveKitRoomProps) {
);
}
- if (connectionError) {
+ if (joinMutation.isError) {
return (
- Failed to connect to meeting
+ {formatJoinError(joinMutation.error)}
);
}
diff --git a/www/app/lib/apiHooks.ts b/www/app/lib/apiHooks.ts
index 4ece77e2..c9a1a822 100644
--- a/www/app/lib/apiHooks.ts
+++ b/www/app/lib/apiHooks.ts
@@ -863,16 +863,9 @@ export function useRoomGetMeeting(
}
export function useRoomJoinMeeting() {
- const { setError } = useError();
-
return $api.useMutation(
"post",
"/v1/rooms/{room_name}/meetings/{meeting_id}/join",
- {
- onError: (error) => {
- setError(error as Error, "There was an error joining the meeting");
- },
- },
);
}
diff --git a/www/app/lib/errorUtils.ts b/www/app/lib/errorUtils.ts
index 1512230c..d0cc73fa 100644
--- a/www/app/lib/errorUtils.ts
+++ b/www/app/lib/errorUtils.ts
@@ -1,5 +1,35 @@
import { isNonEmptyArray, NonEmptyArray } from "./array";
+export function getErrorDetail(error: unknown, fallback: string): string {
+ if (!error) return fallback;
+ if (typeof error === "object" && error !== null) {
+ const detail = (error as { detail?: unknown }).detail;
+ if (typeof detail === "string" && detail.length > 0) return detail;
+ const response = (error as { response?: { data?: { detail?: unknown } } })
+ .response;
+ const nestedDetail = response?.data?.detail;
+ if (typeof nestedDetail === "string" && nestedDetail.length > 0)
+ return nestedDetail;
+ }
+ return fallback;
+}
+
+export function formatJoinError(error: unknown): string {
+ const detail = getErrorDetail(error, "");
+ switch (detail) {
+ case "Meeting has ended":
+ return "This meeting has ended. The organizer can start a new one.";
+ case "Meeting is not active":
+ return "This meeting is no longer active. Ask the organizer to start it again.";
+ case "Meeting not found":
+ return "This meeting no longer exists. Check the link or ask the organizer for a new one.";
+ case "Room not found":
+ return "This room doesn't exist.";
+ default:
+ return detail || "We couldn't join the meeting. Please try again.";
+ }
+}
+
export function shouldShowError(error: Error | null | undefined) {
if (
error?.name == "ResponseError" &&