Compare commits

..

1 Commits

Author SHA1 Message Date
lebaudantoine 78ba03a52b 🐛(frontend) restore automatic lower-hand on speaking
Following the re-rendering optimization refactoring, the automatic
lower-hand feature broke: the way `isSpeaking` was read no longer
made sense once we limited how often components in the app
re-render.

Fix the detection so the raised hand is again lowered automatically
when the participant starts speaking, without relying on frequent
re-renders.
2026-09-05 00:29:50 +02:00
15 changed files with 103 additions and 325 deletions
+1
View File
@@ -22,6 +22,7 @@ and this project adheres to
- 🐛(backend) allow any printable ASCII characters in user sub field #1673
- 🐛(frontend) keep the sending resolution picked while the camera is off #1667
- 🐛(frontend) restore automatic lower-hand on speaking
## [1.30.0] - 2026-09-01
@@ -14,4 +14,3 @@ accesslog = "-"
# Using '-' for the error log file makes gunicorn log errors to stderr
errorlog = "-"
loglevel = "info"
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(M)s'
+11 -105
View File
@@ -79,14 +79,6 @@ class LobbyService:
Handles participant entry requests, status management, and notifications
using cache for state management and LiveKit for real-time updates.
Participant membership per room is tracked in a native Redis SET (the
"room index") so that listing and clearing a room's lobby never scans
the shared keyspace. The per-participant cache entries remain the source
of truth for state: their TTLs implement liveness (a waiter who stops
polling simply expires). The index only records which participant ids
may exist for a room; a stale id costs one cache miss and is pruned
lazily.
"""
@staticmethod
@@ -94,61 +86,6 @@ class LobbyService:
"""Generate cache key for participant(s) data."""
return f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
@staticmethod
def _get_index_key(room_id: UUID) -> str:
"""Raw Redis key of the per-room participant index (a native SET).
Built through django-redis' make_key so it lives under the same
KEY_PREFIX/version namespace as the participant entries.
"""
return cache.client.make_key(f"{settings.LOBBY_KEY_PREFIX}-index_{room_id!s}")
@staticmethod
def _redis(write: bool = True):
"""Raw redis-py client.
SADD/SREM/SMEMBERS are not exposed by the Django cache API; this is
the documented django-redis escape hatch.
"""
return cache.client.get_client(write=write)
def _index_add(self, room_id: UUID, participant_id: str) -> None:
"""Record a participant id in the room index.
Refreshes a backstop TTL on the index so an abandoned room cannot
leak its set beyond the longest participant timeout.
"""
index_key = self._get_index_key(room_id)
pipe = self._redis().pipeline(transaction=False)
pipe.sadd(index_key, participant_id)
pipe.expire(index_key, settings.LOBBY_ACCEPTED_TIMEOUT)
pipe.execute()
def _index_members(self, room_id: UUID) -> List[str]:
"""All participant ids currently indexed for the room."""
members = self._redis(write=False).smembers(self._get_index_key(room_id))
return [
member.decode() if isinstance(member, bytes) else member
for member in members
]
def _index_touch(self, room_id: UUID) -> None:
"""Re-arm the room index backstop TTL.
Called whenever a participant entry is written or refreshed so the
index always outlives every entry it references — including a lone
waiter whose rolling WAITING TTL would otherwise outlast the
backstop set at enter() time.
"""
self._redis().expire(
self._get_index_key(room_id), settings.LOBBY_ACCEPTED_TIMEOUT
)
def _index_remove(self, room_id: UUID, *participant_ids: str) -> None:
"""Drop participant ids from the room index."""
if participant_ids:
self._redis().srem(self._get_index_key(room_id), *participant_ids)
@staticmethod
def _get_or_create_participant_id(request) -> str:
"""Extract unique participant identifier from the request."""
@@ -272,16 +209,14 @@ class LobbyService:
cache.touch(
self._get_cache_key(room_id, participant_id), settings.LOBBY_WAITING_TIMEOUT
)
self._index_touch(room_id)
def enter(
self, room_id: UUID, participant_id: str, username: str
) -> LobbyParticipant:
"""Add participant to waiting lobby.
Create a new participant entry in waiting status, index the
participant id for the room, and notify room participants of the
new entry request.
Create a new participant entry in waiting status and notify room
participants of the new entry request.
"""
color = utils.generate_color(participant_id)
@@ -310,7 +245,6 @@ class LobbyService:
participant.to_dict(),
timeout=settings.LOBBY_WAITING_TIMEOUT,
)
self._index_add(room_id, participant_id)
return participant
@@ -333,31 +267,15 @@ class LobbyService:
return None
def list_waiting_participants(self, room_id: UUID) -> List[dict]:
"""List all waiting participants for a room.
"""List all waiting participants for a room."""
Reads the per-room index (O(participants of this room)) instead of
scanning the shared keyspace. Indexed ids whose cache entry has
expired are pruned lazily here: the entry TTL is the liveness
protocol, so a missing entry means the participant is gone.
"""
pattern = self._get_cache_key(room_id, "*")
keys = list(cache.iter_keys(pattern, itersize=utils.CACHE_SCAN_ITERSIZE))
member_ids = self._index_members(room_id)
if not member_ids:
if not keys:
return []
keys_by_id = {
participant_id: self._get_cache_key(room_id, participant_id)
for participant_id in member_ids
}
data = cache.get_many(list(keys_by_id.values()))
dead_ids = [
participant_id
for participant_id, cache_key in keys_by_id.items()
if cache_key not in data
]
self._index_remove(room_id, *dead_ids)
data = cache.get_many(keys)
waiting_participants = []
for cache_key, raw_participant in data.items():
@@ -423,28 +341,16 @@ class LobbyService:
participant.status = status
cache.set(cache_key, participant.to_dict(), timeout=timeout)
self._index_touch(room_id)
def clear_room_cache(self, room_id: UUID) -> None:
"""Clear all participant entries from the cache for a specific room.
"""Clear all participant entries from the cache for a specific room."""
Deletes the indexed participant entries and the index itself with
targeted commands instead of a full-keyspace pattern scan.
"""
member_ids = self._index_members(room_id)
if member_ids:
cache.delete_many(
[
self._get_cache_key(room_id, participant_id)
for participant_id in member_ids
]
)
self._redis().delete(self._get_index_key(room_id))
cache.delete_pattern(
self._get_cache_key(room_id, "*"), itersize=utils.CACHE_SCAN_ITERSIZE
)
def clear_participant_cache(self, room_id: UUID, participant_id: str) -> None:
"""Clear a given participant entry from the cache for a specific room."""
cache_key = self._get_cache_key(room_id, participant_id)
cache.delete(cache_key)
self._index_remove(room_id, participant_id)
+21 -48
View File
@@ -1,11 +1,25 @@
"""Presence cache."""
"""Presence cache.
Redis-backed memo of "this identity is currently connected to this room".
This module is intentionally a *pure cache store* with no dependency on other
services, so that `participants_management` (which talks to LiveKit) can
import it without creating an import cycle. The composition of "check cache,
fall back to LiveKit" lives in
`ParticipantsManagement.check_if_in_meeting_cached`.
Only positive answers are stored: a sticky negative would lock out someone
who joins right after a miss for the whole TTL. The TTL is a safety net in
case an invalidation webhook is lost.
"""
from typing import List
from uuid import UUID
from django.conf import settings
from django.core.cache import cache
from core.utils import CACHE_SCAN_ITERSIZE
class PresenceCache:
"""Store and invalidate (room, identity) presence entries."""
@@ -15,65 +29,24 @@ class PresenceCache:
"""Cache key for a (room, identity) presence entry."""
return f"{settings.PRESENCE_KEY_PREFIX}_{room_id!s}_{identity}"
@staticmethod
def _get_index_key(room_id: UUID | str) -> str:
"""Raw Redis key of the per-room identity index (a native SET).
Built through django-redis' make_key so it lives under the same
KEY_PREFIX/version namespace as the presence entries.
"""
return cache.client.make_key(
f"{settings.PRESENCE_KEY_PREFIX}-index_{room_id!s}"
)
@staticmethod
def _redis(write: bool = True):
"""Raw redis-py client.
SADD/SREM/SMEMBERS are not exposed by the Django cache API; this is
the documented django-redis escape hatch.
"""
return cache.client.get_client(write=write)
def _index_members(self, room_id: UUID | str) -> List[str]:
"""All identities currently indexed for the room."""
members = self._redis(write=False).smembers(self._get_index_key(room_id))
return [
member.decode() if isinstance(member, bytes) else member
for member in members
]
def is_marked_present(self, room_id: UUID | str, identity: str) -> bool:
"""Return True if a positive presence entry exists in cache."""
return bool(cache.get(self._get_cache_key(room_id, identity)))
def mark_present(self, room_id: UUID | str, identity: str) -> None:
"""Record that `identity` is in `room_id` and index it for the room."""
"""Record that `identity` is in `room_id`."""
cache.set(
self._get_cache_key(room_id, identity),
True,
timeout=settings.PRESENCE_CACHE_TIMEOUT,
)
index_key = self._get_index_key(room_id)
pipe = self._redis().pipeline(transaction=False)
pipe.sadd(index_key, identity)
pipe.expire(index_key, settings.PRESENCE_CACHE_TIMEOUT)
pipe.execute()
def clear(self, room_id: UUID | str, identity: str) -> None:
"""Forget presence for one participant (e.g. on participant_left)."""
cache.delete(self._get_cache_key(room_id, identity))
self._redis().srem(self._get_index_key(room_id), identity)
def clear_room(self, room_id: UUID | str) -> None:
"""Forget presence for every participant of a room (on room_finished).
Deletes the indexed entries and the index itself with targeted
commands instead of a full-keyspace pattern scan.
"""
identities = self._index_members(room_id)
if identities:
cache.delete_many(
[self._get_cache_key(room_id, identity) for identity in identities]
)
self._redis().delete(self._get_index_key(room_id))
"""Forget presence for every participant of a room (on room_finished)."""
cache.delete_pattern(
self._get_cache_key(room_id, "*"), itersize=CACHE_SCAN_ITERSIZE
)
@@ -589,9 +589,6 @@ def test_list_waiting_participants_success(settings):
"color": "#654321",
},
)
lobby_service = LobbyService()
lobby_service._index_add(room.id, "2f7f162f-e7d1-421b-90e7-02bfbfbf8def")
lobby_service._index_add(room.id, "f4ca3ab8a6c04ad88097b8da33f60f10")
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
+17 -126
View File
@@ -2,7 +2,7 @@
Test lobby service.
"""
# pylint: disable=W0621,W0613, W0212, R0913, C0302
# pylint: disable=W0621,W0613, W0212, R0913
# ruff: noqa: PLR0913, PLR0917
import uuid
@@ -24,6 +24,7 @@ from core.services.lobby import (
LobbyParticipantStatus,
LobbyService,
)
from core.services.presence import CACHE_SCAN_ITERSIZE
from core.utils import NotificationError
pytestmark = pytest.mark.django_db
@@ -465,22 +466,18 @@ def test_request_entry_participant_with_role(
def test_refresh_waiting_status(mock_cache, lobby_service, participant_id):
"""Test refreshing waiting status for a participant."""
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
lobby_service._index_touch = mock.Mock()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
lobby_service.refresh_waiting_status(room.id, participant_id)
mock_cache.touch.assert_called_once_with(
"mocked_cache_key", settings.LOBBY_WAITING_TIMEOUT
)
lobby_service._index_touch.assert_called_once_with(room.id)
# pylint: disable=R0917
@mock.patch("core.services.lobby.cache")
@mock.patch("core.utils.generate_color")
@mock.patch("core.utils.notify_participants")
@mock.patch("core.services.lobby.LobbyService._index_add")
def test_enter_success(
mock_index_add,
mock_notify,
mock_generate_color,
mock_cache,
@@ -511,16 +508,13 @@ def test_enter_success(
mock_notify.assert_called_once_with(
room_name=str(room.pk), notification_data={"type": "participantWaiting"}
)
mock_index_add.assert_called_once_with(room.id, participant_id)
# pylint: disable=R0917
@mock.patch("core.services.lobby.cache")
@mock.patch("core.utils.generate_color")
@mock.patch("core.utils.notify_participants")
@mock.patch("core.services.lobby.LobbyService._index_add")
def test_enter_with_notification_error(
mock_index_add,
mock_notify,
mock_generate_color,
mock_cache,
@@ -547,7 +541,6 @@ def test_enter_with_notification_error(
participant.to_dict(),
timeout=settings.LOBBY_WAITING_TIMEOUT,
)
mock_index_add.assert_called_once_with(room.id, participant_id)
@mock.patch("core.services.lobby.cache")
@@ -586,15 +579,14 @@ def test_get_participant_parsing_error(
@mock.patch("core.services.lobby.cache")
def test_list_waiting_participants_empty(mock_cache, lobby_service):
"""Test listing waiting participants when none exist."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
lobby_service._index_members = mock.Mock(return_value=[])
lobby_service._index_remove = mock.Mock()
mock_cache.iter_keys.return_value = []
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
result = lobby_service.list_waiting_participants(room.id)
assert result == []
lobby_service._index_members.assert_called_once_with(room.id)
lobby_service._index_remove.assert_not_called()
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
mock_cache.get_many.assert_not_called()
@@ -603,8 +595,7 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
"""Test listing waiting participants with valid data."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
lobby_service._index_members = mock.Mock(return_value=["participant1"])
lobby_service._index_remove = mock.Mock()
mock_cache.iter_keys.return_value = [cache_key]
mock_cache.get_many.return_value = {cache_key: participant_dict}
result = lobby_service.list_waiting_participants(room.id)
@@ -612,8 +603,8 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
assert len(result) == 1
assert result[0]["status"] == "waiting"
assert result[0]["username"] == "test-username"
lobby_service._index_members.assert_called_once_with(room.id)
lobby_service._index_remove.assert_called_once_with(room.id)
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
mock_cache.get_many.assert_called_once_with([cache_key])
@@ -638,10 +629,7 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
"color": "#654321",
}
lobby_service._index_members = mock.Mock(
return_value=["participant1", "participant2"]
)
lobby_service._index_remove = mock.Mock()
mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
mock_cache.get_many.return_value = {
cache_key1: participant1,
cache_key2: participant2,
@@ -658,7 +646,8 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
# Verify all participants have waiting status
assert all(p["status"] == "waiting" for p in result)
lobby_service._index_members.assert_called_once_with(room.id)
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
@@ -667,8 +656,7 @@ def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service):
"""Test listing waiting participants with corrupted data."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
lobby_service._index_members = mock.Mock(return_value=["participant1"])
lobby_service._index_remove = mock.Mock()
mock_cache.iter_keys.return_value = [cache_key]
mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}}
result = lobby_service.list_waiting_participants(room.id)
@@ -693,10 +681,7 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
corrupted_participant = {"invalid": "data"}
lobby_service._index_members = mock.Mock(
return_value=["participant1", "participant2"]
)
lobby_service._index_remove = mock.Mock()
mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
mock_cache.get_many.return_value = {
cache_key1: corrupted_participant,
cache_key2: valid_participant,
@@ -714,6 +699,8 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
mock_cache.delete.assert_called_once_with(cache_key1)
# Verify both cache keys were queried
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
@@ -737,10 +724,7 @@ def test_list_waiting_participants_non_waiting(mock_cache, lobby_service):
"color": "#654321",
}
lobby_service._index_members = mock.Mock(
return_value=["participant1", "participant2"]
)
lobby_service._index_remove = mock.Mock()
mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
mock_cache.get_many.return_value = {
cache_key1: participant1,
cache_key2: participant2,
@@ -836,7 +820,6 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
mock_cache.get.return_value = participant_dict
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
lobby_service._index_touch = mock.Mock()
lobby_service._update_participant_status(
room.id,
@@ -854,7 +837,6 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
mock_cache.set.assert_called_once_with(
"mocked_cache_key", expected_data, timeout=60
)
lobby_service._index_touch.assert_called_once_with(room.id)
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
@@ -899,13 +881,9 @@ def test_clear_room_cache(settings, lobby_service):
timeout=settings.LOBBY_DENIED_TIMEOUT,
)
for participant_id in ("participant1", "participant2", "participant3"):
lobby_service._index_add(room_id, participant_id)
lobby_service.clear_room_cache(room_id)
assert cache.keys(f"test-lobby_{room_id!s}_*") == []
assert lobby_service._index_members(room_id) == []
def test_clear_room_empty(settings, lobby_service):
@@ -932,13 +910,10 @@ def test_clear_participant_cache(lobby_service):
"color": "#123456",
}
cache.set(cache_key, participant_data, timeout=settings.LOBBY_WAITING_TIMEOUT)
lobby_service._index_add(room_id, participant_id)
assert cache.get(cache_key) is not None
assert participant_id in lobby_service._index_members(room_id)
lobby_service.clear_participant_cache(room_id, participant_id)
assert cache.get(cache_key) is None
assert participant_id not in lobby_service._index_members(room_id)
def test_clear_participant_cache_nonexistent(lobby_service):
@@ -952,87 +927,3 @@ def test_clear_participant_cache_nonexistent(lobby_service):
lobby_service.clear_participant_cache(room_id, participant_id)
assert cache.get(cache_key) is None
# Room index integration tests (real cache backend)
def test_index_add_members_remove_roundtrip(lobby_service):
"""The room index records, lists and forgets participant ids."""
room_id = uuid.uuid4()
assert lobby_service._index_members(room_id) == []
lobby_service._index_add(room_id, "participant1")
lobby_service._index_add(room_id, "participant2")
assert sorted(lobby_service._index_members(room_id)) == [
"participant1",
"participant2",
]
# The index carries a backstop TTL so abandoned rooms cannot leak it.
ttl = lobby_service._redis().ttl(lobby_service._get_index_key(room_id))
assert 0 < ttl <= settings.LOBBY_ACCEPTED_TIMEOUT
lobby_service._index_remove(room_id, "participant1")
assert lobby_service._index_members(room_id) == ["participant2"]
@mock.patch("core.utils.notify_participants")
def test_enter_registers_participant_in_room_index(
mock_notify, lobby_service, participant_id, username
):
"""Entering the lobby must index the participant id for the room."""
room_id = uuid.uuid4()
lobby_service.enter(room_id, participant_id, username)
assert lobby_service._index_members(room_id) == [participant_id]
def test_list_waiting_participants_prunes_stale_index_ids(settings, lobby_service):
"""Indexed ids whose cache entry expired are pruned and not listed."""
settings.LOBBY_KEY_PREFIX = "test-lobby-prune"
room_id = uuid.uuid4()
cache.set(
f"test-lobby-prune_{room_id!s}_participant1",
{
"id": "participant1",
"username": "user1",
"status": "waiting",
"color": "#123456",
},
timeout=100,
)
lobby_service._index_add(room_id, "participant1")
# participant2 is indexed but its cache entry has expired.
lobby_service._index_add(room_id, "participant2")
result = lobby_service.list_waiting_participants(room_id)
assert [participant["id"] for participant in result] == ["participant1"]
assert lobby_service._index_members(room_id) == ["participant1"]
def test_refresh_waiting_status_rearms_room_index_ttl(lobby_service, participant_id):
"""A lone waiter's polling must keep the room index alive.
Regression test: the index backstop TTL is only armed at enter() time,
so a participant whose rolling WAITING refreshes outlast it would keep
their entry alive while silently vanishing from the moderator list.
Refreshing the waiting status must therefore re-arm the index TTL.
"""
room_id = uuid.uuid4()
lobby_service._index_add(room_id, participant_id)
index_key = lobby_service._get_index_key(room_id)
redis_client = lobby_service._redis()
redis_client.expire(index_key, 10)
assert redis_client.ttl(index_key) <= 10
lobby_service.refresh_waiting_status(room_id, participant_id)
assert redis_client.ttl(index_key) > 10
assert lobby_service._index_members(room_id) == [participant_id]
@@ -90,18 +90,19 @@ def test_presence_clear_and_clear_room():
assert presence.is_marked_present(other_room, "a") is True
def test_presence_clear_room_removes_many_entries_and_the_index():
"""clear_room removes every entry of the room through the index — never
a keyspace scan — and leaves other rooms untouched."""
def test_presence_clear_room_scans_in_pages():
"""clear_room removes every match, even across several SCAN pages,
and only within the room."""
room_id, other_room = str(uuid4()), str(uuid4())
presence = PresenceCache()
for i in range(7):
presence.mark_present(room_id, f"user-{i}")
presence.mark_present(other_room, "user-0")
presence.clear_room(room_id)
# An itersize smaller than the match count forces delete_pattern to
# page through several SCAN cursors rather than finish in one pass.
with mock.patch("core.utils.CACHE_SCAN_ITERSIZE", 3):
presence.clear_room(room_id)
assert all(not presence.is_marked_present(room_id, f"user-{i}") for i in range(7))
assert presence.is_marked_present(other_room, "user-0") is True
assert presence._index_members(room_id) == []
assert presence._index_members(other_room) == ["user-0"]
+3
View File
@@ -499,3 +499,6 @@ def build_telephony_config():
"default_country": country,
"international_phone_number": international,
}
CACHE_SCAN_ITERSIZE = 500
+1 -1
View File
@@ -865,7 +865,7 @@ class Base(Configuration):
"room_lobby", environ_name="LOBBY_KEY_PREFIX", environ_prefix=None
)
LOBBY_WAITING_TIMEOUT = values.PositiveIntegerValue(
6, environ_name="LOBBY_WAITING_TIMEOUT", environ_prefix=None
3, environ_name="LOBBY_WAITING_TIMEOUT", environ_prefix=None
)
LOBBY_DENIED_TIMEOUT = values.PositiveIntegerValue(
5, environ_name="LOBBY_DENIED_TIMEOUT", environ_prefix=None
@@ -18,7 +18,7 @@ export const fetchUser = (
}
): Promise<ApiUser | false> => {
return new Promise((resolve, reject) => {
fetchApi<ApiUser>('/users/me/')
fetchApi<ApiUser>('/users/me')
.then(resolve)
.catch((error) => {
// we assume that a 401 means the user is not logged in
@@ -1,6 +1,5 @@
import { ApiError } from '@/api/ApiError'
import { fetchApi } from '@/api/fetchApi'
import { captureEvent } from '@/features/analytics/telemetry'
import { useMutation, type UseMutationOptions } from '@tanstack/react-query'
export interface EnterRoomParams {
@@ -18,24 +17,13 @@ export const enterRoom = async ({
allowEntry,
participantId,
}: EnterRoomParams): Promise<EnterRoomResponse> => {
try {
return await fetchApi<EnterRoomResponse>(`/rooms/${roomId}/enter/`, {
method: 'POST',
body: JSON.stringify({
participant_id: participantId,
allow_entry: allowEntry,
}),
})
} catch (error) {
if (error instanceof ApiError && error.statusCode === 404) {
captureEvent('lobby_entry_participant_gone', {
room_id: roomId,
allow_entry: allowEntry,
})
return { message: 'participant_gone' }
}
throw error
}
return await fetchApi<EnterRoomResponse>(`/rooms/${roomId}/enter/`, {
method: 'POST',
body: JSON.stringify({
participant_id: participantId,
allow_entry: allowEntry,
}),
})
}
export function useEnterRoom(
@@ -12,8 +12,8 @@ import { keys } from '@/api/queryKeys'
import { queryClient } from '@/api/queryClient'
import { ApiError } from '@/api/ApiError'
export const POLL_INTERVAL_MS = 4_000
export const LAZY_POLL_INTERVAL_MS = 15_000
export const POLL_INTERVAL_MS = 1000
export const LAZY_POLL_INTERVAL_MS = 10_000
export const LobbyProvider = () => {
const room = useRoomContext()
@@ -34,11 +34,10 @@ export const LobbyProvider = () => {
refetchOnReconnect: false,
refetchInterval: (query) => {
if (!query.state.data?.participants?.length) return false
if (isParticipantsOpen)
return query.state.error ? POLL_INTERVAL_MS * 3 : POLL_INTERVAL_MS
if (isParticipantsOpen) return POLL_INTERVAL_MS
return LAZY_POLL_INTERVAL_MS
},
refetchIntervalInBackground: false,
refetchIntervalInBackground: true,
})
// Triggers: each one-shot, idempotent, deduped by React Query if
@@ -8,7 +8,7 @@ import {
} from '../api/requestEntry'
export const WAIT_TIMEOUT_MS = 600000 // 10 minutes
export const POLL_INTERVAL_MS = 3_000
export const POLL_INTERVAL_MS = 1000
export const useLobby = ({
roomId,
@@ -53,9 +53,7 @@ export const useLobby = ({
}
return response
},
retry: false,
refetchInterval: (query) =>
query.state.error ? POLL_INTERVAL_MS * 3 : POLL_INTERVAL_MS,
refetchInterval: POLL_INTERVAL_MS,
refetchOnWindowFocus: false,
refetchIntervalInBackground: true,
enabled: status === ApiLobbyStatus.WAITING,
@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'
import { RiHand } from '@remixicon/react'
import { ToggleButton } from '@/primitives'
import { css } from '@/styled-system/css'
import { useRoomContext } from '@livekit/components-react'
import { useIsSpeaking, useRoomContext } from '@livekit/components-react'
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
import { useEffect, useRef, useState } from 'react'
import {
@@ -25,11 +25,11 @@ export const HandToggle = ({
const { t } = useTranslation('rooms', { keyPrefix: 'controls.hand' })
const room = useRoomContext()
const { isHandRaised, toggleRaisedHand } = useRaisedHand({
const { isHandRaised, toggleRaisedHand, lowerHand } = useRaisedHand({
participant: room.localParticipant,
})
const isSpeaking = room.localParticipant.isSpeaking
const isSpeaking = useIsSpeaking(room.localParticipant)
const speakingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [hasShownToast, setHasShownToast] = useState(false)
@@ -57,9 +57,10 @@ export const HandToggle = ({
if (shouldShowToast && !speakingTimerRef.current) {
speakingTimerRef.current = setTimeout(() => {
speakingTimerRef.current = null
setHasShownToast(true)
const onClose = () => {
if (isHandRaised) toggleRaisedHand()
lowerHand()
resetToastState()
}
showLowerHandToast(room.localParticipant, onClose)
@@ -70,7 +71,17 @@ export const HandToggle = ({
speakingTimerRef.current = null
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSpeaking, isHandRaised, hasShownToast, toggleRaisedHand])
}, [isSpeaking, isHandRaised, hasShownToast, lowerHand])
// Clear any pending timer on unmount
useEffect(() => {
return () => {
if (speakingTimerRef.current) {
clearTimeout(speakingTimerRef.current)
speakingTimerRef.current = null
}
}
}, [])
const tooltipLabel = isHandRaised ? 'lower' : 'raise'
@@ -86,5 +86,16 @@ export function useRaisedHand({ participant }: useRaisedHandProps) {
}
}
return { isHandRaised, toggleRaisedHand }
const lowerHand = async () => {
if (!isLocal(participant)) return
try {
await raiseHand(false)
} catch (e) {
reportError('generic_failure', e, {
context: 'lower_raised_hand',
})
}
}
return { isHandRaised, toggleRaisedHand, lowerHand }
}