mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-05 15:16:38 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 42477e6592 | |||
| 12566aefc0 | |||
| a6933b53a4 | |||
| 6c6a4c22d9 | |||
| e546d73834 | |||
| 38a40f12c5 | |||
| 69160f023c | |||
| 514cb2999a | |||
| 2e33052516 | |||
| b9ef6e6eb3 | |||
| 071d2b4de7 |
@@ -22,8 +22,6 @@ and this project adheres to
|
|||||||
|
|
||||||
- 🐛(backend) allow any printable ASCII characters in user sub field #1673
|
- 🐛(backend) allow any printable ASCII characters in user sub field #1673
|
||||||
- 🐛(frontend) keep the sending resolution picked while the camera is off #1667
|
- 🐛(frontend) keep the sending resolution picked while the camera is off #1667
|
||||||
- 🐛(frontend) restore automatic lower-hand on speaking
|
|
||||||
- 🐛(frontend) center Avatar initials with a font-aware cap-height ratio
|
|
||||||
|
|
||||||
## [1.30.0] - 2026-09-01
|
## [1.30.0] - 2026-09-01
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
:root {
|
:root {
|
||||||
--fonts-sans: 'Marianne', ui-sans-serif, system-ui, sans-serif;
|
--fonts-sans: 'Marianne', ui-sans-serif, system-ui, sans-serif;
|
||||||
--avatar-cap-height: 0.7;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.Header-beforeLogo {
|
.Header-beforeLogo {
|
||||||
|
|||||||
@@ -14,3 +14,4 @@ accesslog = "-"
|
|||||||
# Using '-' for the error log file makes gunicorn log errors to stderr
|
# Using '-' for the error log file makes gunicorn log errors to stderr
|
||||||
errorlog = "-"
|
errorlog = "-"
|
||||||
loglevel = "info"
|
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'
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ Let's say you want to change the font of our application to a custom font. You c
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
--fonts-sans: 'Roboto', ui-sans-serif, system-ui, sans-serif;
|
--fonts-sans: 'Roboto', ui-sans-serif, system-ui, sans-serif;
|
||||||
--avatar-cap-height: 0.7;
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,14 @@ class LobbyService:
|
|||||||
|
|
||||||
Handles participant entry requests, status management, and notifications
|
Handles participant entry requests, status management, and notifications
|
||||||
using cache for state management and LiveKit for real-time updates.
|
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
|
@staticmethod
|
||||||
@@ -86,6 +94,61 @@ class LobbyService:
|
|||||||
"""Generate cache key for participant(s) data."""
|
"""Generate cache key for participant(s) data."""
|
||||||
return f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
|
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
|
@staticmethod
|
||||||
def _get_or_create_participant_id(request) -> str:
|
def _get_or_create_participant_id(request) -> str:
|
||||||
"""Extract unique participant identifier from the request."""
|
"""Extract unique participant identifier from the request."""
|
||||||
@@ -209,14 +272,16 @@ class LobbyService:
|
|||||||
cache.touch(
|
cache.touch(
|
||||||
self._get_cache_key(room_id, participant_id), settings.LOBBY_WAITING_TIMEOUT
|
self._get_cache_key(room_id, participant_id), settings.LOBBY_WAITING_TIMEOUT
|
||||||
)
|
)
|
||||||
|
self._index_touch(room_id)
|
||||||
|
|
||||||
def enter(
|
def enter(
|
||||||
self, room_id: UUID, participant_id: str, username: str
|
self, room_id: UUID, participant_id: str, username: str
|
||||||
) -> LobbyParticipant:
|
) -> LobbyParticipant:
|
||||||
"""Add participant to waiting lobby.
|
"""Add participant to waiting lobby.
|
||||||
|
|
||||||
Create a new participant entry in waiting status and notify room
|
Create a new participant entry in waiting status, index the
|
||||||
participants of the new entry request.
|
participant id for the room, and notify room participants of the
|
||||||
|
new entry request.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
color = utils.generate_color(participant_id)
|
color = utils.generate_color(participant_id)
|
||||||
@@ -245,6 +310,7 @@ class LobbyService:
|
|||||||
participant.to_dict(),
|
participant.to_dict(),
|
||||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||||
)
|
)
|
||||||
|
self._index_add(room_id, participant_id)
|
||||||
|
|
||||||
return participant
|
return participant
|
||||||
|
|
||||||
@@ -267,15 +333,31 @@ class LobbyService:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def list_waiting_participants(self, room_id: UUID) -> List[dict]:
|
def list_waiting_participants(self, room_id: UUID) -> List[dict]:
|
||||||
"""List all waiting participants for a room."""
|
"""List all waiting participants for a room.
|
||||||
|
|
||||||
pattern = self._get_cache_key(room_id, "*")
|
Reads the per-room index (O(participants of this room)) instead of
|
||||||
keys = list(cache.iter_keys(pattern, itersize=utils.CACHE_SCAN_ITERSIZE))
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
if not keys:
|
member_ids = self._index_members(room_id)
|
||||||
|
|
||||||
|
if not member_ids:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
data = cache.get_many(keys)
|
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)
|
||||||
|
|
||||||
waiting_participants = []
|
waiting_participants = []
|
||||||
for cache_key, raw_participant in data.items():
|
for cache_key, raw_participant in data.items():
|
||||||
@@ -341,16 +423,28 @@ class LobbyService:
|
|||||||
|
|
||||||
participant.status = status
|
participant.status = status
|
||||||
cache.set(cache_key, participant.to_dict(), timeout=timeout)
|
cache.set(cache_key, participant.to_dict(), timeout=timeout)
|
||||||
|
self._index_touch(room_id)
|
||||||
|
|
||||||
def clear_room_cache(self, room_id: UUID) -> None:
|
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.
|
||||||
|
|
||||||
cache.delete_pattern(
|
Deletes the indexed participant entries and the index itself with
|
||||||
self._get_cache_key(room_id, "*"), itersize=utils.CACHE_SCAN_ITERSIZE
|
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))
|
||||||
|
|
||||||
def clear_participant_cache(self, room_id: UUID, participant_id: str) -> None:
|
def clear_participant_cache(self, room_id: UUID, participant_id: str) -> None:
|
||||||
"""Clear a given participant entry from the cache for a specific room."""
|
"""Clear a given participant entry from the cache for a specific room."""
|
||||||
|
|
||||||
cache_key = self._get_cache_key(room_id, participant_id)
|
cache_key = self._get_cache_key(room_id, participant_id)
|
||||||
cache.delete(cache_key)
|
cache.delete(cache_key)
|
||||||
|
self._index_remove(room_id, participant_id)
|
||||||
|
|||||||
@@ -1,25 +1,11 @@
|
|||||||
"""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 uuid import UUID
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
|
||||||
from core.utils import CACHE_SCAN_ITERSIZE
|
|
||||||
|
|
||||||
|
|
||||||
class PresenceCache:
|
class PresenceCache:
|
||||||
"""Store and invalidate (room, identity) presence entries."""
|
"""Store and invalidate (room, identity) presence entries."""
|
||||||
@@ -29,24 +15,65 @@ class PresenceCache:
|
|||||||
"""Cache key for a (room, identity) presence entry."""
|
"""Cache key for a (room, identity) presence entry."""
|
||||||
return f"{settings.PRESENCE_KEY_PREFIX}_{room_id!s}_{identity}"
|
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:
|
def is_marked_present(self, room_id: UUID | str, identity: str) -> bool:
|
||||||
"""Return True if a positive presence entry exists in cache."""
|
"""Return True if a positive presence entry exists in cache."""
|
||||||
return bool(cache.get(self._get_cache_key(room_id, identity)))
|
return bool(cache.get(self._get_cache_key(room_id, identity)))
|
||||||
|
|
||||||
def mark_present(self, room_id: UUID | str, identity: str) -> None:
|
def mark_present(self, room_id: UUID | str, identity: str) -> None:
|
||||||
"""Record that `identity` is in `room_id`."""
|
"""Record that `identity` is in `room_id` and index it for the room."""
|
||||||
cache.set(
|
cache.set(
|
||||||
self._get_cache_key(room_id, identity),
|
self._get_cache_key(room_id, identity),
|
||||||
True,
|
True,
|
||||||
timeout=settings.PRESENCE_CACHE_TIMEOUT,
|
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:
|
def clear(self, room_id: UUID | str, identity: str) -> None:
|
||||||
"""Forget presence for one participant (e.g. on participant_left)."""
|
"""Forget presence for one participant (e.g. on participant_left)."""
|
||||||
cache.delete(self._get_cache_key(room_id, identity))
|
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:
|
def clear_room(self, room_id: UUID | str) -> None:
|
||||||
"""Forget presence for every participant of a room (on room_finished)."""
|
"""Forget presence for every participant of a room (on room_finished).
|
||||||
cache.delete_pattern(
|
|
||||||
self._get_cache_key(room_id, "*"), itersize=CACHE_SCAN_ITERSIZE
|
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))
|
||||||
|
|||||||
@@ -589,6 +589,9 @@ def test_list_waiting_participants_success(settings):
|
|||||||
"color": "#654321",
|
"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/")
|
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
Test lobby service.
|
Test lobby service.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# pylint: disable=W0621,W0613, W0212, R0913
|
# pylint: disable=W0621,W0613, W0212, R0913, C0302
|
||||||
# ruff: noqa: PLR0913, PLR0917
|
# ruff: noqa: PLR0913, PLR0917
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
@@ -24,7 +24,6 @@ from core.services.lobby import (
|
|||||||
LobbyParticipantStatus,
|
LobbyParticipantStatus,
|
||||||
LobbyService,
|
LobbyService,
|
||||||
)
|
)
|
||||||
from core.services.presence import CACHE_SCAN_ITERSIZE
|
|
||||||
from core.utils import NotificationError
|
from core.utils import NotificationError
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
@@ -466,18 +465,22 @@ def test_request_entry_participant_with_role(
|
|||||||
def test_refresh_waiting_status(mock_cache, lobby_service, participant_id):
|
def test_refresh_waiting_status(mock_cache, lobby_service, participant_id):
|
||||||
"""Test refreshing waiting status for a participant."""
|
"""Test refreshing waiting status for a participant."""
|
||||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||||
|
lobby_service._index_touch = mock.Mock()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
lobby_service.refresh_waiting_status(room.id, participant_id)
|
lobby_service.refresh_waiting_status(room.id, participant_id)
|
||||||
mock_cache.touch.assert_called_once_with(
|
mock_cache.touch.assert_called_once_with(
|
||||||
"mocked_cache_key", settings.LOBBY_WAITING_TIMEOUT
|
"mocked_cache_key", settings.LOBBY_WAITING_TIMEOUT
|
||||||
)
|
)
|
||||||
|
lobby_service._index_touch.assert_called_once_with(room.id)
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=R0917
|
# pylint: disable=R0917
|
||||||
@mock.patch("core.services.lobby.cache")
|
@mock.patch("core.services.lobby.cache")
|
||||||
@mock.patch("core.utils.generate_color")
|
@mock.patch("core.utils.generate_color")
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
|
@mock.patch("core.services.lobby.LobbyService._index_add")
|
||||||
def test_enter_success(
|
def test_enter_success(
|
||||||
|
mock_index_add,
|
||||||
mock_notify,
|
mock_notify,
|
||||||
mock_generate_color,
|
mock_generate_color,
|
||||||
mock_cache,
|
mock_cache,
|
||||||
@@ -508,13 +511,16 @@ def test_enter_success(
|
|||||||
mock_notify.assert_called_once_with(
|
mock_notify.assert_called_once_with(
|
||||||
room_name=str(room.pk), notification_data={"type": "participantWaiting"}
|
room_name=str(room.pk), notification_data={"type": "participantWaiting"}
|
||||||
)
|
)
|
||||||
|
mock_index_add.assert_called_once_with(room.id, participant_id)
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=R0917
|
# pylint: disable=R0917
|
||||||
@mock.patch("core.services.lobby.cache")
|
@mock.patch("core.services.lobby.cache")
|
||||||
@mock.patch("core.utils.generate_color")
|
@mock.patch("core.utils.generate_color")
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
|
@mock.patch("core.services.lobby.LobbyService._index_add")
|
||||||
def test_enter_with_notification_error(
|
def test_enter_with_notification_error(
|
||||||
|
mock_index_add,
|
||||||
mock_notify,
|
mock_notify,
|
||||||
mock_generate_color,
|
mock_generate_color,
|
||||||
mock_cache,
|
mock_cache,
|
||||||
@@ -541,6 +547,7 @@ def test_enter_with_notification_error(
|
|||||||
participant.to_dict(),
|
participant.to_dict(),
|
||||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||||
)
|
)
|
||||||
|
mock_index_add.assert_called_once_with(room.id, participant_id)
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.lobby.cache")
|
@mock.patch("core.services.lobby.cache")
|
||||||
@@ -579,14 +586,15 @@ def test_get_participant_parsing_error(
|
|||||||
@mock.patch("core.services.lobby.cache")
|
@mock.patch("core.services.lobby.cache")
|
||||||
def test_list_waiting_participants_empty(mock_cache, lobby_service):
|
def test_list_waiting_participants_empty(mock_cache, lobby_service):
|
||||||
"""Test listing waiting participants when none exist."""
|
"""Test listing waiting participants when none exist."""
|
||||||
mock_cache.iter_keys.return_value = []
|
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
lobby_service._index_members = mock.Mock(return_value=[])
|
||||||
|
lobby_service._index_remove = mock.Mock()
|
||||||
|
|
||||||
result = lobby_service.list_waiting_participants(room.id)
|
result = lobby_service.list_waiting_participants(room.id)
|
||||||
|
|
||||||
assert result == []
|
assert result == []
|
||||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
lobby_service._index_members.assert_called_once_with(room.id)
|
||||||
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
lobby_service._index_remove.assert_not_called()
|
||||||
mock_cache.get_many.assert_not_called()
|
mock_cache.get_many.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@@ -595,7 +603,8 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
|
|||||||
"""Test listing waiting participants with valid data."""
|
"""Test listing waiting participants with valid data."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||||
mock_cache.iter_keys.return_value = [cache_key]
|
lobby_service._index_members = mock.Mock(return_value=["participant1"])
|
||||||
|
lobby_service._index_remove = mock.Mock()
|
||||||
mock_cache.get_many.return_value = {cache_key: participant_dict}
|
mock_cache.get_many.return_value = {cache_key: participant_dict}
|
||||||
|
|
||||||
result = lobby_service.list_waiting_participants(room.id)
|
result = lobby_service.list_waiting_participants(room.id)
|
||||||
@@ -603,8 +612,8 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
|
|||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0]["status"] == "waiting"
|
assert result[0]["status"] == "waiting"
|
||||||
assert result[0]["username"] == "test-username"
|
assert result[0]["username"] == "test-username"
|
||||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
lobby_service._index_members.assert_called_once_with(room.id)
|
||||||
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
lobby_service._index_remove.assert_called_once_with(room.id)
|
||||||
mock_cache.get_many.assert_called_once_with([cache_key])
|
mock_cache.get_many.assert_called_once_with([cache_key])
|
||||||
|
|
||||||
|
|
||||||
@@ -629,7 +638,10 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
|||||||
"color": "#654321",
|
"color": "#654321",
|
||||||
}
|
}
|
||||||
|
|
||||||
mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
|
lobby_service._index_members = mock.Mock(
|
||||||
|
return_value=["participant1", "participant2"]
|
||||||
|
)
|
||||||
|
lobby_service._index_remove = mock.Mock()
|
||||||
mock_cache.get_many.return_value = {
|
mock_cache.get_many.return_value = {
|
||||||
cache_key1: participant1,
|
cache_key1: participant1,
|
||||||
cache_key2: participant2,
|
cache_key2: participant2,
|
||||||
@@ -646,8 +658,7 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
|||||||
# Verify all participants have waiting status
|
# Verify all participants have waiting status
|
||||||
assert all(p["status"] == "waiting" for p in result)
|
assert all(p["status"] == "waiting" for p in result)
|
||||||
|
|
||||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
lobby_service._index_members.assert_called_once_with(room.id)
|
||||||
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])
|
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
|
||||||
|
|
||||||
|
|
||||||
@@ -656,7 +667,8 @@ def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service):
|
|||||||
"""Test listing waiting participants with corrupted data."""
|
"""Test listing waiting participants with corrupted data."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||||
mock_cache.iter_keys.return_value = [cache_key]
|
lobby_service._index_members = mock.Mock(return_value=["participant1"])
|
||||||
|
lobby_service._index_remove = mock.Mock()
|
||||||
mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}}
|
mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}}
|
||||||
|
|
||||||
result = lobby_service.list_waiting_participants(room.id)
|
result = lobby_service.list_waiting_participants(room.id)
|
||||||
@@ -681,7 +693,10 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
|
|||||||
|
|
||||||
corrupted_participant = {"invalid": "data"}
|
corrupted_participant = {"invalid": "data"}
|
||||||
|
|
||||||
mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
|
lobby_service._index_members = mock.Mock(
|
||||||
|
return_value=["participant1", "participant2"]
|
||||||
|
)
|
||||||
|
lobby_service._index_remove = mock.Mock()
|
||||||
mock_cache.get_many.return_value = {
|
mock_cache.get_many.return_value = {
|
||||||
cache_key1: corrupted_participant,
|
cache_key1: corrupted_participant,
|
||||||
cache_key2: valid_participant,
|
cache_key2: valid_participant,
|
||||||
@@ -699,8 +714,6 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
|
|||||||
mock_cache.delete.assert_called_once_with(cache_key1)
|
mock_cache.delete.assert_called_once_with(cache_key1)
|
||||||
|
|
||||||
# Verify both cache keys were queried
|
# 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])
|
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
|
||||||
|
|
||||||
|
|
||||||
@@ -724,7 +737,10 @@ def test_list_waiting_participants_non_waiting(mock_cache, lobby_service):
|
|||||||
"color": "#654321",
|
"color": "#654321",
|
||||||
}
|
}
|
||||||
|
|
||||||
mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
|
lobby_service._index_members = mock.Mock(
|
||||||
|
return_value=["participant1", "participant2"]
|
||||||
|
)
|
||||||
|
lobby_service._index_remove = mock.Mock()
|
||||||
mock_cache.get_many.return_value = {
|
mock_cache.get_many.return_value = {
|
||||||
cache_key1: participant1,
|
cache_key1: participant1,
|
||||||
cache_key2: participant2,
|
cache_key2: participant2,
|
||||||
@@ -820,6 +836,7 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
|
|||||||
|
|
||||||
mock_cache.get.return_value = participant_dict
|
mock_cache.get.return_value = participant_dict
|
||||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||||
|
lobby_service._index_touch = mock.Mock()
|
||||||
|
|
||||||
lobby_service._update_participant_status(
|
lobby_service._update_participant_status(
|
||||||
room.id,
|
room.id,
|
||||||
@@ -837,6 +854,7 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
|
|||||||
mock_cache.set.assert_called_once_with(
|
mock_cache.set.assert_called_once_with(
|
||||||
"mocked_cache_key", expected_data, timeout=60
|
"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)
|
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
||||||
|
|
||||||
|
|
||||||
@@ -881,9 +899,13 @@ def test_clear_room_cache(settings, lobby_service):
|
|||||||
timeout=settings.LOBBY_DENIED_TIMEOUT,
|
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)
|
lobby_service.clear_room_cache(room_id)
|
||||||
|
|
||||||
assert cache.keys(f"test-lobby_{room_id!s}_*") == []
|
assert cache.keys(f"test-lobby_{room_id!s}_*") == []
|
||||||
|
assert lobby_service._index_members(room_id) == []
|
||||||
|
|
||||||
|
|
||||||
def test_clear_room_empty(settings, lobby_service):
|
def test_clear_room_empty(settings, lobby_service):
|
||||||
@@ -910,10 +932,13 @@ def test_clear_participant_cache(lobby_service):
|
|||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
}
|
}
|
||||||
cache.set(cache_key, participant_data, timeout=settings.LOBBY_WAITING_TIMEOUT)
|
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 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)
|
lobby_service.clear_participant_cache(room_id, participant_id)
|
||||||
assert cache.get(cache_key) is None
|
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):
|
def test_clear_participant_cache_nonexistent(lobby_service):
|
||||||
@@ -927,3 +952,87 @@ def test_clear_participant_cache_nonexistent(lobby_service):
|
|||||||
lobby_service.clear_participant_cache(room_id, participant_id)
|
lobby_service.clear_participant_cache(room_id, participant_id)
|
||||||
|
|
||||||
assert cache.get(cache_key) is None
|
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,19 +90,18 @@ def test_presence_clear_and_clear_room():
|
|||||||
assert presence.is_marked_present(other_room, "a") is True
|
assert presence.is_marked_present(other_room, "a") is True
|
||||||
|
|
||||||
|
|
||||||
def test_presence_clear_room_scans_in_pages():
|
def test_presence_clear_room_removes_many_entries_and_the_index():
|
||||||
"""clear_room removes every match, even across several SCAN pages,
|
"""clear_room removes every entry of the room through the index — never
|
||||||
and only within the room."""
|
a keyspace scan — and leaves other rooms untouched."""
|
||||||
room_id, other_room = str(uuid4()), str(uuid4())
|
room_id, other_room = str(uuid4()), str(uuid4())
|
||||||
presence = PresenceCache()
|
presence = PresenceCache()
|
||||||
for i in range(7):
|
for i in range(7):
|
||||||
presence.mark_present(room_id, f"user-{i}")
|
presence.mark_present(room_id, f"user-{i}")
|
||||||
presence.mark_present(other_room, "user-0")
|
presence.mark_present(other_room, "user-0")
|
||||||
|
|
||||||
# An itersize smaller than the match count forces delete_pattern to
|
presence.clear_room(room_id)
|
||||||
# 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 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.is_marked_present(other_room, "user-0") is True
|
||||||
|
assert presence._index_members(room_id) == []
|
||||||
|
assert presence._index_members(other_room) == ["user-0"]
|
||||||
|
|||||||
@@ -499,6 +499,3 @@ def build_telephony_config():
|
|||||||
"default_country": country,
|
"default_country": country,
|
||||||
"international_phone_number": international,
|
"international_phone_number": international,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
CACHE_SCAN_ITERSIZE = 500
|
|
||||||
|
|||||||
@@ -865,7 +865,7 @@ class Base(Configuration):
|
|||||||
"room_lobby", environ_name="LOBBY_KEY_PREFIX", environ_prefix=None
|
"room_lobby", environ_name="LOBBY_KEY_PREFIX", environ_prefix=None
|
||||||
)
|
)
|
||||||
LOBBY_WAITING_TIMEOUT = values.PositiveIntegerValue(
|
LOBBY_WAITING_TIMEOUT = values.PositiveIntegerValue(
|
||||||
3, environ_name="LOBBY_WAITING_TIMEOUT", environ_prefix=None
|
6, environ_name="LOBBY_WAITING_TIMEOUT", environ_prefix=None
|
||||||
)
|
)
|
||||||
LOBBY_DENIED_TIMEOUT = values.PositiveIntegerValue(
|
LOBBY_DENIED_TIMEOUT = values.PositiveIntegerValue(
|
||||||
5, environ_name="LOBBY_DENIED_TIMEOUT", environ_prefix=None
|
5, environ_name="LOBBY_DENIED_TIMEOUT", environ_prefix=None
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { css, cva, RecipeVariantProps } from '@/styled-system/css'
|
import { css, cva, RecipeVariantProps } from '@/styled-system/css'
|
||||||
import React, { useMemo } from 'react'
|
import React, { useLayoutEffect, useMemo } from 'react'
|
||||||
|
|
||||||
const avatar = cva({
|
const avatar = cva({
|
||||||
base: {
|
base: {
|
||||||
@@ -28,17 +28,24 @@ const avatar = cva({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Instantiating a segmenter is expensive; create it once and reuse it.
|
||||||
const graphemeSegmenter =
|
const graphemeSegmenter =
|
||||||
typeof Intl !== 'undefined' && 'Segmenter' in Intl
|
typeof Intl !== 'undefined' && 'Segmenter' in Intl
|
||||||
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the first user-perceived character. Some Unicode characters span
|
||||||
|
* multiple UTF-16 code units, so a naive index into the string can split them
|
||||||
|
* and yield a broken glyph.
|
||||||
|
*/
|
||||||
const getFirstGrapheme = (value: string): string => {
|
const getFirstGrapheme = (value: string): string => {
|
||||||
if (!value) return ''
|
if (!value) return ''
|
||||||
if (graphemeSegmenter) {
|
if (graphemeSegmenter) {
|
||||||
const [first] = graphemeSegmenter.segment(value)
|
const [first] = graphemeSegmenter.segment(value)
|
||||||
return first?.segment ?? ''
|
return first?.segment ?? ''
|
||||||
}
|
}
|
||||||
|
// Fallback: keeps single code points intact (including surrogate pairs).
|
||||||
return Array.from(value)[0] ?? ''
|
return Array.from(value)[0] ?? ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +66,36 @@ export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
|
|||||||
export const Avatar = React.memo(
|
export const Avatar = React.memo(
|
||||||
({ name, bgColor, context, notification, style, ...props }: AvatarProps) => {
|
({ name, bgColor, context, notification, style, ...props }: AvatarProps) => {
|
||||||
const initials = useMemo(() => getInitials(name), [name])
|
const initials = useMemo(() => getInitials(name), [name])
|
||||||
|
const textRef = React.useRef<SVGTextElement>(null)
|
||||||
|
const [offsetY, setOffsetY] = React.useState(0)
|
||||||
|
|
||||||
|
// Optically center the initials: measure the ink bounding box of the
|
||||||
|
// rendered glyphs and shift them so the box's center sits at the middle
|
||||||
|
// of the viewBox. Works for any font, weight or glyph shape, unlike a
|
||||||
|
// hand-tuned dy offset. getBBox() is in local (pre-transform)
|
||||||
|
// coordinates, so applying the translation never changes the measure.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const text = textRef.current
|
||||||
|
if (!text) return
|
||||||
|
|
||||||
|
const center = () => {
|
||||||
|
const box = text.getBBox()
|
||||||
|
// A hidden element measures as an empty box; keep the default then.
|
||||||
|
if (box.height === 0) return
|
||||||
|
setOffsetY(50 - (box.y + box.height / 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
center()
|
||||||
|
// Glyph metrics can change once webfonts finish loading.
|
||||||
|
let cancelled = false
|
||||||
|
document.fonts?.ready.then(() => {
|
||||||
|
if (!cancelled) center()
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [initials])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{ backgroundColor: bgColor, ...style }}
|
style={{ backgroundColor: bgColor, ...style }}
|
||||||
@@ -71,16 +108,15 @@ export const Avatar = React.memo(
|
|||||||
className={css({ width: '100%', height: '100%', display: 'block' })}
|
className={css({ width: '100%', height: '100%', display: 'block' })}
|
||||||
>
|
>
|
||||||
<text
|
<text
|
||||||
|
ref={textRef}
|
||||||
x="50"
|
x="50"
|
||||||
y={50}
|
y="50"
|
||||||
|
transform={`translate(0 ${offsetY})`}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
|
dominantBaseline="central"
|
||||||
fontSize="52"
|
fontSize="52"
|
||||||
fontWeight="500"
|
fontWeight="500"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
className={css({
|
|
||||||
transform:
|
|
||||||
'translateY(calc(var(--avatar-cap-height, 0.7) * 0.5em))',
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
{initials}
|
{initials}
|
||||||
</text>
|
</text>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const fetchUser = (
|
|||||||
}
|
}
|
||||||
): Promise<ApiUser | false> => {
|
): Promise<ApiUser | false> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
fetchApi<ApiUser>('/users/me')
|
fetchApi<ApiUser>('/users/me/')
|
||||||
.then(resolve)
|
.then(resolve)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
// we assume that a 401 means the user is not logged in
|
// we assume that a 401 means the user is not logged in
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ApiError } from '@/api/ApiError'
|
import { ApiError } from '@/api/ApiError'
|
||||||
import { fetchApi } from '@/api/fetchApi'
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
|
import { captureEvent } from '@/features/analytics/telemetry'
|
||||||
import { useMutation, type UseMutationOptions } from '@tanstack/react-query'
|
import { useMutation, type UseMutationOptions } from '@tanstack/react-query'
|
||||||
|
|
||||||
export interface EnterRoomParams {
|
export interface EnterRoomParams {
|
||||||
@@ -17,13 +18,24 @@ export const enterRoom = async ({
|
|||||||
allowEntry,
|
allowEntry,
|
||||||
participantId,
|
participantId,
|
||||||
}: EnterRoomParams): Promise<EnterRoomResponse> => {
|
}: EnterRoomParams): Promise<EnterRoomResponse> => {
|
||||||
return await fetchApi<EnterRoomResponse>(`/rooms/${roomId}/enter/`, {
|
try {
|
||||||
method: 'POST',
|
return await fetchApi<EnterRoomResponse>(`/rooms/${roomId}/enter/`, {
|
||||||
body: JSON.stringify({
|
method: 'POST',
|
||||||
participant_id: participantId,
|
body: JSON.stringify({
|
||||||
allow_entry: allowEntry,
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useEnterRoom(
|
export function useEnterRoom(
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import { keys } from '@/api/queryKeys'
|
|||||||
import { queryClient } from '@/api/queryClient'
|
import { queryClient } from '@/api/queryClient'
|
||||||
import { ApiError } from '@/api/ApiError'
|
import { ApiError } from '@/api/ApiError'
|
||||||
|
|
||||||
export const POLL_INTERVAL_MS = 1000
|
export const POLL_INTERVAL_MS = 4_000
|
||||||
export const LAZY_POLL_INTERVAL_MS = 10_000
|
export const LAZY_POLL_INTERVAL_MS = 15_000
|
||||||
|
|
||||||
export const LobbyProvider = () => {
|
export const LobbyProvider = () => {
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
@@ -34,10 +34,11 @@ export const LobbyProvider = () => {
|
|||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
refetchInterval: (query) => {
|
refetchInterval: (query) => {
|
||||||
if (!query.state.data?.participants?.length) return false
|
if (!query.state.data?.participants?.length) return false
|
||||||
if (isParticipantsOpen) return POLL_INTERVAL_MS
|
if (isParticipantsOpen)
|
||||||
|
return query.state.error ? POLL_INTERVAL_MS * 3 : POLL_INTERVAL_MS
|
||||||
return LAZY_POLL_INTERVAL_MS
|
return LAZY_POLL_INTERVAL_MS
|
||||||
},
|
},
|
||||||
refetchIntervalInBackground: true,
|
refetchIntervalInBackground: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Triggers: each one-shot, idempotent, deduped by React Query if
|
// Triggers: each one-shot, idempotent, deduped by React Query if
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from '../api/requestEntry'
|
} from '../api/requestEntry'
|
||||||
|
|
||||||
export const WAIT_TIMEOUT_MS = 600000 // 10 minutes
|
export const WAIT_TIMEOUT_MS = 600000 // 10 minutes
|
||||||
export const POLL_INTERVAL_MS = 1000
|
export const POLL_INTERVAL_MS = 3_000
|
||||||
|
|
||||||
export const useLobby = ({
|
export const useLobby = ({
|
||||||
roomId,
|
roomId,
|
||||||
@@ -53,7 +53,9 @@ export const useLobby = ({
|
|||||||
}
|
}
|
||||||
return response
|
return response
|
||||||
},
|
},
|
||||||
refetchInterval: POLL_INTERVAL_MS,
|
retry: false,
|
||||||
|
refetchInterval: (query) =>
|
||||||
|
query.state.error ? POLL_INTERVAL_MS * 3 : POLL_INTERVAL_MS,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchIntervalInBackground: true,
|
refetchIntervalInBackground: true,
|
||||||
enabled: status === ApiLobbyStatus.WAITING,
|
enabled: status === ApiLobbyStatus.WAITING,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { RiHand } from '@remixicon/react'
|
import { RiHand } from '@remixicon/react'
|
||||||
import { ToggleButton } from '@/primitives'
|
import { ToggleButton } from '@/primitives'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { useIsSpeaking, useRoomContext } from '@livekit/components-react'
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
|
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
@@ -25,11 +25,11 @@ export const HandToggle = ({
|
|||||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.hand' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'controls.hand' })
|
||||||
|
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
const { isHandRaised, toggleRaisedHand, lowerHand } = useRaisedHand({
|
const { isHandRaised, toggleRaisedHand } = useRaisedHand({
|
||||||
participant: room.localParticipant,
|
participant: room.localParticipant,
|
||||||
})
|
})
|
||||||
|
|
||||||
const isSpeaking = useIsSpeaking(room.localParticipant)
|
const isSpeaking = room.localParticipant.isSpeaking
|
||||||
const speakingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const speakingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const [hasShownToast, setHasShownToast] = useState(false)
|
const [hasShownToast, setHasShownToast] = useState(false)
|
||||||
|
|
||||||
@@ -57,10 +57,9 @@ export const HandToggle = ({
|
|||||||
|
|
||||||
if (shouldShowToast && !speakingTimerRef.current) {
|
if (shouldShowToast && !speakingTimerRef.current) {
|
||||||
speakingTimerRef.current = setTimeout(() => {
|
speakingTimerRef.current = setTimeout(() => {
|
||||||
speakingTimerRef.current = null
|
|
||||||
setHasShownToast(true)
|
setHasShownToast(true)
|
||||||
const onClose = () => {
|
const onClose = () => {
|
||||||
lowerHand()
|
if (isHandRaised) toggleRaisedHand()
|
||||||
resetToastState()
|
resetToastState()
|
||||||
}
|
}
|
||||||
showLowerHandToast(room.localParticipant, onClose)
|
showLowerHandToast(room.localParticipant, onClose)
|
||||||
@@ -71,17 +70,7 @@ export const HandToggle = ({
|
|||||||
speakingTimerRef.current = null
|
speakingTimerRef.current = null
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isSpeaking, isHandRaised, hasShownToast, lowerHand])
|
}, [isSpeaking, isHandRaised, hasShownToast, toggleRaisedHand])
|
||||||
|
|
||||||
// Clear any pending timer on unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (speakingTimerRef.current) {
|
|
||||||
clearTimeout(speakingTimerRef.current)
|
|
||||||
speakingTimerRef.current = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const tooltipLabel = isHandRaised ? 'lower' : 'raise'
|
const tooltipLabel = isHandRaised ? 'lower' : 'raise'
|
||||||
|
|
||||||
|
|||||||
@@ -86,16 +86,5 @@ export function useRaisedHand({ participant }: useRaisedHandProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const lowerHand = async () => {
|
return { isHandRaised, toggleRaisedHand }
|
||||||
if (!isLocal(participant)) return
|
|
||||||
try {
|
|
||||||
await raiseHand(false)
|
|
||||||
} catch (e) {
|
|
||||||
reportError('generic_failure', e, {
|
|
||||||
context: 'lower_raised_hand',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { isHandRaised, toggleRaisedHand, lowerHand }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,6 @@ body,
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
|
||||||
--avatar-cap-height: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.font-lexend {
|
html.font-lexend {
|
||||||
--fonts-sans: 'Lexend Variable', ui-sans-serif, system-ui, sans-serif;
|
--fonts-sans: 'Lexend Variable', ui-sans-serif, system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user