feat: who's viewing, and ad-hoc parties that watch together
The project socket already carried scene deltas; it now also carries presence.
The top bar grows a Google-Docs-style cluster of faces, and the menu behind it
groups the room into parties, then everyone flying solo, with a Join on each.
A party has no host — you only join one. Joining someone solo adopts *their
cid* as the party id, so two people clicking Join in the same instant converge
instead of minting two parties of one, and the party outlives whoever was
joined first. Everyone in it drives everyone else: a jump, a scrub or a play
from any member moves all the others. That's why being joined needs consent
("Let others join me", on by default, remembered) and why Leave is one click.
Ordering was the thing to get right. A save is a PUT and a jump rides the
socket, so "create an annotation, then jump into it" can arrive at a peer in
the wrong order. Rather than truncate the stack and strand them at the root, a
nav naming a group we haven't been told about is parked and replayed the
moment the delta lands. Authoring parks it for the same reason: a draft
detaches you from the party so hunting for marks is nobody else's business,
and closing the form replays the park, putting you exactly where the party got
to. Deleting a timeline someone is standing in now pops them out too.
Playback ticks stay off the wire — every member runs the same clip off its own
clock, so streaming positions would only fight them. Only deliberate moves and
transport changes go out, and a play we started because a peer did isn't
echoed back at them.
The socket now reconnects with backoff and, on the way back, re-states the
party id it was carrying and pulls the scene it missed. That catch-up is
deliberately additive: "absent from the server" can also mean "saved a moment
ago", and a wrong deletion costs someone their work.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
12ab7c4fca
commit
60c4096d2f
11 changed files with 898 additions and 52 deletions
|
|
@ -1,22 +1,60 @@
|
|||
import json
|
||||
import uuid
|
||||
|
||||
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||
|
||||
|
||||
class SceneConsumer(AsyncWebsocketConsumer):
|
||||
"""One connection per open project. Joins the project's group and relays
|
||||
scene deltas the server broadcasts (see scenes.views.scene). Read-only:
|
||||
edits still go through the PUT endpoint, which is the broadcast source."""
|
||||
"""One connection per open project. Two things ride it.
|
||||
|
||||
Scene deltas: relayed from what the server broadcasts on a save (see
|
||||
scenes.views.scene). Read-only — edits still go through the PUT endpoint,
|
||||
which is the broadcast source.
|
||||
|
||||
Presence: who's viewing, which ad-hoc party they're in, and where that party
|
||||
is looking. Peers gossip it between themselves and work the party out
|
||||
locally (tl.party); all this does is hand out a connection id, stamp the
|
||||
sender's server-side identity onto every message — so nobody can post as
|
||||
somebody else — and relay. Nothing about a party is stored anywhere.
|
||||
"""
|
||||
|
||||
RELAYED = ("state", "nav")
|
||||
|
||||
async def connect(self):
|
||||
self.pk = self.scope['url_route']['kwargs']['pk']
|
||||
self.group = f'project_{self.pk}'
|
||||
self.pk = self.scope["url_route"]["kwargs"]["pk"]
|
||||
self.group = f"project_{self.pk}"
|
||||
self.cid = uuid.uuid4().hex[:12]
|
||||
user = self.scope.get("user")
|
||||
self.username = user.get_username() if user and user.is_authenticated else None
|
||||
await self.channel_layer.group_add(self.group, self.channel_name)
|
||||
await self.accept()
|
||||
await self.send(text_data=json.dumps(
|
||||
{"kind": "welcome", "cid": self.cid, "user": self.username}))
|
||||
# the room answers a join with their own state, so rosters fill in both ways
|
||||
await self._relay({"kind": "join"})
|
||||
|
||||
async def disconnect(self, code):
|
||||
await self.channel_layer.group_discard(self.group, self.channel_name)
|
||||
if hasattr(self, "cid"):
|
||||
await self._relay({"kind": "leave"})
|
||||
await self.channel_layer.group_discard(self.group, self.channel_name)
|
||||
|
||||
async def receive(self, text_data=None, bytes_data=None):
|
||||
try:
|
||||
msg = json.loads(text_data or "{}")
|
||||
except ValueError:
|
||||
return
|
||||
if isinstance(msg, dict) and msg.get("kind") in self.RELAYED:
|
||||
await self._relay(msg)
|
||||
|
||||
async def _relay(self, msg):
|
||||
await self.channel_layer.group_send(
|
||||
self.group,
|
||||
{"type": "peer.msg", "msg": {**msg, "cid": self.cid, "user": self.username}})
|
||||
|
||||
# broadcast handler: {type: "peer.msg", msg: {...}} — presence gossip
|
||||
async def peer_msg(self, event):
|
||||
await self.send(text_data=json.dumps(event["msg"]))
|
||||
|
||||
# broadcast handler: {type: "scene.delta", delta: {...}}
|
||||
async def scene_delta(self, event):
|
||||
await self.send(text_data=json.dumps(event['delta']))
|
||||
await self.send(text_data=json.dumps(event["delta"]))
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import json
|
||||
|
||||
from channels.testing import WebsocketCommunicator
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import Client, TestCase
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.test import Client, TestCase, TransactionTestCase
|
||||
|
||||
from scenes.models import Project, Revision
|
||||
from server.asgi import application
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
|
@ -103,3 +106,58 @@ class AccessTests(SceneApiTestCase):
|
|||
User.objects.create_user("carol", password="pw")
|
||||
carol = Client(); carol.login(username="carol", password="pw")
|
||||
self.assertEqual(self.put(carol, changed={"a1": ann("x")}).status_code, 404)
|
||||
|
||||
|
||||
class PresenceRelayTests(TransactionTestCase):
|
||||
"""The socket hands out connection ids and stamps identity; the party itself
|
||||
is worked out in the browsers, so there is nothing here to store or trust."""
|
||||
|
||||
def setUp(self):
|
||||
self.alice = User.objects.create_user("alice", password="pw")
|
||||
self.project = Project.objects.create(owner=self.alice, name="p")
|
||||
|
||||
async def open(self, user=None):
|
||||
comm = WebsocketCommunicator(application, f"/ws/projects/{self.project.pk}/")
|
||||
comm.scope["user"] = user or AnonymousUser()
|
||||
connected, _ = await comm.connect()
|
||||
self.assertTrue(connected)
|
||||
return comm, await comm.receive_json_from()
|
||||
|
||||
async def test_welcome_carries_an_id_and_the_signed_in_name(self):
|
||||
comm, welcome = await self.open(self.alice)
|
||||
self.assertEqual(welcome["kind"], "welcome")
|
||||
self.assertEqual(welcome["user"], "alice")
|
||||
self.assertTrue(welcome["cid"])
|
||||
await comm.disconnect()
|
||||
|
||||
async def test_presence_is_stamped_with_the_server_side_identity(self):
|
||||
a, a_hello = await self.open(self.alice)
|
||||
await a.receive_json_from() # our own join, echoed back
|
||||
b, _ = await self.open()
|
||||
self.assertEqual((await a.receive_json_from())["kind"], "join")
|
||||
|
||||
await b.send_json_to({"kind": "state", "party": a_hello["cid"],
|
||||
"joinable": True, "joining": a_hello["cid"],
|
||||
"user": "alice", "cid": "forged"})
|
||||
seen = await a.receive_json_from()
|
||||
self.assertEqual(seen["party"], a_hello["cid"])
|
||||
self.assertIsNone(seen["user"]) # anonymous, not "alice"
|
||||
self.assertNotEqual(seen["cid"], "forged")
|
||||
await a.disconnect(); await b.disconnect()
|
||||
|
||||
async def test_scene_edits_are_not_accepted_over_the_socket(self):
|
||||
a, _ = await self.open(self.alice)
|
||||
await a.receive_json_from() # join
|
||||
await a.send_json_to({"kind": "scene", "changed": {"a1": ann("x")}})
|
||||
self.assertTrue(await a.receive_nothing(timeout=0.2))
|
||||
await a.disconnect()
|
||||
|
||||
async def test_leaving_tells_the_room(self):
|
||||
a, _ = await self.open(self.alice)
|
||||
await a.receive_json_from() # join
|
||||
b, b_hello = await self.open()
|
||||
await a.receive_json_from() # b's join
|
||||
await b.disconnect()
|
||||
bye = await a.receive_json_from()
|
||||
self.assertEqual((bye["kind"], bye["cid"]), ("leave", b_hello["cid"]))
|
||||
await a.disconnect()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue