diff --git a/lib/collab/presence/presence_models.dart b/lib/collab/presence/presence_models.dart index 1ac8f5b9..e28717e5 100644 --- a/lib/collab/presence/presence_models.dart +++ b/lib/collab/presence/presence_models.dart @@ -42,6 +42,26 @@ class PresenceCursor { int get hashCode => Object.hash(pageId, x, y); } +/// The lineup groups one connection is editing, on one page: the groups of +/// the lineups and spots it holds, places from, or has open. Edits to one +/// group at the same time conflict, so teammates see it before they start. +class PresenceEditing { + const PresenceEditing({required this.pageId, required this.groupIds}); + + final String pageId; + final Set groupIds; + + @override + bool operator ==(Object other) => + other is PresenceEditing && + other.pageId == pageId && + other.groupIds.length == groupIds.length && + other.groupIds.containsAll(groupIds); + + @override + int get hashCode => Object.hash(pageId, Object.hashAllUnordered(groupIds)); +} + /// One connection to the room. The same person in two windows is two peers /// with one [uid]. class PresencePeer { @@ -53,6 +73,7 @@ class PresencePeer { required this.role, required this.cursor, String? pageId, + this.editing, }) : pageId = pageId ?? cursor?.pageId; final String sid; @@ -69,6 +90,9 @@ class PresencePeer { /// their cursor first shows. final String? pageId; + /// The lineup groups they are editing, if any. + final PresenceEditing? editing; + /// With [cursor] in place of the last one; hiding it keeps [pageId]. PresencePeer withCursor(PresenceCursor? cursor) => PresencePeer( sid: sid, @@ -78,6 +102,19 @@ class PresencePeer { role: role, cursor: cursor, pageId: cursor?.pageId ?? pageId, + editing: editing, + ); + + /// With [editing] in place of what they were editing. + PresencePeer withEditing(PresenceEditing? editing) => PresencePeer( + sid: sid, + uid: uid, + name: name, + avatarUrl: avatarUrl, + role: role, + cursor: cursor, + pageId: pageId, + editing: editing, ); /// This profile, where [earlier] was: its cursor and its page. @@ -89,6 +126,7 @@ class PresencePeer { role: role, cursor: earlier.cursor, pageId: earlier.pageId, + editing: editing ?? earlier.editing, ); } @@ -137,6 +175,20 @@ class PresenceRoomState { ]; } + /// Everyone else editing lineup group [groupId] on [pageId], one entry + /// per person. + List editingLineupGroup(String pageId, String groupId) { + final seen = {}; + return [ + for (final peer in peers.values) + if (peer.uid != selfUid && + peer.editing?.pageId == pageId && + (peer.editing?.groupIds.contains(groupId) ?? false) && + seen.add(peer.uid)) + peer, + ]; + } + /// Other people's cursors on [pageId]. List cursorsOn(String pageId) => [ for (final peer in peers.values) diff --git a/lib/collab/presence/presence_room.dart b/lib/collab/presence/presence_room.dart index f010612f..45ca9773 100644 --- a/lib/collab/presence/presence_room.dart +++ b/lib/collab/presence/presence_room.dart @@ -25,6 +25,7 @@ class PresenceRoom { required RoomPassIssuer issuePass, RoomSocketConnector? connect, this.cursorInterval = const Duration(milliseconds: 50), + this.editingInterval = const Duration(milliseconds: 150), this.keepAliveInterval = const Duration(seconds: 20), this.silenceLimit = const Duration(seconds: 50), this.maxRetryDelay = const Duration(seconds: 30), @@ -34,6 +35,10 @@ class PresenceRoom { final RoomPassIssuer _issuePass; final RoomSocketConnector _connect; final Duration cursorInterval; + + /// The room drops an editing message sent sooner than 100 ms after the + /// last; this keeps clear of it, and only the latest state goes out. + final Duration editingInterval; final Duration keepAliveInterval; final Duration silenceLimit; final Duration maxRetryDelay; @@ -48,6 +53,8 @@ class PresenceRoom { Timer? _renewTimer; Timer? _keepAliveTimer; Timer? _cursorTimer; + Timer? _editingTimer; + DateTime? _lastEditingSentAt; int _failures = 0; DateTime _lastHeard = clock.now(); DateTime _joinedAt = clock.now(); @@ -63,6 +70,10 @@ class PresenceRoom { PresenceCursor? _sentCursor; DateTime? _lastCursorSentAt; + /// The lineup groups this user is editing now, and what the room has. + PresenceEditing? _currentEditing; + PresenceEditing? _sentEditing; + PresenceRoomState get state => _state; Stream get states => _states.stream; @@ -96,12 +107,57 @@ class PresenceRoom { _send({'t': 'hide'}); } + /// The lineup groups this user is editing now; null for none. Sent when it + /// changes, and again after a rejoin. + void setEditing(PresenceEditing? editing) { + _currentEditing = editing; + if (_editingTimer != null) return; + final lastSent = _lastEditingSentAt; + final wait = lastSent == null + ? Duration.zero + : editingInterval - clock.now().difference(lastSent); + if (wait <= Duration.zero) { + _flushEditing(); + } else { + _editingTimer = Timer(wait, _flushEditing); + } + } + + void _flushEditing() { + // A send can come early (a rejoin resends at once); a scheduled one + // must not follow it inside the room's interval. + _editingTimer?.cancel(); + _editingTimer = null; + final editing = _currentEditing; + if (editing == _sentEditing) return; + final sent = _sentEditing; + // Clearing names the page it cleared, as the room expects a page. + final message = editing == null + ? (sent == null + ? null + : {'t': 'editing', 'page': sent.pageId, 'groups': const []}) + : { + 't': 'editing', + 'page': editing.pageId, + // The room refuses more than eight; in practice it is one or + // two. + 'groups': ([...editing.groupIds]..sort()).take(8).toList(), + }; + if (message == null) { + _sentEditing = editing; + } else if (_send(message)) { + _sentEditing = editing; + _lastEditingSentAt = clock.now(); + } + } + void dispose() { _disposed = true; _generation++; _closeSocket(); _retryTimer?.cancel(); _cursorTimer?.cancel(); + _editingTimer?.cancel(); unawaited(_states.close()); } @@ -183,9 +239,12 @@ class PresenceRoom { final next = applyPresenceMessage(_state, decoded); if (decoded['t'] == 'welcome') { _failures = 0; - // After a rejoin the room has forgotten this cursor; send it again. + // After a rejoin the room has forgotten this cursor and what this user + // edits; send them again. final cursor = _currentCursor; if (cursor != null) moveCursor(cursor); + _sentEditing = null; + _flushEditing(); } if (!identical(next, _state)) _emit(next); } @@ -265,6 +324,7 @@ class PresenceRoom { _channel = null; if (channel != null) unawaited(channel.sink.close()); _sentCursor = null; + _sentEditing = null; } void _emit(PresenceRoomState next) { @@ -318,6 +378,15 @@ PresenceRoomState applyPresenceMessage( return state.copyWith( peers: {...state.peers, peer.sid: peer.withCursor(null)}, ); + case 'editing': + final peer = state.peers[message['sid']]; + if (peer == null) return state; + return state.copyWith( + peers: { + ...state.peers, + peer.sid: peer.withEditing(_editingFrom(message)) + }, + ); default: return state; } @@ -334,6 +403,7 @@ PresencePeer? _peerFrom(Object? raw) { } final avatar = raw['avatar']; final cursor = raw['cursor']; + final editing = raw['editing']; return PresencePeer( sid: sid, uid: uid, @@ -341,9 +411,23 @@ PresencePeer? _peerFrom(Object? raw) { avatarUrl: avatar is String && avatar.isNotEmpty ? avatar : null, role: role, cursor: cursor is Map ? _cursorFrom(cursor) : null, + editing: editing is Map ? _editingFrom(editing) : null, ); } +/// What a peer is editing; null when it is nothing, or unreadable. +PresenceEditing? _editingFrom(Map raw) { + final page = raw['page']; + final groups = raw['groups']; + if (page is! String || groups is! List) return null; + final groupIds = { + for (final group in groups) + if (group is String && group.isNotEmpty) group, + }; + if (groupIds.isEmpty) return null; + return PresenceEditing(pageId: page, groupIds: groupIds); +} + PresenceCursor? _cursorFrom(Map raw) { final page = raw['page']; final x = raw['x']; diff --git a/lib/providers/collab/active_page_live_sync_provider.dart b/lib/providers/collab/active_page_live_sync_provider.dart index dc713d65..4177adb3 100644 --- a/lib/providers/collab/active_page_live_sync_provider.dart +++ b/lib/providers/collab/active_page_live_sync_provider.dart @@ -70,6 +70,11 @@ class ActivePageLiveSyncState { } } +/// Bumped whenever the lineup group memory learns a group (see +/// [ActivePageLiveSyncNotifier.lineupGroupOf]), so what reads it can read it +/// again. +final lineupGroupMemoryRevisionProvider = StateProvider((ref) => 0); + final activePageLiveSyncProvider = NotifierProvider( ActivePageLiveSyncNotifier.new, @@ -99,7 +104,21 @@ class ActivePageLiveSyncNotifier extends Notifier { /// Records the groups a canvas of [pageId] about to be drawn from cloud /// rows uses. void noteLineupGroups(String pageId, Map groupOf) { - (_lineupGroupOfByPage[pageId] ??= {}).addAll(groupOf); + _rememberLineupGroups(_lineupGroupOfByPage[pageId] ??= {}, groupOf); + } + + void _rememberLineupGroups( + Map memory, + Map groupOf, + ) { + var learned = false; + for (final MapEntry(:key, :value) in groupOf.entries) { + if (memory[key] != value) { + memory[key] = value; + learned = true; + } + } + if (learned) ref.read(lineupGroupMemoryRevisionProvider.notifier).state++; } /// The server's version of [key] the canvas was drawn from, which local @@ -1038,7 +1057,7 @@ class ActivePageLiveSyncNotifier extends Notifier { key.entityId!, }, ); - lineupGroupOf.addAll(lineupRows.groupOf); + _rememberLineupGroups(lineupGroupOf, lineupRows.groupOf); for (final row in lineupRows.rows) { final key = EntitySyncKey.lineup(pageId, row.publicId); entities[key] = _NormalizedEntity( diff --git a/lib/providers/collab/lineup_editing_presence_provider.dart b/lib/providers/collab/lineup_editing_presence_provider.dart new file mode 100644 index 00000000..7c268b14 --- /dev/null +++ b/lib/providers/collab/lineup_editing_presence_provider.dart @@ -0,0 +1,76 @@ +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/collab/presence/presence_models.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_provider.dart'; +import 'package:icarus/providers/collab/strategy_presence_provider.dart'; +import 'package:icarus/providers/editor_operation_provider.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart'; + +/// The lineups and spots each open lineup dialog shows, by the dialog that +/// opened them, so having one open counts as editing it. +final openLineUpItemsProvider = + NotifierProvider>>( + OpenLineUpItemsNotifier.new, +); + +class OpenLineUpItemsNotifier extends Notifier>> { + @override + Map> build() => const {}; + + void open(Object owner, Set itemIds) { + state = {...state, owner: itemIds}; + } + + void close(Object owner) { + if (!state.containsKey(owner)) return; + state = {...state}..remove(owner); + } +} + +/// The lineup groups this user is editing on the page on screen: those of +/// the lineup spots they hold, the spot a lineup is being placed from, and +/// the lineups an open dialog shows. Null for none. +final myLineupEditingProvider = Provider((ref) { + final pageId = + ref.watch(strategyPageSessionProvider.select((s) => s.activePageId)); + if (pageId == null) return null; + final held = ref.watch(editorHeldEntitiesProvider) ?? const {}; + final placement = ref.watch(lineUpProvider.select((s) => s.placement)); + final open = ref.watch(openLineUpItemsProvider); + ref.watch(lineupGroupMemoryRevisionProvider); + final liveSync = ref.read(activePageLiveSyncProvider.notifier); + final groupIds = { + for (final id in { + ...held, + if (placement?.pinnedOriginId case final id?) id, + if (placement?.pinnedLandingId case final id?) id, + for (final ids in open.values) ...ids, + }) + if (liveSync.lineupGroupOf(pageId, id) case final group?) group, + }; + if (groupIds.isEmpty) return null; + return PresenceEditing(pageId: pageId, groupIds: groupIds); +}); + +/// Teammates editing the lineup group [itemId] (a lineup, origin or landing +/// on the page on screen) is in, one entry per person. +final lineupGroupEditorsProvider = + Provider.autoDispose.family, String>((ref, itemId) { + final pageId = + ref.watch(strategyPageSessionProvider.select((s) => s.activePageId)); + if (pageId == null) return const []; + ref.watch(lineupGroupMemoryRevisionProvider); + final group = ref + .read(activePageLiveSyncProvider.notifier) + .lineupGroupOf(pageId, itemId); + if (group == null) return const []; + // Cursors move many times a second; rebuild only when who is editing + // changes, by watching a key of them rather than the list itself. + ref.watch(strategyPresenceProvider.select((s) => jsonEncode([ + for (final peer in s.editingLineupGroup(pageId, group)) + [peer.uid, peer.name], + ]))); + return ref.read(strategyPresenceProvider).editingLineupGroup(pageId, group); +}); diff --git a/lib/providers/collab/strategy_presence_provider.dart b/lib/providers/collab/strategy_presence_provider.dart index 0b1c7e5c..01679df0 100644 --- a/lib/providers/collab/strategy_presence_provider.dart +++ b/lib/providers/collab/strategy_presence_provider.dart @@ -3,6 +3,7 @@ import 'package:icarus/collab/convex_strategy_repository.dart'; import 'package:icarus/collab/presence/presence_models.dart'; import 'package:icarus/collab/presence/presence_room.dart'; import 'package:icarus/providers/collab/cloud_collab_provider.dart'; +import 'package:icarus/providers/collab/lineup_editing_presence_provider.dart'; import 'package:icarus/providers/strategy_page_session_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; @@ -46,6 +47,12 @@ class StrategyPresenceNotifier extends AutoDisposeNotifier { room.dispose(); }); room.start(); + // What this user is editing goes out as it changes, and from the start. + ref.listen( + myLineupEditingProvider, + (_, editing) => room.setEditing(editing), + fireImmediately: true, + ); return room.state; } diff --git a/lib/widgets/dialogs/create_lineup_dialog.dart b/lib/widgets/dialogs/create_lineup_dialog.dart index 0ddc887a..f8faaaa8 100644 --- a/lib/widgets/dialogs/create_lineup_dialog.dart +++ b/lib/widgets/dialogs/create_lineup_dialog.dart @@ -15,6 +15,8 @@ import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/services/clipboard_service.dart'; import 'package:icarus/services/analytics_service.dart'; import 'package:icarus/widgets/dialogs/strategy/line_up_media_page.dart'; +import 'package:icarus/providers/collab/lineup_editing_presence_provider.dart'; +import 'package:icarus/widgets/lineup_editors_notice.dart'; import 'package:path/path.dart' as path; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:uuid/uuid.dart'; @@ -182,11 +184,35 @@ class _CreateLineupDialogState extends ConsumerState { _imagePaths.addAll(link.images); _initialImageIds.addAll(link.images.map((image) => image.id)); } + // Editing a lineup counts as editing its group (see + // myLineupEditingProvider); providers change after this frame. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ref + .read(openLineUpItemsProvider.notifier) + .open(_openToken, {widget.linkId!}); + }); } } + /// Names this dialog in openLineUpItemsProvider. + final Object _openToken = Object(); + ProviderContainer? _container; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _container ??= ProviderScope.containerOf(context, listen: false); + } + @override void dispose() { + final container = _container; + if (container != null && _isEditing) { + WidgetsBinding.instance.addPostFrameCallback((_) { + container.read(openLineUpItemsProvider.notifier).close(_openToken); + }); + } _nameController.dispose(); _youtubeLinkController.dispose(); _notesController.dispose(); @@ -292,6 +318,8 @@ class _CreateLineupDialogState extends ConsumerState { }, child: ShadDialog( title: Text(_isEditing ? "Edit Lineup" : "Create Lineup"), + description: + _isEditing ? LineUpEditorsNotice(itemId: widget.linkId!) : null, // The close button pops directly, past PopScope, so it steps aside // while Save is queuing. closeIcon: _saving ? const SizedBox.shrink() : null, diff --git a/lib/widgets/dialogs/lineup_panel_dialog.dart b/lib/widgets/dialogs/lineup_panel_dialog.dart index fe4899e8..6b0e4cee 100644 --- a/lib/widgets/dialogs/lineup_panel_dialog.dart +++ b/lib/widgets/dialogs/lineup_panel_dialog.dart @@ -4,10 +4,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/agents.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/collab/lineup_editing_presence_provider.dart'; import 'package:icarus/providers/strategy_image_source.dart'; import 'package:icarus/widgets/custom_text_field.dart'; import 'package:icarus/widgets/dialogs/create_lineup_dialog.dart'; import 'package:icarus/widgets/line_up_media_carousel.dart'; +import 'package:icarus/widgets/lineup_editors_notice.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; /// Opens the lineup panel for a landing spot or an origin: every lineup that @@ -80,10 +82,21 @@ class _LineUpPanelDialogState extends ConsumerState { _container ??= ProviderScope.containerOf(context, listen: false); } + /// The spot the panel is for, which names its lineup group. + String get _spotId => widget.landingId ?? widget.originId!; + @override void initState() { super.initState(); _selectedLinkId = widget.initialLinkId; + // Having the panel open counts as editing its lineups (see + // myLineupEditingProvider); providers change after this frame. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ref.read(openLineUpItemsProvider.notifier).open(_hoverOwnerToken, { + _spotId, + }); + }); } @override @@ -94,6 +107,9 @@ class _LineUpPanelDialogState extends ConsumerState { container .read(hoveredLineUpTargetProvider.notifier) .clearIfOwned(_hoverOwnerToken); + container + .read(openLineUpItemsProvider.notifier) + .close(_hoverOwnerToken); }); } super.dispose(); @@ -196,7 +212,14 @@ class _LineUpPanelDialogState extends ConsumerState { autofocus: true, child: ShadDialog( title: Text(title), - description: Text(subtitle), + description: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(subtitle), + LineUpEditorsNotice(itemId: _spotId, lineups: true), + ], + ), constraints: BoxConstraints(maxWidth: bodyWidth + 48), child: SizedBox( width: bodyWidth, diff --git a/lib/widgets/line_up_media_carousel.dart b/lib/widgets/line_up_media_carousel.dart index 105366eb..a6769881 100644 --- a/lib/widgets/line_up_media_carousel.dart +++ b/lib/widgets/line_up_media_carousel.dart @@ -10,6 +10,7 @@ import 'package:icarus/providers/strategy_image_source.dart'; import 'package:icarus/widgets/dialogs/create_lineup_dialog.dart'; import 'package:icarus/widgets/youtube_view.dart'; +import 'package:icarus/widgets/lineup_editors_notice.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; /// Fullscreen viewer for one lineup's media, with delete and edit actions. @@ -48,13 +49,17 @@ class LineUpMediaCarousel extends ConsumerWidget { youtubeLink: link.youtubeLink, padding: const EdgeInsets.all(56.0), ), + // One row, so the notice gives way to the actions however + // narrow the window. Positioned( top: 24, + left: 24, right: 24, child: SafeArea( child: Row( spacing: 8, children: [ + Expanded(child: LineUpEditorsNotice(itemId: linkId)), ShadIconButton.destructive( icon: const Icon(LucideIcons.trash2), decoration: ShadDecoration( diff --git a/lib/widgets/lineup_editors_notice.dart b/lib/widgets/lineup_editors_notice.dart new file mode 100644 index 00000000..6b9cb9c0 --- /dev/null +++ b/lib/widgets/lineup_editors_notice.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/collab/presence/presence_models.dart'; +import 'package:icarus/providers/collab/lineup_editing_presence_provider.dart'; +import 'package:icarus/widgets/strategy_presence.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +/// Who else is editing the lineups a dialog shows, so the user knows before +/// changing them that edits made at the same time will conflict. Nothing +/// when no one is. +/// +/// [itemId] is any lineup, origin or landing the dialog shows: they are all +/// in one lineup group, and the group is what teammates edit. +class LineUpEditorsNotice extends ConsumerWidget { + const LineUpEditorsNotice({ + super.key, + required this.itemId, + this.lineups = false, + }); + + final String itemId; + + /// The dialog shows several lineups rather than one. + final bool lineups; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final editors = ref.watch(lineupGroupEditorsProvider(itemId)); + if (editors.isEmpty) return const SizedBox.shrink(); + final theme = ShadTheme.of(context); + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: presenceColorFor(editors.first.uid), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + lineUpEditorsText(editors, lineups: lineups), + // The label role: 12px/600. + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + } +} + +/// "Sam is editing this lineup right now." and its plural forms. +String lineUpEditorsText(List editors, {bool lineups = false}) { + final what = lineups ? 'these lineups' : 'this lineup'; + final who = switch (editors) { + [final one] => '${one.name} is', + [final a, final b] => '${a.name} and ${b.name} are', + [final a, ...final rest] => '${a.name} and ${rest.length} others are', + [] => '', + }; + return '$who editing $what right now.'; +} diff --git a/presence/README.md b/presence/README.md index 00f00373..f499fe4d 100644 --- a/presence/README.md +++ b/presence/README.md @@ -20,11 +20,25 @@ stored: cursors live in memory for as long as someone is connected. within two minutes. Messages are JSON. From the client: `cursor {page, x, y}` (canonical -attack-side world coordinates, at most 20 a second), `hide`, `renew {pass}`, -and the text `ping`, which the runtime answers with `pong` without waking the -room. From the room: `welcome {self, uid, peers}`, `join {peer}`, -`leave {sid}`, `cursor {sid, page, x, y}`, `hide {sid}`, `renewed {exp}`. -Close code 4001 means the pass ran out or a renewal was refused. +attack-side world coordinates, at most 20 a second), `hide`, +`editing {page, groups}`, `renew {pass}`, and the text `ping`, which the +runtime answers with `pong` without waking the room. From the room: +`welcome {self, uid, peers}`, `join {peer}`, `leave {sid}`, +`cursor {sid, page, x, y}`, `hide {sid}`, `editing {sid, page, groups}`, +`renewed {exp}`. Close code 4001 means the pass ran out or a renewal was +refused. + +`editing` names the lineup groups a session is editing on one page: at most 8 +group ids of at most 128 characters each. An empty `groups` means editing +nothing, and the room relays it as `{page: null, groups: []}`. The room sorts +the ids, drops duplicates, relays only a change, and ignores a non-empty +`editing` that arrives within 100 ms of the last one it accepted. An ignored +`editing` is dropped, not delayed, so a client that changes faster than that +must hold the latest state and send it once 100 ms have passed. A clear (empty +`groups`) always goes through, however soon it arrives: dropped, it would +leave the notice on everyone's screen. Each peer in `welcome` and `join` +carries `editing` as `{page, groups}` or null. Clients and rooms that predate +`editing` ignore it, so either side can deploy first. ## Environments diff --git a/presence/src/room.ts b/presence/src/room.ts index 134c9181..717a193a 100644 --- a/presence/src/room.ts +++ b/presence/src/room.ts @@ -1,10 +1,10 @@ import { DurableObject } from "cloudflare:workers"; import { verifyPass, type PassClaims, type PassRole } from "./pass"; -// One room per strategy. The room is a relay: it remembers who is connected -// and where their cursor last was, only for as long as they are connected. -// Nothing here is written to storage; a cursor is worth nothing a second -// after it moves. +// One room per strategy. The room is a relay: it remembers who is connected, +// where their cursor last was, and which lineup groups they are editing, only +// for as long as they are connected. Nothing here is written to storage; a +// cursor is worth nothing a second after it moves. export interface Env { PRESENCE_ROOM: DurableObjectNamespace; @@ -17,6 +17,12 @@ export interface Cursor { y: number; } +/** The lineup groups one session is editing on one page. Never empty. */ +export interface Editing { + page: string; + groups: string[]; +} + export interface Peer { sid: string; uid: string; @@ -24,6 +30,7 @@ export interface Peer { avatar: string | null; role: PassRole; cursor: Cursor | null; + editing: Editing | null; } /** Everything the room knows about one socket. Survives hibernation. */ @@ -31,6 +38,7 @@ interface Attachment extends Peer { room: string; exp: number; lastCursorAt: number; + lastEditingAt: number; lastRenewAt: number; } @@ -41,10 +49,14 @@ export const MAX_SESSIONS_PER_USER = 4; export const SWEEP_INTERVAL_MS = 30_000; /** Faster than any client sends (20/s); only a misbehaving client hits it. */ const MIN_CURSOR_INTERVAL_MS = 25; +/** Editing follows clicks, not pointer motion; anything faster is ignored. */ +const MIN_EDITING_INTERVAL_MS = 100; /** Clients renew once a minute; anything faster is ignored unverified. */ const MIN_RENEW_INTERVAL_MS = 10_000; const MAX_MESSAGE_CHARS = 2048; const MAX_PAGE_ID_CHARS = 128; +const MAX_GROUP_ID_CHARS = 128; +const MAX_EDITING_GROUPS = 8; /** The map is 1000 units tall and 1778 wide; allow the margin around it. */ const COORDINATE_LIMIT = 10_000; @@ -87,9 +99,11 @@ export class PresenceRoom extends DurableObject { avatar: claims.avatar, role: claims.role, cursor: null, + editing: null, room: claims.room, exp: claims.exp, lastCursorAt: 0, + lastEditingAt: 0, lastRenewAt: 0, }; server.serializeAttachment(self); @@ -141,6 +155,29 @@ export class PresenceRoom extends DurableObject { this.broadcast({ t: "hide", sid: self.sid }, ws); return; } + case "editing": { + const editing = parseEditing(m); + const now = Date.now(); + // A clear always goes through: dropped, it would leave the notice + // on everyone's screen. It only broadcasts after an accepted change, + // so it cannot flood the room. + if ( + editing === undefined || + (editing !== null && + now - self.lastEditingAt < MIN_EDITING_INTERVAL_MS) || + sameEditing(editing, self.editing) + ) { + return; + } + ws.serializeAttachment({ ...self, editing, lastEditingAt: now }); + this.broadcast( + editing === null + ? { t: "editing", sid: self.sid, page: null, groups: [] } + : { t: "editing", sid: self.sid, ...editing }, + ws, + ); + return; + } case "renew": { const now = Date.now(); if (now - self.lastRenewAt < MIN_RENEW_INTERVAL_MS) return; @@ -280,6 +317,7 @@ function toPeer(a: Attachment): Peer { avatar: a.avatar, role: a.role, cursor: a.cursor, + editing: a.editing, }; } @@ -301,6 +339,43 @@ function parseCursor(m: Record): Cursor | null { return { page, x, y }; } +/** + * The groups an `editing` message names, sorted with duplicates dropped, or + * null when it names none. Undefined when the message is malformed. + */ +function parseEditing(m: Record): Editing | null | undefined { + const { page, groups } = m; + if ( + typeof page !== "string" || + page.length === 0 || + page.length > MAX_PAGE_ID_CHARS || + !Array.isArray(groups) || + !groups.every(isGroupId) + ) { + return undefined; + } + const unique = [...new Set(groups)].sort(); + if (unique.length > MAX_EDITING_GROUPS) return undefined; + return unique.length === 0 ? null : { page, groups: unique }; +} + +function isGroupId(value: unknown): value is string { + return ( + typeof value === "string" && + value.length > 0 && + value.length <= MAX_GROUP_ID_CHARS + ); +} + +function sameEditing(a: Editing | null, b: Editing | null): boolean { + if (a === null || b === null) return a === b; + return ( + a.page === b.page && + a.groups.length === b.groups.length && + a.groups.every((group, i) => group === b.groups[i]) + ); +} + function isCoordinate(value: unknown): value is number { return ( typeof value === "number" && diff --git a/presence/test/presence.test.ts b/presence/test/presence.test.ts index b51f4d7b..00071471 100644 --- a/presence/test/presence.test.ts +++ b/presence/test/presence.test.ts @@ -269,3 +269,155 @@ describe("room", () => { expect(ana.messages).toEqual([]); }); }); + +describe("editing", () => { + // Longer than the room's 100 ms editing interval. + const PAST_INTERVAL_MS = 150; + + /** Ana and Ben in one room, with every arrival message already consumed. */ + async function anaAndBen() { + const ana = await connect(await signPass(claims(), SECRET)); + await ana.next("welcome"); + const ben = await connect(await signPass(claims({ uid: "u-ben", name: "Ben" }), SECRET)); + const benSid = (await ben.next("welcome")).self as string; + await ana.next("join"); + return { ana, ben, benSid }; + } + + function editing(page: unknown, groups: unknown): string { + return JSON.stringify({ t: "editing", page, groups }); + } + + it("relays editing to others without echoing it back", async () => { + const { ana, ben, benSid } = await anaAndBen(); + ben.ws.send(editing("p1", ["g2", "g1", "g2"])); + expect(await ana.next("editing")).toEqual({ + t: "editing", + sid: benSid, + page: "p1", + groups: ["g1", "g2"], + }); + await sleep(100); + expect(ben.messages).toEqual([]); + }); + + it("tells a newcomer what everyone is editing", async () => { + const { ana, ben, benSid } = await anaAndBen(); + ben.ws.send(editing("p1", ["g1"])); + await ana.next("editing"); + + const cy = await connect(await signPass(claims({ uid: "u-cy", name: "Cy" }), SECRET)); + const peers = (await cy.next("welcome")).peers; + expect(peers.find((p: any) => p.sid === benSid).editing).toEqual({ + page: "p1", + groups: ["g1"], + }); + expect(peers.find((p: any) => p.name === "Ana").editing).toBeNull(); + // Peers already in the room hear about Cy, who is editing nothing. + expect((await ana.next("join")).peer.editing).toBeNull(); + }); + + it("broadcasts a clear and forgets it", async () => { + const { ana, ben, benSid } = await anaAndBen(); + ben.ws.send(editing("p1", ["g1"])); + await ana.next("editing"); + await sleep(PAST_INTERVAL_MS); + + ben.ws.send(editing("p1", [])); + expect(await ana.next("editing")).toEqual({ + t: "editing", + sid: benSid, + page: null, + groups: [], + }); + const cy = await connect(await signPass(claims({ uid: "u-cy", name: "Cy" }), SECRET)); + const peers = (await cy.next("welcome")).peers; + expect(peers.find((p: any) => p.sid === benSid).editing).toBeNull(); + }); + + it("ignores malformed editing", async () => { + const { ana, ben } = await anaAndBen(); + const long = "g".repeat(129); + const nine = ["1", "2", "3", "4", "5", "6", "7", "8", "9"]; + ben.ws.send(editing("p".repeat(129), ["g1"])); + ben.ws.send(editing("p1", [long])); + ben.ws.send(editing("p1", nine)); + ben.ws.send(editing("p1", "g1")); + ben.ws.send(editing("p1", { 0: "g1" })); + ben.ws.send(editing("p1", [""])); + ben.ws.send(editing("p1", ["g1", 2])); + ben.ws.send(JSON.stringify({ t: "editing", groups: ["g1"] })); + ben.ws.send(editing("", ["g1"])); + ben.ws.send(editing(7, ["g1"])); + // Malformed messages don't start the rate limit, so this one lands. + ben.ws.send(editing("p1", ["g1", "g1", "g1", "g1", "g1", "g1", "g1", "g1", "g2"])); + expect(await ana.next("editing")).toMatchObject({ page: "p1", groups: ["g1", "g2"] }); + expect(ana.messages.filter((m) => m.t === "editing")).toEqual([]); + }); + + it("ignores editing faster than one per 100 ms", async () => { + const { ana, ben } = await anaAndBen(); + ben.ws.send(editing("p1", ["g1"])); + ben.ws.send(editing("p1", ["g2"])); + expect(await ana.next("editing")).toMatchObject({ groups: ["g1"] }); + await sleep(PAST_INTERVAL_MS); + expect(ana.messages.filter((m) => m.t === "editing")).toEqual([]); + + ben.ws.send(editing("p1", ["g3"])); + expect(await ana.next("editing")).toMatchObject({ groups: ["g3"] }); + }); + + it("relays a clear however soon it follows a change", async () => { + const { ana, ben } = await anaAndBen(); + // Sent 150 ms apart, but the network delivered them together. + ben.ws.send(editing("p1", ["g1"])); + ben.ws.send(editing("p1", [])); + expect(await ana.next("editing")).toMatchObject({ groups: ["g1"] }); + expect(await ana.next("editing")).toMatchObject({ page: null, groups: [] }); + }); + + it("doesn't rebroadcast an unchanged editing", async () => { + const { ana, ben } = await anaAndBen(); + ben.ws.send(editing("p1", ["g1", "g2"])); + await ana.next("editing"); + await sleep(PAST_INTERVAL_MS); + // The same set in another order is the same editing. + ben.ws.send(editing("p1", ["g2", "g1"])); + await sleep(PAST_INTERVAL_MS); + expect(ana.messages.filter((m) => m.t === "editing")).toEqual([]); + + // Neither is an empty clear while editing nothing. + ben.ws.send(editing("p1", [])); + expect(await ana.next("editing")).toMatchObject({ page: null, groups: [] }); + await sleep(PAST_INTERVAL_MS); + ben.ws.send(editing("p2", [])); + await sleep(PAST_INTERVAL_MS); + expect(ana.messages.filter((m) => m.t === "editing")).toEqual([]); + + // The same groups on another page is a change. + ben.ws.send(editing("p2", ["g1", "g2"])); + expect(await ana.next("editing")).toMatchObject({ page: "p2", groups: ["g1", "g2"] }); + }); + + it("keeps editing through a renewal and its profile refresh", async () => { + const { ana, ben, benSid } = await anaAndBen(); + ben.ws.send(editing("p1", ["g1"])); + await ana.next("editing"); + + const pass = await signPass(claims({ uid: "u-ben", name: "Benjamin" }), SECRET); + ben.ws.send(JSON.stringify({ t: "renew", pass })); + await ben.next("renewed"); + expect((await ana.next("join")).peer).toMatchObject({ + sid: benSid, + name: "Benjamin", + editing: { page: "p1", groups: ["g1"] }, + }); + + const cy = await connect(await signPass(claims({ uid: "u-cy", name: "Cy" }), SECRET)); + const peers = (await cy.next("welcome")).peers; + expect(peers.find((p: any) => p.sid === benSid).editing).toEqual({ + page: "p1", + groups: ["g1"], + }); + }); +}); diff --git a/test/collab/presence_room_test.dart b/test/collab/presence_room_test.dart index 83715cac..01d307ed 100644 --- a/test/collab/presence_room_test.dart +++ b/test/collab/presence_room_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; +import 'package:clock/clock.dart'; import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:icarus/collab/presence/presence_models.dart'; @@ -12,10 +13,12 @@ void main() { group('applyPresenceMessage', () { const empty = PresenceRoomState(connection: PresenceConnection.connecting); + // Without [editing] this is the peer an old room sends: no editing key. Map peer( String sid, String uid, { Map? cursor, + Object? editing, }) => { 'sid': sid, @@ -24,6 +27,7 @@ void main() { 'avatar': null, 'role': 'editor', 'cursor': cursor, + if (editing != null) 'editing': editing, }; test('welcome replaces the room and marks it live', () { @@ -142,6 +146,281 @@ void main() { isTrue, ); }); + + test('editing is set, changed, and cleared on one peer', () { + var state = applyPresenceMessage(empty, { + 't': 'welcome', + 'uid': 'me', + 'peers': [ + peer('s1', 'ana', cursor: {'page': 'p1', 'x': 1, 'y': 2}), + peer('s2', 'ben'), + ], + }); + state = applyPresenceMessage(state, { + 't': 'editing', + 'sid': 's1', + 'page': 'p1', + 'groups': ['g1', 'g2'], + }); + expect( + state.peers['s1']!.editing, + const PresenceEditing(pageId: 'p1', groupIds: {'g2', 'g1'}), + ); + // Editing leaves the cursor and the other peer alone. + expect( + state.peers['s1']!.cursor, + const PresenceCursor(pageId: 'p1', x: 1, y: 2), + ); + expect(state.peers['s2']!.editing, isNull); + + state = applyPresenceMessage(state, { + 't': 'editing', + 'sid': 's1', + 'page': 'p2', + 'groups': ['g3'], + }); + expect( + state.peers['s1']!.editing, + const PresenceEditing(pageId: 'p2', groupIds: {'g3'}), + ); + + // The room clears with no page and no groups. + state = applyPresenceMessage(state, { + 't': 'editing', + 'sid': 's1', + 'page': null, + 'groups': [], + }); + expect(state.peers['s1']!.editing, isNull); + expect(state.peers['s1']!.cursor, isNotNull); + }); + + test('editing from an unknown peer changes nothing', () { + final state = applyPresenceMessage(empty, { + 't': 'welcome', + 'uid': 'me', + 'peers': [peer('s1', 'ana')], + }); + expect( + identical( + applyPresenceMessage(state, { + 't': 'editing', + 'sid': 'ghost', + 'page': 'p1', + 'groups': ['g1'], + }), + state, + ), + isTrue, + ); + }); + + test('welcome and join peers carry what they are editing', () { + var state = applyPresenceMessage(empty, { + 't': 'welcome', + 'uid': 'me', + 'peers': [ + peer('s1', 'ana', editing: { + 'page': 'p1', + 'groups': ['g1'], + }), + ], + }); + expect( + state.peers['s1']!.editing, + const PresenceEditing(pageId: 'p1', groupIds: {'g1'}), + ); + + state = applyPresenceMessage(state, { + 't': 'join', + 'peer': peer('s2', 'ben', editing: { + 'page': 'p2', + 'groups': ['g2', 'g3'], + }), + }); + expect( + state.peers['s2']!.editing, + const PresenceEditing(pageId: 'p2', groupIds: {'g2', 'g3'}), + ); + }); + + test('a repeat join keeps what the peer is editing', () { + var state = applyPresenceMessage(empty, { + 't': 'welcome', + 'uid': 'me', + 'peers': [peer('s1', 'ana')], + }); + state = applyPresenceMessage(state, { + 't': 'editing', + 'sid': 's1', + 'page': 'p1', + 'groups': ['g1'], + }); + // A profile refresh, sent without editing. + state = + applyPresenceMessage(state, {'t': 'join', 'peer': peer('s1', 'ana')}); + expect( + state.peers['s1']!.editing, + const PresenceEditing(pageId: 'p1', groupIds: {'g1'}), + ); + }); + + test('malformed editing reads as nothing', () { + final malformed = [ + 'p1', + ['g1'], + { + 'groups': ['g1'] + }, + { + 'page': 3, + 'groups': ['g1'] + }, + {'page': 'p1'}, + {'page': 'p1', 'groups': 'g1'}, + {'page': 'p1', 'groups': []}, + { + 'page': 'p1', + 'groups': [1, '', null], + }, + ]; + for (final editing in malformed) { + final welcomed = applyPresenceMessage(empty, { + 't': 'welcome', + 'uid': 'me', + 'peers': [peer('s1', 'ana', editing: editing)], + }); + expect(welcomed.peers['s1'], isNotNull, reason: '$editing'); + expect(welcomed.peers['s1']!.editing, isNull, reason: '$editing'); + + if (editing is! Map) continue; + final edited = applyPresenceMessage( + applyPresenceMessage(welcomed, { + 't': 'editing', + 'sid': 's1', + 'page': 'p9', + 'groups': ['g9'], + }), + {'t': 'editing', 'sid': 's1', ...editing}, + ); + expect(edited.peers['s1']!.editing, isNull, reason: '$editing'); + } + + // Unreadable group ids are dropped; readable ones stay. + final mixed = applyPresenceMessage(empty, { + 't': 'welcome', + 'uid': 'me', + 'peers': [ + peer('s1', 'ana', editing: { + 'page': 'p1', + 'groups': ['g1', 2, ''], + }), + ], + }); + expect( + mixed.peers['s1']!.editing, + const PresenceEditing(pageId: 'p1', groupIds: {'g1'}), + ); + }); + + test('a welcome from a room older than editing still parses', () { + final state = applyPresenceMessage(empty, { + 't': 'welcome', + 'self': 's-me', + 'uid': 'me', + 'peers': [ + { + 'sid': 's1', + 'uid': 'ana', + 'name': 'Ana', + 'avatar': null, + 'role': 'editor', + 'cursor': {'page': 'p1', 'x': 1, 'y': 2}, + }, + { + 'sid': 's2', + 'uid': 'ben', + 'name': 'Ben', + 'avatar': 'https://example.test/ben.png', + 'role': 'viewer', + 'cursor': null, + }, + ], + }); + expect(state.connection, PresenceConnection.live); + expect(state.peers.keys, ['s1', 's2']); + expect(state.peers.values.map((peer) => peer.editing), [null, null]); + expect(state.peopleOn('p1').single.uid, 'ana'); + expect(state.editingLineupGroup('p1', 'g1'), isEmpty); + }); + }); + + group('PresenceRoomState.editingLineupGroup', () { + PresencePeer editor( + String sid, + String uid, { + String pageId = 'p1', + Set groups = const {'g1'}, + }) => + PresencePeer( + sid: sid, + uid: uid, + name: 'Name $uid', + avatarUrl: null, + role: 'editor', + cursor: null, + editing: PresenceEditing(pageId: pageId, groupIds: groups), + ); + + test('never names yourself, even from another window', () { + final state = PresenceRoomState( + connection: PresenceConnection.live, + selfUid: 'me', + peers: { + 's1': editor('s1', 'me'), + 's2': editor('s2', 'ana'), + }, + ); + expect(state.editingLineupGroup('p1', 'g1').map((peer) => peer.uid), + ['ana']); + }); + + test('names one person in two windows once', () { + final state = PresenceRoomState( + connection: PresenceConnection.live, + selfUid: 'me', + peers: { + 's1': editor('s1', 'ana'), + 's2': editor('s2', 'ana', groups: {'g1', 'g2'}), + 's3': editor('s3', 'ben', groups: {'g2'}), + }, + ); + expect( + state.editingLineupGroup('p1', 'g1').map((peer) => peer.sid), ['s1']); + expect(state.editingLineupGroup('p1', 'g2').map((peer) => peer.uid), + ['ana', 'ben']); + }); + + test('a group on another page does not match', () { + final state = PresenceRoomState( + connection: PresenceConnection.live, + selfUid: 'me', + peers: { + 's1': editor('s1', 'ana', pageId: 'p2'), + 's2': PresencePeer( + sid: 's2', + uid: 'ben', + name: 'Ben', + avatarUrl: null, + role: 'editor', + cursor: const PresenceCursor(pageId: 'p1', x: 0, y: 0), + ), + }, + ); + expect(state.editingLineupGroup('p1', 'g1'), isEmpty); + expect(state.editingLineupGroup('p2', 'g1').single.uid, 'ana'); + expect(state.editingLineupGroup('p2', 'g2'), isEmpty); + }); }); group('PresenceRoom', () { @@ -300,6 +579,221 @@ void main() { presence.dispose(); }); }); + + PresenceEditing editing(String pageId, Set groups) => + PresenceEditing(pageId: pageId, groupIds: groups); + const interval = Duration(milliseconds: 150); + + test('sends what this user edits, sorted, and at most eight groups', () { + fakeAsync((async) { + final presence = room()..start(); + async.flushMicrotasks(); + welcome(async, sockets.single); + expect(presence.editingInterval, interval); + + presence.setEditing(editing('p1', {'g2', 'g1'})); + expect(sockets.single.sent.last, { + 't': 'editing', + 'page': 'p1', + 'groups': ['g1', 'g2'], + }); + + // The room ignores a message naming more than eight. + async.elapse(interval); + presence.setEditing(editing('p1', { + for (var i = 9; i >= 0; i--) 'g$i', + })); + expect(sockets.single.sent.last, { + 't': 'editing', + 'page': 'p1', + 'groups': ['g0', 'g1', 'g2', 'g3', 'g4', 'g5', 'g6', 'g7'], + }); + expect(sockets.single.sentEditing, hasLength(2)); + presence.dispose(); + }); + }); + + test('sends at most one editing per interval, and always the last', () { + fakeAsync((async) { + final presence = room()..start(); + async.flushMicrotasks(); + welcome(async, sockets.single); + + presence.setEditing(editing('p1', {'g1'})); + async.elapse(const Duration(milliseconds: 10)); + presence.setEditing(editing('p1', {'g2'})); + async.elapse(const Duration(milliseconds: 10)); + presence.setEditing(editing('p2', {'g3'})); + expect(sockets.single.sentEditing, hasLength(1)); + + async.elapse(const Duration(milliseconds: 129)); + expect(sockets.single.sentEditing, hasLength(1)); + async.elapse(const Duration(milliseconds: 1)); + expect(sockets.single.sentEditing.last, { + 't': 'editing', + 'page': 'p2', + 'groups': ['g3'], + }); + final at = sockets.single.sentEditingAt; + expect(at[1].difference(at[0]), interval); + + async.elapse(const Duration(seconds: 1)); + expect(sockets.single.sentEditing, hasLength(2)); + + // There and back within the interval: the room already has it. + presence.setEditing(editing('p2', {'g4'})); + expect(sockets.single.sentEditing, hasLength(3)); + presence.setEditing(editing('p2', {'g5'})); + presence.setEditing(editing('p2', {'g4'})); + async.elapse(const Duration(seconds: 1)); + expect(sockets.single.sentEditing, hasLength(3)); + presence.dispose(); + }); + }); + + test('clearing sends no groups for the page it last sent', () { + fakeAsync((async) { + final presence = room()..start(); + async.flushMicrotasks(); + welcome(async, sockets.single); + + presence.setEditing(editing('p1', {'g1'})); + async.elapse(interval); + presence.setEditing(editing('p2', {'g2'})); + async.elapse(interval); + presence.setEditing(null); + expect(sockets.single.sent.last, { + 't': 'editing', + 'page': 'p2', + 'groups': [], + }); + + // A clear that waits out the interval still names the page. + async.elapse(interval); + presence.setEditing(editing('p3', {'g3'})); + presence.setEditing(null); + async.elapse(interval); + expect(sockets.single.sentEditing, hasLength(5)); + expect(sockets.single.sent.last, { + 't': 'editing', + 'page': 'p3', + 'groups': [], + }); + + // Something that never went out needs no clear. + presence.setEditing(editing('p4', {'g4'})); + presence.setEditing(null); + async.elapse(const Duration(seconds: 1)); + expect(sockets.single.sentEditing, hasLength(5)); + presence.dispose(); + }); + }); + + test('sends nothing when what this user edits is unchanged', () { + fakeAsync((async) { + final presence = room()..start(); + async.flushMicrotasks(); + welcome(async, sockets.single); + + // Nothing edited, nothing to clear. + presence.setEditing(null); + async.elapse(interval); + expect(sockets.single.sentEditing, isEmpty); + + presence.setEditing(editing('p1', {'g1', 'g2'})); + async.elapse(interval); + presence.setEditing(editing('p1', {'g2', 'g1'})); + async.elapse(const Duration(seconds: 1)); + expect(sockets.single.sentEditing, hasLength(1)); + + presence.setEditing(null); + async.elapse(interval); + presence.setEditing(null); + async.elapse(const Duration(seconds: 1)); + expect(sockets.single.sentEditing, hasLength(2)); + presence.dispose(); + }); + }); + + test('rejoins after a drop and sends what this user edits again', () { + fakeAsync((async) { + final presence = room()..start(); + async.flushMicrotasks(); + welcome(async, sockets.first); + presence.setEditing(editing('p1', {'g1'})); + expect(sockets.first.sentEditing, hasLength(1)); + + sockets.first.drop(1006); + async.flushMicrotasks(); + // Changed while away: only the latest goes out after the rejoin. + presence.setEditing(editing('p1', {'g2'})); + async.elapse(interval); + expect(sockets.first.sentEditing, hasLength(1)); + + async.elapse(const Duration(seconds: 1)); + expect(sockets, hasLength(2)); + expect(sockets.last.sentEditing, isEmpty); + welcome(async, sockets.last); + expect(sockets.last.sentEditing, [ + { + 't': 'editing', + 'page': 'p1', + 'groups': ['g2'], + }, + ]); + async.elapse(const Duration(seconds: 1)); + expect(sockets.last.sentEditing, hasLength(1)); + + // Stopped while away: the new room never heard of it, so nothing + // goes out. + sockets.last.drop(1006); + async.flushMicrotasks(); + presence.setEditing(null); + async.elapse(const Duration(seconds: 2)); + expect(sockets, hasLength(3)); + welcome(async, sockets.last); + async.elapse(const Duration(seconds: 1)); + expect(sockets.last.sentEditing, isEmpty); + presence.dispose(); + }); + }); + + test('a quick rejoin keeps editing messages an interval apart', () { + fakeAsync((async) { + final presence = room()..start(); + async.flushMicrotasks(); + welcome(async, sockets.first); + // Long enough that a lapsed pass rejoins at once. + async.elapse(const Duration(seconds: 11)); + + presence.setEditing(editing('p1', {'g1'})); + async.elapse(const Duration(milliseconds: 10)); + // Waits for the interval. + presence.setEditing(editing('p1', {'g2'})); + sockets.first.drop(closePassExpired); + async.flushMicrotasks(); + expect(sockets, hasLength(2)); + + async.elapse(const Duration(milliseconds: 90)); + welcome(async, sockets.last); // resends g2 + async.elapse(const Duration(milliseconds: 10)); + presence.setEditing(editing('p1', {'g3'})); + async.elapse(const Duration(seconds: 1)); + + expect(sockets.last.sentEditing.last['groups'], ['g3']); + // The room drops an editing message sent within 100 ms of the last, + // so the last state would never reach it. + final at = sockets.last.sentEditingAt; + for (var i = 1; i < at.length; i++) { + expect( + at[i].difference(at[i - 1]), + greaterThanOrEqualTo(presence.editingInterval), + reason: 'editing message $i of ${sockets.last.sentEditing}', + ); + } + presence.dispose(); + }); + }); }); } @@ -310,6 +804,9 @@ class _FakeSocket extends StreamChannelMixin final Uri uri; final _incoming = StreamController(); final List sent = []; + + /// When each of [sent] went out, by the (fake) clock. + final List sentAt = []; bool closed = false; int? _closeCode; @@ -319,6 +816,17 @@ class _FakeSocket extends StreamChannelMixin (message['x'] as num).toDouble(), ]; + List> get sentEditing => [ + for (final message in sent) + if (message is Map && message['t'] == 'editing') message, + ]; + + /// When each of [sentEditing] went out. + List get sentEditingAt => [ + for (final (index, message) in sent.indexed) + if (message is Map && message['t'] == 'editing') sentAt[index], + ]; + void receive(Map message) => _incoming.add(jsonEncode(message)); @@ -357,6 +865,7 @@ class _FakeSink implements WebSocketSink { void add(dynamic data) { final text = data as String; _socket.sent.add(text == 'ping' ? text : jsonDecode(text)); + _socket.sentAt.add(clock.now()); } @override diff --git a/test/providers/lineup_editing_presence_provider_test.dart b/test/providers/lineup_editing_presence_provider_test.dart new file mode 100644 index 00000000..b6436dcd --- /dev/null +++ b/test/providers/lineup_editing_presence_provider_test.dart @@ -0,0 +1,186 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/presence/presence_models.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_provider.dart'; +import 'package:icarus/providers/collab/lineup_editing_presence_provider.dart'; +import 'package:icarus/providers/editor_operation_provider.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart'; + +/// The editor showing page p1, without the session's cloud machinery. +class _FixedSession extends StrategyPageSessionNotifier { + @override + StrategyPageSessionState build() => const StrategyPageSessionState( + activePageId: 'p1', + availablePageIds: ['p1', 'p2'], + transitionState: PageTransitionState.idle, + isApplyingPage: false, + ); +} + +/// Two lineups, each from its own origin to its own landing: two groups. +LineUpGraph _graph() { + final ability = AgentData.agents[AgentType.breach]!.abilities.first; + LineUpOrigin origin(String id) => LineUpOrigin( + id: id, + agent: PlacedAgent( + id: '$id-agent', + type: AgentType.breach, + position: const Offset(20, 20), + ), + ); + LineUpLanding landing(String id) => LineUpLanding( + id: id, + ability: PlacedAbility( + id: '$id-ability', + data: ability, + position: const Offset(50, 50), + ), + ); + return LineUpGraph( + origins: [origin('origin-a'), origin('origin-b')], + landings: [landing('landing-a'), landing('landing-b')], + links: [ + LineUpLink(id: 'link-a', originId: 'origin-a', landingId: 'landing-a'), + LineUpLink(id: 'link-b', originId: 'origin-b', landingId: 'landing-b'), + ], + ); +} + +void main() { + late ProviderContainer container; + + setUp(() { + container = ProviderContainer(overrides: [ + strategyPageSessionProvider.overrideWith(_FixedSession.new), + ]); + addTearDown(container.dispose); + container.read(lineUpProvider.notifier).fromHive(_graph()); + container.read(activePageLiveSyncProvider.notifier) + ..noteLineupGroups('p1', { + 'origin-a': 'group-a', + 'landing-a': 'group-a', + 'link-a': 'group-a', + 'origin-b': 'group-b', + 'landing-b': 'group-b', + 'link-b': 'group-b', + }) + // The same lineups on a duplicated page are another page's groups. + ..noteLineupGroups('p2', {'landing-a': 'group-a2'}); + }); + + PresenceEditing? editing() => container.read(myLineupEditingProvider); + + test('nothing edited is null', () { + expect(editing(), isNull); + }); + + test('a held lineup spot is editing its group', () { + container.read(editorPointersProvider.notifier).holdEntity(1, 'landing-a'); + expect( + editing(), + const PresenceEditing(pageId: 'p1', groupIds: {'group-a'}), + ); + }); + + test('held items that are not lineups are ignored', () { + final pointers = container.read(editorPointersProvider.notifier); + pointers.holdEntity(1, 'some-agent'); + expect(editing(), isNull); + pointers.holdEntity(1, 'origin-b'); + expect( + editing(), + const PresenceEditing(pageId: 'p1', groupIds: {'group-b'}), + ); + }); + + test('placing from a pinned spot is editing its group', () { + container.read(lineUpProvider.notifier).startFromOrigin('origin-b'); + expect( + editing(), + const PresenceEditing(pageId: 'p1', groupIds: {'group-b'}), + ); + container.read(lineUpProvider.notifier).startToLanding('landing-a'); + expect( + editing(), + const PresenceEditing(pageId: 'p1', groupIds: {'group-a'}), + ); + // A fresh lineup belongs to no group yet. + container.read(lineUpProvider.notifier).startFresh(); + expect(editing(), isNull); + }); + + test('an open dialog is editing the groups of the items it shows', () { + final open = container.read(openLineUpItemsProvider.notifier); + final panel = Object(); + final editor = Object(); + open.open(panel, {'landing-a'}); + expect( + editing(), + const PresenceEditing(pageId: 'p1', groupIds: {'group-a'}), + ); + open.open(editor, {'link-b', 'not-a-lineup'}); + expect( + editing(), + const PresenceEditing(pageId: 'p1', groupIds: {'group-a', 'group-b'}), + ); + open.close(panel); + expect( + editing(), + const PresenceEditing(pageId: 'p1', groupIds: {'group-b'}), + ); + open.close(editor); + expect(container.read(openLineUpItemsProvider), isEmpty); + expect(editing(), isNull); + }); + + test('held, placed and open items together name each group once', () { + container.read(editorPointersProvider.notifier).holdEntity(1, 'link-a'); + container.read(lineUpProvider.notifier).startToLanding('landing-a'); + container + .read(openLineUpItemsProvider.notifier) + .open(Object(), {'origin-b'}); + expect( + editing(), + const PresenceEditing(pageId: 'p1', groupIds: {'group-a', 'group-b'}), + ); + }); + + test('a group learned while a dialog is open is edited from then on', () { + container + .read(openLineUpItemsProvider.notifier) + .open(Object(), {'link-new'}); + expect(editing(), isNull); + // The lineup's group arrives (its first sync, or the page redrawn from + // the server) while the dialog stays open. + container + .read(activePageLiveSyncProvider.notifier) + .noteLineupGroups('p1', {'link-new': 'group-new'}); + expect( + editing(), + const PresenceEditing(pageId: 'p1', groupIds: {'group-new'}), + ); + }); + + test('groups are those of the page on screen', () { + container + .read(openLineUpItemsProvider.notifier) + .open(Object(), {'landing-a', 'landing-b'}); + final session = container.read(strategyPageSessionProvider.notifier); + session.setStateForTest( + container.read(strategyPageSessionProvider).copyWith(activePageId: 'p2'), + ); + expect( + editing(), + const PresenceEditing(pageId: 'p2', groupIds: {'group-a2'}), + ); + session.setStateForTest( + container + .read(strategyPageSessionProvider) + .copyWith(clearActivePageId: true), + ); + expect(editing(), isNull); + }); +} diff --git a/test/widgets/strategy_presence_test.dart b/test/widgets/strategy_presence_test.dart index 709dfcc7..92a85711 100644 --- a/test/widgets/strategy_presence_test.dart +++ b/test/widgets/strategy_presence_test.dart @@ -1,9 +1,18 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_provider.dart'; +import 'package:icarus/providers/collab/lineup_editing_presence_provider.dart'; import 'package:icarus/providers/collab/strategy_presence_provider.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart'; +import 'package:icarus/widgets/dialogs/lineup_panel_dialog.dart'; +import 'package:icarus/widgets/lineup_editors_notice.dart'; import 'package:icarus/widgets/strategy_presence.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -16,7 +25,13 @@ class _FixedPresence extends StrategyPresenceNotifier { PresenceRoomState build() => _state; } -PresencePeer _peer(String sid, String uid, String name, String role) => +PresencePeer _peer( + String sid, + String uid, + String name, + String role, { + PresenceEditing? editing, +}) => PresencePeer( sid: sid, uid: uid, @@ -24,8 +39,89 @@ PresencePeer _peer(String sid, String uid, String name, String role) => avatarUrl: null, role: role, cursor: null, + editing: editing, ); +/// The editor showing page p1, without the session's cloud machinery. +class _FixedSession extends StrategyPageSessionNotifier { + @override + StrategyPageSessionState build() => const StrategyPageSessionState( + activePageId: 'p1', + availablePageIds: ['p1'], + transitionState: PageTransitionState.idle, + isApplyingPage: false, + ); +} + +/// Two lineups from two origins landing on one spot, all lineup group g1. +LineUpGraph _landingGraph() { + LineUpOrigin origin(String id) => LineUpOrigin( + id: id, + agent: PlacedAgent( + id: '$id-agent', + type: AgentType.breach, + position: const Offset(20, 20), + ), + ); + return LineUpGraph( + origins: [origin('origin-1'), origin('origin-2')], + landings: [ + LineUpLanding( + id: 'landing', + ability: PlacedAbility( + id: 'landing-ability', + data: AgentData.agents[AgentType.breach]!.abilities.first, + position: const Offset(50, 50), + ), + ), + ], + links: [ + LineUpLink(id: 'link-1', originId: 'origin-1', landingId: 'landing'), + LineUpLink(id: 'link-2', originId: 'origin-2', landingId: 'landing'), + ], + ); +} + +/// An editor on page p1 whose landing spot opens the lineup panel. +ProviderContainer _panelContainer(PresenceRoomState presence) { + final container = ProviderContainer(overrides: [ + strategyPresenceProvider.overrideWith(() => _FixedPresence(presence)), + strategyPageSessionProvider.overrideWith(_FixedSession.new), + ]); + container.read(lineUpProvider.notifier).fromHive(_landingGraph()); + container.read(activePageLiveSyncProvider.notifier).noteLineupGroups('p1', { + 'origin-1': 'g1', + 'origin-2': 'g1', + 'landing': 'g1', + 'link-1': 'g1', + 'link-2': 'g1', + }); + return container; +} + +Future _openPanel( + WidgetTester tester, ProviderContainer container) async { + tester.view.physicalSize = const Size(1400, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget(UncontrolledProviderScope( + container: container, + child: ShadApp( + home: Scaffold( + body: Builder( + builder: (context) => ShadButton( + onPressed: () => showLineUpPanel(context, landingId: 'landing'), + child: const Text('Open panel'), + ), + ), + ), + ), + )); + await tester.tap(find.text('Open panel')); + await tester.pumpAndSettle(); + expect(find.byType(LineUpPanelDialog), findsOneWidget); +} + Widget _app(PresenceRoomState state, Widget child) => ProviderScope( overrides: [ strategyPresenceProvider.overrideWith(() => _FixedPresence(state)), @@ -100,4 +196,95 @@ void main() { hasLength(Settings.presenceColors.length), ); }); + + group('lineUpEditorsText', () { + final sam = _peer('s1', 'sam', 'Sam', 'editor'); + final ana = _peer('s2', 'ana', 'Ana', 'editor'); + final ben = _peer('s3', 'ben', 'Ben', 'editor'); + final cy = _peer('s4', 'cy', 'Cy', 'editor'); + + test('names one editor', () { + expect(lineUpEditorsText([sam]), 'Sam is editing this lineup right now.'); + expect(lineUpEditorsText([sam], lineups: true), + 'Sam is editing these lineups right now.'); + }); + + test('names two editors', () { + expect(lineUpEditorsText([sam, ana]), + 'Sam and Ana are editing this lineup right now.'); + expect(lineUpEditorsText([sam, ana], lineups: true), + 'Sam and Ana are editing these lineups right now.'); + }); + + test('counts the rest past one editor when there are three or more', () { + expect(lineUpEditorsText([sam, ana, ben]), + 'Sam and 2 others are editing this lineup right now.'); + expect(lineUpEditorsText([sam, ana, ben, cy], lineups: true), + 'Sam and 3 others are editing these lineups right now.'); + }); + }); + + group('lineup panel', () { + const sams = PresenceEditing(pageId: 'p1', groupIds: {'g1'}); + + testWidgets('says who else is editing the lineups of its spot', + (tester) async { + final container = _panelContainer(PresenceRoomState( + connection: PresenceConnection.live, + selfUid: 'me', + peers: {'s1': _peer('s1', 'sam', 'Sam', 'editor', editing: sams)}, + )); + addTearDown(container.dispose); + await _openPanel(tester, container); + expect( + find.text('Sam is editing these lineups right now.'), + findsOneWidget, + ); + }); + + testWidgets('says nothing when no one else edits its lineups', + (tester) async { + final container = _panelContainer(PresenceRoomState( + connection: PresenceConnection.live, + selfUid: 'me', + peers: { + // Another group, the same group on another page, and you. + 's1': _peer('s1', 'sam', 'Sam', 'editor', + editing: const PresenceEditing(pageId: 'p1', groupIds: {'g2'})), + 's2': _peer('s2', 'ana', 'Ana', 'editor', + editing: const PresenceEditing(pageId: 'p2', groupIds: {'g1'})), + 's3': _peer('s3', 'me', 'Me elsewhere', 'editor', editing: sams), + 's4': _peer('s4', 'ben', 'Ben', 'editor'), + }, + )); + addTearDown(container.dispose); + await _openPanel(tester, container); + expect(find.byType(LineUpEditorsNotice), findsOneWidget); + expect(find.textContaining('editing'), findsNothing); + }); + + testWidgets('counts as editing its spot while it is open', (tester) async { + final container = _panelContainer(const PresenceRoomState( + connection: PresenceConnection.live, + selfUid: 'me', + )); + addTearDown(container.dispose); + expect(container.read(openLineUpItemsProvider), isEmpty); + + await _openPanel(tester, container); + expect(container.read(openLineUpItemsProvider).values, [ + {'landing'}, + ]); + expect( + container.read(myLineupEditingProvider), + const PresenceEditing(pageId: 'p1', groupIds: {'g1'}), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(find.byType(LineUpPanelDialog), findsNothing); + expect(container.read(openLineUpItemsProvider), isEmpty); + expect(container.read(myLineupEditingProvider), isNull); + }); + }); }