From f5ebc9457c66196432e835e6e83658f118eca541 Mon Sep 17 00:00:00 2001 From: dimmur-brw Date: Mon, 20 Jul 2026 17:01:01 +0200 Subject: [PATCH] feat: show anonymous public view visitors in editor's presence bar (#5704) --- .../src/baserow/contrib/database/ws/pages.py | 15 + backend/src/baserow/ws/consumers.py | 22 +- backend/src/baserow/ws/presence.py | 79 +++- backend/tests/baserow/ws/test_presence.py | 366 +++++++++++++++++- ...view_visitors_in_editors_presence_bar.json | 9 + .../components/presence/PresenceBadge.vue | 20 +- .../core/components/presence/PresenceBar.vue | 18 +- .../modules/core/plugins/realTimeHandler.js | 7 + web-frontend/modules/core/store/presence.js | 36 +- .../modules/core/utils/presenceColors.js | 24 +- .../view/grid/GridViewFocusBadge.vue | 12 +- .../components/view/grid/GridViewRow.vue | 9 +- .../modules/database/utils/presence.js | 25 +- .../test/unit/core/realTimeHandler.spec.js | 16 + .../test/unit/core/store/presence.spec.js | 62 +++ .../__snapshots__/publicView.spec.js.snap | 9 +- .../test/unit/database/utils/presence.spec.js | 31 ++ 17 files changed, 715 insertions(+), 45 deletions(-) create mode 100644 changelog/entries/unreleased/feature/5702_show_anonymous_public_view_visitors_in_editors_presence_bar.json diff --git a/backend/src/baserow/contrib/database/ws/pages.py b/backend/src/baserow/contrib/database/ws/pages.py index d9b6d5dbb6..5c3f84f4dc 100755 --- a/backend/src/baserow/contrib/database/ws/pages.py +++ b/backend/src/baserow/contrib/database/ws/pages.py @@ -1,6 +1,7 @@ from typing import Any from django.conf import settings +from django.contrib.auth.models import AnonymousUser from baserow.contrib.database.rows.exceptions import RowDoesNotExist from baserow.contrib.database.rows.handler import RowHandler @@ -85,6 +86,20 @@ class PublicViewPageType(PageType): type = "view" parameters = ["slug", "token"] + def get_presence_space_name(self, slug=None, token=None, **kwargs) -> str | None: + if not slug: + return None + try: + view = ViewHandler().get_public_view_by_slug( + AnonymousUser(), slug, authorization_token=token + ) + except (ViewDoesNotExist, NoAuthorizationToPubliclySharedView): + return None + return table_presence_space_name(view.table_id) + + def filter_focus_for_recipient(self, page_parameters, focus, focus_type) -> bool: + return False + def can_add(self, user, web_socket_id, slug, token=None, **kwargs): """ The user should only have access to this page if the view exists and: diff --git a/backend/src/baserow/ws/consumers.py b/backend/src/baserow/ws/consumers.py index 17d61c7846..720e61f42a 100644 --- a/backend/src/baserow/ws/consumers.py +++ b/backend/src/baserow/ws/consumers.py @@ -11,6 +11,9 @@ from baserow.config.settings.utils import try_int from baserow.ws.presence import ( + ANONYMOUS_ALLOWED_PRESENCE_EVENTS, + ANONYMOUS_USER_ID, + PRESENCE_EVENT_PREFIX, NullPresenceHandler, PresenceHandler, PresenceHandlerProtocol, @@ -212,10 +215,15 @@ async def connect(self): self.scope["pages"] = SubscribedPages() web_socket_id = self.scope["web_socket_id"] if settings.PRESENCE_VISIBLE_USERS > 0: + user_id = ( + user.id + if getattr(user, "is_authenticated", False) + else ANONYMOUS_USER_ID + ) self.presence = PresenceHandler( consumer=self, web_socket_id=web_socket_id, - user_id=user.id, + user_id=user_id, ) else: self.presence = NullPresenceHandler() @@ -671,9 +679,17 @@ async def broadcast_to_group(self, event: "BroadcastToChannelGroupMessage"): payload = event["payload"] ignore_web_socket_id = event["ignore_web_socket_id"] exclude_user_ids = set(event.get("exclude_user_ids", None) or []) - user_id = self.scope["user"].id + user = self.scope["user"] + + if not getattr(user, "is_authenticated", False): + payload_type = payload.get("type", "") + if ( + payload_type.startswith(PRESENCE_EVENT_PREFIX) + and payload_type not in ANONYMOUS_ALLOWED_PRESENCE_EVENTS + ): + return - if user_id in exclude_user_ids: + if getattr(user, "id", None) in exclude_user_ids: return if not ignore_web_socket_id or ignore_web_socket_id != web_socket_id: diff --git a/backend/src/baserow/ws/presence.py b/backend/src/baserow/ws/presence.py index f916a5ad28..c6e749a407 100644 --- a/backend/src/baserow/ws/presence.py +++ b/backend/src/baserow/ws/presence.py @@ -2,6 +2,7 @@ import uuid from typing import TYPE_CHECKING, Any, Optional, Protocol, runtime_checkable +from channels.db import database_sync_to_async from loguru import logger from baserow.core.async_redis import get_async_redis @@ -18,6 +19,11 @@ if TYPE_CHECKING: from baserow.ws.consumers import CoreConsumer +ANONYMOUS_USER_ID = -1 +PRESENCE_EVENT_PREFIX = "presence." +# Retained for when anonymous focus emission is re-enabled (flip ANONYMOUS_FOCUS_ENABLED in web-frontend/modules/database/utils/presence.js). +ANONYMOUS_ALLOWED_PRESENCE_EVENTS = frozenset({"presence.editors_active"}) + PRESENCE_KEY_PREFIX = "presence:" PRESENCE_SPACE_TTL = 43200 # 12 hours @@ -231,7 +237,7 @@ async def handle_page_subscribed( space = None already_in_space = False try: - space_name = self.resolve_space_name(page_type_name, parameters) + space_name = await self.resolve_space_name(page_type_name, parameters) if space_name is None: return @@ -250,16 +256,23 @@ async def handle_page_subscribed( ) members = await self._join(space) self._page_subscribed(page_key, space_name, page_type_name, parameters) - await self._consumer.send_json( - { - "type": "presence.members", - "space": space_name, - "entries": self._filter_members_focus( - page_type_name, parameters, members - ), - } - ) + if self.user_id != ANONYMOUS_USER_ID: + await self._consumer.send_json( + { + "type": "presence.members", + "space": space_name, + "entries": self._filter_members_focus( + page_type_name, parameters, members + ), + } + ) + else: + if self._has_auth_members(members): + await self._send_editors_active(space, active=True) await self._broadcast_join(space) + if self.user_id != ANONYMOUS_USER_ID: + if not self._has_auth_members(members): + await self._signal_editors_active(space, active=True) except Exception: logger.exception("Presence subscribe failed for page {}", page_type_name) # Roll back everything so a later re-subscribe starts clean instead @@ -314,6 +327,7 @@ async def handle_page_unsubscribed( for side_effect in ( lambda: self._broadcast_leave(space), + lambda: self._maybe_signal_last_editor_left(space), lambda: self._consumer.send_json( { "type": "presence.space_discard", @@ -346,6 +360,11 @@ async def handle_focus(self, page_key: str, raw_focus: dict | None) -> None: clear focus. """ + # Remove when enabling anonymous focus (flip ANONYMOUS_FOCUS_ENABLED + # in web-frontend/modules/database/utils/presence.js). + if self.user_id == ANONYMOUS_USER_ID: + return + space_name = self._page_to_space.get(page_key) if space_name is None: return @@ -372,6 +391,7 @@ async def leave_all_spaces(self) -> None: space = PresenceSpace(name=space_name) await self._leave(space) await self._broadcast_leave(space) + await self._maybe_signal_last_editor_left(space) await self._consumer.channel_layer.group_discard( space.channel_group, self._consumer.channel_name ) @@ -490,6 +510,37 @@ async def _broadcast_focus( }, ) + @staticmethod + def _has_auth_members(members: list[ActivePresenceEntry]) -> bool: + return any(m["user_id"] != ANONYMOUS_USER_ID for m in members) + + # editors_active signaling: retained for when anonymous focus emission is re-enabled (flip ANONYMOUS_FOCUS_ENABLED in web-frontend/modules/database/utils/presence.js). + async def _signal_editors_active(self, space: PresenceSpace, active: bool) -> None: + await self._broadcast( + space, + { + "type": "presence.editors_active", + "space": space.name, + "active": active, + }, + ) + + async def _send_editors_active(self, space: PresenceSpace, active: bool) -> None: + await self._consumer.send_json( + { + "type": "presence.editors_active", + "space": space.name, + "active": active, + } + ) + + async def _maybe_signal_last_editor_left(self, space: PresenceSpace) -> None: + if self.user_id == ANONYMOUS_USER_ID: + return + remaining = await space.get_members() + if not self._has_auth_members(remaining): + await self._signal_editors_active(space, active=False) + def _filter_members_focus( self, page_type_name: str, @@ -600,7 +651,9 @@ async def receive_focus_broadcast(self, event: dict) -> None: return @staticmethod - def resolve_space_name(page_type_name: str, parameters: dict) -> Optional[str]: + async def resolve_space_name( + page_type_name: str, parameters: dict + ) -> Optional[str]: """ Look up the presence space name for a page type via the registry. @@ -614,4 +667,6 @@ def resolve_space_name(page_type_name: str, parameters: dict) -> Optional[str]: page_type = page_registry.get(page_type_name) except page_registry.does_not_exist_exception_class: return None - return page_type.get_presence_space_name(**parameters) + return await database_sync_to_async(page_type.get_presence_space_name)( + **parameters + ) diff --git a/backend/tests/baserow/ws/test_presence.py b/backend/tests/baserow/ws/test_presence.py index 5e10c99da5..8322ea00cb 100644 --- a/backend/tests/baserow/ws/test_presence.py +++ b/backend/tests/baserow/ws/test_presence.py @@ -11,7 +11,9 @@ from baserow.config.asgi import application from baserow.core.async_redis import get_async_redis +from baserow.ws.auth import ANONYMOUS_USER_TOKEN from baserow.ws.presence import ( + ANONYMOUS_USER_ID, PresenceHandler, PresenceSpace, make_page_key, @@ -52,6 +54,18 @@ async def _connect(token): return communicator, ws_id +async def _connect_anonymous(): + ws_id = str(uuid.uuid4()) + communicator = WebsocketCommunicator( + application, + f"ws/core/?jwt_token={ANONYMOUS_USER_TOKEN}&web_socket_id={ws_id}", + headers=[(b"origin", b"http://localhost")], + ) + await communicator.connect() + await communicator.receive_json_from() # auth message + return communicator, ws_id + + async def _subscribe(communicator, page="test_presence_page", test_param=1): await communicator.send_json_to({"page": page, "test_param": test_param}) return await communicator.receive_json_from(timeout=0.5) @@ -580,7 +594,7 @@ async def test_restricted_view_excluded_from_presence(data_fixture): @pytest.mark.asyncio @pytest.mark.django_db(transaction=True) @pytest.mark.websockets -async def test_public_view_has_no_presence(data_fixture): +async def test_public_view_joins_table_presence_space(data_fixture): user_a, token_a = data_fixture.create_user_and_token() workspace = data_fixture.create_workspace(user=user_a) database = data_fixture.create_database_application(workspace=workspace) @@ -591,7 +605,10 @@ async def test_public_view_has_no_presence(data_fixture): await comm_a.send_json_to({"page": "view", "slug": view.slug}) page_add = await comm_a.receive_json_from(timeout=1) assert page_add["type"] == "page_add" - assert await comm_a.receive_nothing(timeout=0.5) + members = await comm_a.receive_json_from(timeout=1) + assert members["type"] == "presence.members" + assert members["space"] == f"table-{table.id}" + assert members["entries"] == [] await comm_a.disconnect() @@ -843,3 +860,348 @@ async def test_subscribe_failure_rolls_back_state_so_resubscribe_works( members_msg = consumer.send_json.await_args_list[0].args[0] assert members_msg["type"] == "presence.members" assert members_msg["space"] == SPACE_NAME + + +# --- Anonymous presence tests --- + + +@pytest.mark.asyncio +@pytest.mark.websockets +async def test_anonymous_entry_valid_in_presence_space(): + from baserow.ws.presence import _is_valid_entry + + assert _is_valid_entry({"user_id": ANONYMOUS_USER_ID}) is True + assert _is_valid_entry({"user_id": ANONYMOUS_USER_ID, "focus": None}) is True + + space = PresenceSpace("anon-test") + pid = "anon-pid-1" + await space.join(pid, ANONYMOUS_USER_ID) + members = await space.get_members() + assert len(members) == 1 + assert members[0]["user_id"] == ANONYMOUS_USER_ID + assert members[0]["presence_id"] == pid + + await space.remove_entry(pid) + assert await space.get_members() == [] + + +@pytest.mark.asyncio +@pytest.mark.websockets +async def test_multiple_anonymous_entries_coexist(): + space = PresenceSpace("anon-multi") + await space.join("anon-1", ANONYMOUS_USER_ID) + await space.join("anon-2", ANONYMOUS_USER_ID) + await space.join("auth-1", 42) + + members = await space.get_members() + assert len(members) == 3 + anon_members = [m for m in members if m["user_id"] == ANONYMOUS_USER_ID] + assert len(anon_members) == 2 + assert {m["presence_id"] for m in anon_members} == {"anon-1", "anon-2"} + + +@pytest.mark.asyncio +@pytest.mark.websockets +async def test_anonymous_handler_suppresses_members_snapshot(presence_types): + handler, consumer = _make_mock_handler(user_id=ANONYMOUS_USER_ID) + params = {"test_param": 1} + await handler.handle_page_subscribed("test_presence_page", params) + + assert handler.presence_id in await _presence_ids_in_redis(PRESENCE_KEY) + send_calls = consumer.send_json.await_args_list + assert not any( + call.args[0].get("type") == "presence.members" for call in send_calls + ) + consumer.channel_layer.group_send.assert_awaited() + + await handler.leave_all_spaces() + + +@pytest.mark.asyncio +@pytest.mark.django_db(transaction=True) +@pytest.mark.websockets +async def test_anonymous_join_broadcasts_to_authenticated_editor(data_fixture): + user_a, token_a = data_fixture.create_user_and_token() + workspace = data_fixture.create_workspace(user=user_a) + database = data_fixture.create_database_application(workspace=workspace) + table = data_fixture.create_database_table(database=database) + view = data_fixture.create_grid_view(table=table, public=True) + + comm_a, ws_a = await _connect(token_a) + await comm_a.send_json_to({"page": "table", "table_id": table.id}) + await comm_a.receive_json_from(timeout=1) # page_add + await comm_a.receive_json_from(timeout=1) # presence.members + + comm_anon, ws_anon = await _connect_anonymous() + await comm_anon.send_json_to({"page": "view", "slug": view.slug}) + page_add = await comm_anon.receive_json_from(timeout=1) + assert page_add["type"] == "page_add" + + ea = await comm_anon.receive_json_from(timeout=1) + assert ea["type"] == "presence.editors_active" + assert ea["active"] is True + assert await comm_anon.receive_nothing(timeout=0.5) + + join = await comm_a.receive_json_from(timeout=1) + assert join["type"] == "presence.join" + assert join["user_id"] == ANONYMOUS_USER_ID + assert "presence_id" in join + + await comm_anon.disconnect() + + leave = await comm_a.receive_json_from(timeout=1) + assert leave["type"] == "presence.leave" + assert leave["user_id"] == ANONYMOUS_USER_ID + + await comm_a.disconnect() + + +@pytest.mark.asyncio +@pytest.mark.django_db(transaction=True) +@pytest.mark.websockets +async def test_anonymous_receives_no_join_leave_broadcasts(data_fixture): + user_a, token_a = data_fixture.create_user_and_token() + user_b, token_b = data_fixture.create_user_and_token() + workspace = data_fixture.create_workspace(user=user_a, members=[user_b]) + database = data_fixture.create_database_application(workspace=workspace) + table = data_fixture.create_database_table(database=database) + view = data_fixture.create_grid_view(table=table, public=True) + + comm_a, ws_a = await _connect(token_a) + await comm_a.send_json_to({"page": "table", "table_id": table.id}) + await comm_a.receive_json_from(timeout=1) # page_add + await comm_a.receive_json_from(timeout=1) # presence.members + + comm_anon, ws_anon = await _connect_anonymous() + await comm_anon.send_json_to({"page": "view", "slug": view.slug}) + await comm_anon.receive_json_from(timeout=1) # page_add + await _drain(comm_anon, timeout=0.5) # consume editors_active + + comm_b, ws_b = await _connect(token_b) + await comm_b.send_json_to({"page": "table", "table_id": table.id}) + await comm_b.receive_json_from(timeout=1) # page_add + await comm_b.receive_json_from(timeout=1) # presence.members + await _drain(comm_a, timeout=0.5) # consume join on editor A + + anon_frames = await _drain(comm_anon, timeout=0.5) + join_or_leave = [ + f for f in anon_frames if f.get("type") in ("presence.join", "presence.leave") + ] + assert join_or_leave == [] + + await comm_b.disconnect() + await _drain(comm_a, timeout=0.5) # consume leave on editor A + + anon_frames = await _drain(comm_anon, timeout=0.5) + join_or_leave = [ + f for f in anon_frames if f.get("type") in ("presence.join", "presence.leave") + ] + assert join_or_leave == [] + + await comm_anon.disconnect() + await comm_a.disconnect() + + +@pytest.mark.asyncio +@pytest.mark.django_db(transaction=True) +@pytest.mark.websockets +async def test_anonymous_receives_no_focus_broadcasts(data_fixture): + user_a, token_a = data_fixture.create_user_and_token() + workspace = data_fixture.create_workspace(user=user_a) + database = data_fixture.create_database_application(workspace=workspace) + table = data_fixture.create_database_table(database=database) + view = data_fixture.create_grid_view(table=table, public=True) + + comm_a, ws_a = await _connect(token_a) + await comm_a.send_json_to({"page": "table", "table_id": table.id}) + await comm_a.receive_json_from(timeout=1) # page_add + await comm_a.receive_json_from(timeout=1) # presence.members + + comm_anon, ws_anon = await _connect_anonymous() + await comm_anon.send_json_to({"page": "view", "slug": view.slug}) + await comm_anon.receive_json_from(timeout=1) # page_add + await _drain(comm_anon, timeout=0.5) # consume editors_active + await _drain(comm_a, timeout=0.5) # consume join + + await comm_a.send_json_to( + { + "type": "presence.focus", + "page": "table", + "table_id": table.id, + "focus": {"type": "cell", "row_id": 1, "field_id": 1, "editing": False}, + } + ) + + assert await comm_anon.receive_nothing(timeout=0.5) + + await comm_anon.disconnect() + await comm_a.disconnect() + + +@pytest.mark.asyncio +@pytest.mark.django_db(transaction=True) +@pytest.mark.websockets +async def test_public_view_page_resolves_to_table_space(data_fixture): + from baserow.ws.registries import page_registry + + user_a, token_a = data_fixture.create_user_and_token() + workspace = data_fixture.create_workspace(user=user_a) + database = data_fixture.create_database_application(workspace=workspace) + table = data_fixture.create_database_table(database=database) + view = data_fixture.create_grid_view(table=table, public=True) + + view_page = page_registry.get("view") + + space_name = view_page.get_presence_space_name(slug=view.slug) + assert space_name == f"table-{table.id}" + + assert view_page.get_presence_space_name(slug="non-existent") is None + assert view_page.get_presence_space_name(slug=None) is None + assert view_page.get_presence_space_name() is None + + +# --- editors_active signal tests --- + + +@pytest.mark.asyncio +@pytest.mark.django_db(transaction=True) +@pytest.mark.websockets +async def test_anonymous_receives_editors_active_on_editor_join(data_fixture): + user_a, token_a = data_fixture.create_user_and_token() + workspace = data_fixture.create_workspace(user=user_a) + database = data_fixture.create_database_application(workspace=workspace) + table = data_fixture.create_database_table(database=database) + view = data_fixture.create_grid_view(table=table, public=True) + + comm_anon, ws_anon = await _connect_anonymous() + await comm_anon.send_json_to({"page": "view", "slug": view.slug}) + await comm_anon.receive_json_from(timeout=1) # page_add + assert await comm_anon.receive_nothing(timeout=0.5) + + comm_a, ws_a = await _connect(token_a) + await comm_a.send_json_to({"page": "table", "table_id": table.id}) + await comm_a.receive_json_from(timeout=1) # page_add + await comm_a.receive_json_from(timeout=1) # presence.members + + anon_frames = await _drain(comm_anon, timeout=0.5) + editors_active_msgs = [ + f for f in anon_frames if f.get("type") == "presence.editors_active" + ] + assert len(editors_active_msgs) == 1 + assert editors_active_msgs[0]["active"] is True + assert editors_active_msgs[0]["space"] == f"table-{table.id}" + + await comm_anon.disconnect() + await comm_a.disconnect() + + +@pytest.mark.asyncio +@pytest.mark.django_db(transaction=True) +@pytest.mark.websockets +async def test_anonymous_receives_editors_active_false_on_last_editor_leave( + data_fixture, +): + user_a, token_a = data_fixture.create_user_and_token() + workspace = data_fixture.create_workspace(user=user_a) + database = data_fixture.create_database_application(workspace=workspace) + table = data_fixture.create_database_table(database=database) + view = data_fixture.create_grid_view(table=table, public=True) + + comm_a, ws_a = await _connect(token_a) + await comm_a.send_json_to({"page": "table", "table_id": table.id}) + await comm_a.receive_json_from(timeout=1) # page_add + await comm_a.receive_json_from(timeout=1) # presence.members + + comm_anon, ws_anon = await _connect_anonymous() + await comm_anon.send_json_to({"page": "view", "slug": view.slug}) + await comm_anon.receive_json_from(timeout=1) # page_add + ea_init = await comm_anon.receive_json_from(timeout=1) + assert ea_init["type"] == "presence.editors_active" + assert ea_init["active"] is True + await _drain(comm_a, timeout=0.5) # consume anon join + + await comm_a.disconnect() + + anon_frames = await _drain(comm_anon, timeout=1) + editors_active_msgs = [ + f for f in anon_frames if f.get("type") == "presence.editors_active" + ] + assert len(editors_active_msgs) == 1 + assert editors_active_msgs[0]["active"] is False + + await comm_anon.disconnect() + + +@pytest.mark.asyncio +@pytest.mark.django_db(transaction=True) +@pytest.mark.websockets +async def test_no_editors_active_signal_when_second_editor_joins(data_fixture): + user_a, token_a = data_fixture.create_user_and_token() + user_b, token_b = data_fixture.create_user_and_token() + workspace = data_fixture.create_workspace(user=user_a, members=[user_b]) + database = data_fixture.create_database_application(workspace=workspace) + table = data_fixture.create_database_table(database=database) + view = data_fixture.create_grid_view(table=table, public=True) + + comm_a, ws_a = await _connect(token_a) + await comm_a.send_json_to({"page": "table", "table_id": table.id}) + await comm_a.receive_json_from(timeout=1) # page_add + await comm_a.receive_json_from(timeout=1) # presence.members + + comm_anon, ws_anon = await _connect_anonymous() + await comm_anon.send_json_to({"page": "view", "slug": view.slug}) + await comm_anon.receive_json_from(timeout=1) # page_add + await comm_anon.receive_json_from(timeout=1) # editors_active: true (initial) + await _drain(comm_a, timeout=0.5) # consume anon join + + comm_b, ws_b = await _connect(token_b) + await comm_b.send_json_to({"page": "table", "table_id": table.id}) + await comm_b.receive_json_from(timeout=1) # page_add + await comm_b.receive_json_from(timeout=1) # presence.members + + anon_frames = await _drain(comm_anon, timeout=0.5) + editors_active_msgs = [ + f for f in anon_frames if f.get("type") == "presence.editors_active" + ] + assert len(editors_active_msgs) == 0 + + await comm_anon.disconnect() + await comm_a.disconnect() + await comm_b.disconnect() + + +@pytest.mark.asyncio +@pytest.mark.django_db(transaction=True) +@pytest.mark.websockets +async def test_anonymous_focus_emission_blocked_server_side(data_fixture): + """Anonymous connections cannot emit focus even if a custom client tries.""" + user_a, token_a = data_fixture.create_user_and_token() + workspace = data_fixture.create_workspace(user=user_a) + database = data_fixture.create_database_application(workspace=workspace) + table = data_fixture.create_database_table(database=database) + view = data_fixture.create_grid_view(table=table, public=True) + + comm_a, ws_a = await _connect(token_a) + await comm_a.send_json_to({"page": "table", "table_id": table.id}) + await comm_a.receive_json_from(timeout=1) # page_add + await comm_a.receive_json_from(timeout=1) # presence.members + + comm_anon, ws_anon = await _connect_anonymous() + await comm_anon.send_json_to({"page": "view", "slug": view.slug}) + await comm_anon.receive_json_from(timeout=1) # page_add + await _drain(comm_anon, timeout=0.5) # consume editors_active + await _drain(comm_a, timeout=0.5) # consume join + + await comm_anon.send_json_to( + { + "type": "presence.focus", + "page": "view", + "slug": view.slug, + "focus": {"type": "cell", "row_id": 1, "field_id": 1, "editing": False}, + } + ) + + assert await comm_a.receive_nothing(timeout=0.5) + + await comm_anon.disconnect() + await comm_a.disconnect() diff --git a/changelog/entries/unreleased/feature/5702_show_anonymous_public_view_visitors_in_editors_presence_bar.json b/changelog/entries/unreleased/feature/5702_show_anonymous_public_view_visitors_in_editors_presence_bar.json new file mode 100644 index 0000000000..8e4e783590 --- /dev/null +++ b/changelog/entries/unreleased/feature/5702_show_anonymous_public_view_visitors_in_editors_presence_bar.json @@ -0,0 +1,9 @@ +{ + "type": "feature", + "message": "Show anonymous public view visitors in editor's presence bar", + "issue_origin": "github", + "issue_number": 5702, + "domain": "database", + "bullet_points": [], + "created_at": "2026-07-10" +} diff --git a/web-frontend/modules/core/components/presence/PresenceBadge.vue b/web-frontend/modules/core/components/presence/PresenceBadge.vue index e960a03f8f..ef42ececc8 100644 --- a/web-frontend/modules/core/components/presence/PresenceBadge.vue +++ b/web-frontend/modules/core/components/presence/PresenceBadge.vue @@ -9,7 +9,11 @@