Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions lib/collab/presence/presence_models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> 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 {
Expand All @@ -53,6 +73,7 @@ class PresencePeer {
required this.role,
required this.cursor,
String? pageId,
this.editing,
}) : pageId = pageId ?? cursor?.pageId;

final String sid;
Expand All @@ -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,
Expand All @@ -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.
Expand All @@ -89,6 +126,7 @@ class PresencePeer {
role: role,
cursor: earlier.cursor,
pageId: earlier.pageId,
editing: editing ?? earlier.editing,
);
}

Expand Down Expand Up @@ -137,6 +175,20 @@ class PresenceRoomState {
];
}

/// Everyone else editing lineup group [groupId] on [pageId], one entry
/// per person.
List<PresencePeer> editingLineupGroup(String pageId, String groupId) {
final seen = <String>{};
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<PresencePeer> cursorsOn(String pageId) => [
for (final peer in peers.values)
Expand Down
86 changes: 85 additions & 1 deletion lib/collab/presence/presence_room.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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;
Expand All @@ -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();
Expand All @@ -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<PresenceRoomState> get states => _states.stream;

Expand Down Expand Up @@ -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 <String>[]})
: {
'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(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Ninth group loses notice

When open dialogs or held items cover more than eight distinct lineup groups, this truncation silently omits the remaining groups from presence. Collaborators receive no editing notice for an omitted group, making the notice incomplete in this non-blocking edge case.

Artifacts

Flutter test source for open-dialog group presence

  • The authored test creates distinct dialog groups, captures the real client outbound payload, and applies it to the remote presence reader.

Eight-group control test output

  • The Flutter test ran with eight open-dialog groups and captured all eight outbound and remotely visible, with none missing.

Nine-group test output

  • The Flutter test ran with nine open-dialog groups and captured an eight-group payload, missing `group-09` and leaving it without a remote editor.

View artifacts

T-Rex Ran code and verified through T-Rex

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intended. The room refuses more than 8 groups, so the client sends at most 8 rather than having the whole message dropped. In practice it's one or two: a held spot, a pinned placement, an open dialog. Editing nine lineup groups at once doesn't happen.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That’s fair. Since the room accepts at most eight groups, truncating the client payload to eight is the correct compatibility behavior, and the expected editing flows normally involve only one or two groups. I’m withdrawing this as a finding; no change is needed.

};
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());
}

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -265,6 +324,7 @@ class PresenceRoom {
_channel = null;
if (channel != null) unawaited(channel.sink.close());
_sentCursor = null;
_sentEditing = null;
}

void _emit(PresenceRoomState next) {
Expand Down Expand Up @@ -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;
}
Expand All @@ -334,16 +403,31 @@ PresencePeer? _peerFrom(Object? raw) {
}
final avatar = raw['avatar'];
final cursor = raw['cursor'];
final editing = raw['editing'];
return PresencePeer(
sid: sid,
uid: uid,
name: name,
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<dynamic, dynamic> 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<dynamic, dynamic> raw) {
final page = raw['page'];
final x = raw['x'];
Expand Down
23 changes: 21 additions & 2 deletions lib/providers/collab/active_page_live_sync_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>((ref) => 0);

final activePageLiveSyncProvider =
NotifierProvider<ActivePageLiveSyncNotifier, ActivePageLiveSyncState>(
ActivePageLiveSyncNotifier.new,
Expand Down Expand Up @@ -99,7 +104,21 @@ class ActivePageLiveSyncNotifier extends Notifier<ActivePageLiveSyncState> {
/// Records the groups a canvas of [pageId] about to be drawn from cloud
/// rows uses.
void noteLineupGroups(String pageId, Map<String, String> groupOf) {
(_lineupGroupOfByPage[pageId] ??= {}).addAll(groupOf);
_rememberLineupGroups(_lineupGroupOfByPage[pageId] ??= {}, groupOf);
}

void _rememberLineupGroups(
Map<String, String> memory,
Map<String, String> 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
Expand Down Expand Up @@ -1038,7 +1057,7 @@ class ActivePageLiveSyncNotifier extends Notifier<ActivePageLiveSyncState> {
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(
Expand Down
76 changes: 76 additions & 0 deletions lib/providers/collab/lineup_editing_presence_provider.dart
Original file line number Diff line number Diff line change
@@ -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, Map<Object, Set<String>>>(
OpenLineUpItemsNotifier.new,
);

class OpenLineUpItemsNotifier extends Notifier<Map<Object, Set<String>>> {
@override
Map<Object, Set<String>> build() => const {};

void open(Object owner, Set<String> 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<PresenceEditing?>((ref) {
final pageId =
ref.watch(strategyPageSessionProvider.select((s) => s.activePageId));
if (pageId == null) return null;
final held = ref.watch(editorHeldEntitiesProvider) ?? const <String>{};
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,
Comment on lines +49 to +51

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Dialog follows wrong page

An edit dialog can remain open after its page changes, but its registered lineup ID is looked up on the newly active page. If that page has a lineup with the same ID, collaborators see an editing notice for the new page’s group while the dialog still contains the old page’s draft. This misleading notice is a non-blocking concern; tie the registration to the page where the dialog opened.

Artifacts

Flutter test for the lineup edit dialog across a page switch

  • The authored test opens the edit dialog on p1, switches provider state and the lineup graph to p2, and checks registration, presence, draft fields, and saving, exercising the requested behavior.

Edit dialog state before switching pages

  • The focused widget command captured the open p1 dialog, its registered lineup ID, `{group-p1}` presence, and unsaved draft before the switch.

Edit dialog state after switching pages

  • The focused widget command captured the dialog remaining open in both p2 cases and, for a reused ID, `{group-p2}` presence and the p1 draft saved into p2’s lineup.

Edit dialog initialization and save paths

  • A captured source search located the one-time draft initialization, dialog registration, and save lookup, supporting the observed switch and save behavior.

View artifacts

T-Rex Ran code and verified through T-Rex

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #244. Each open dialog records the page it opened on, and only dialogs from the page on screen count as editing.

};
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<List<PresencePeer>, 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);
});
Loading
Loading