diff --git a/lib/model/autocomplete.dart b/lib/model/autocomplete.dart index 9b8a81efd4..658327d60c 100644 --- a/lib/model/autocomplete.dart +++ b/lib/model/autocomplete.dart @@ -125,26 +125,37 @@ class AutocompleteViewManager { assert(removed); } - void handleRealmUserAddEvent(RealmUserAddEvent event) { + /// Recomputes the autocomplete results for users. + /// + /// Calls [MentionAutocompleteView.refreshStaleUserResults] for all that are registered. + void _refreshStaleUserResults() { for (final view in _mentionAutocompleteViews) { view.refreshStaleUserResults(); } } + void handleRealmUserAddEvent(RealmUserAddEvent event) { + _refreshStaleUserResults(); + } + void handleRealmUserRemoveEvent(RealmUserRemoveEvent event) { - for (final view in _mentionAutocompleteViews) { - view.refreshStaleUserResults(); - } + _refreshStaleUserResults(); autocompleteDataCache.invalidateUser(event.userId); } void handleRealmUserUpdateEvent(RealmUserUpdateEvent event) { - for (final view in _mentionAutocompleteViews) { - view.refreshStaleUserResults(); - } + _refreshStaleUserResults(); autocompleteDataCache.invalidateUser(event.userId); } + void handleMessageEvent(MessageEvent event) { + _refreshStaleUserResults(); + } + + void handleOlderMessages() { + _refreshStaleUserResults(); + } + /// Called when the app is reassembled during debugging, e.g. for hot reload. /// /// Calls [MentionAutocompleteView.reassemble] for all that are registered. @@ -190,6 +201,7 @@ class MentionAutocompleteView extends ChangeNotifier { @override void dispose() { store.autocompleteViewManager.unregisterMentionAutocomplete(this); + _sortedUsers = null; // We cancel in-progress computations by checking [hasListeners] between tasks. // After [super.dispose] is called, [hasListeners] returns false. // TODO test that logic (may involve detecting an unhandled Future rejection; how?) @@ -213,6 +225,7 @@ class MentionAutocompleteView extends ChangeNotifier { /// Called in particular when we get a [RealmUserEvent]. void refreshStaleUserResults() { if (_query != null) { + _sortedUsers = null; _startSearch(_query!); } } @@ -222,6 +235,7 @@ class MentionAutocompleteView extends ChangeNotifier { /// This will redo the search from scratch for the current query, if any. void reassemble() { if (_query != null) { + _sortedUsers = null; _startSearch(_query!); } } @@ -251,22 +265,106 @@ class MentionAutocompleteView extends ChangeNotifier { notifyListeners(); } + List? _sortedUsers; + + int compareByRelevance({ + required User userA, + required User userB, + required int? streamId, + required String? topic, + }) { + // TODO(#234): give preference to "all", "everyone" or "stream". + + // TODO(#618): give preference to subscribed users first. + + if (streamId != null) { + final conversationPrecedence = store.recentSenders.compareByRecency( + userA, + userB, + streamId: streamId, + topic: topic); + if (conversationPrecedence != 0) { + return conversationPrecedence; + } + } + final dmPrecedence = store.recentDmConversationsView.compareByDms(userA, userB); + if (dmPrecedence != 0) { + return dmPrecedence; + } + + if (!userA.isBot && userB.isBot) { + return -1; + } else if (userA.isBot && !userB.isBot) { + return 1; + } + + final userAName = store.autocompleteViewManager.autocompleteDataCache + .normalizedNameForUser(userA); + final userBName = store.autocompleteViewManager.autocompleteDataCache + .normalizedNameForUser(userB); + return userAName.compareTo(userBName); + } + + List sortByRelevance({ + required List users, + required Narrow narrow, + }) { + switch (narrow) { + case StreamNarrow(): + users.sort((userA, userB) => compareByRelevance( + userA: userA, + userB: userB, + streamId: narrow.streamId, + topic: null)); + case TopicNarrow(): + users.sort((userA, userB) => compareByRelevance( + userA: userA, + userB: userB, + streamId: narrow.streamId, + topic: narrow.topic)); + case DmNarrow(): + users.sort((userA, userB) => compareByRelevance( + userA: userA, + userB: userB, + streamId: null, + topic: null)); + case AllMessagesNarrow(): + // do nothing in this case for now + } + return users; + } + + void _sortUsers() { + final users = store.users.values.toList(); + _sortedUsers = sortByRelevance(users: users, narrow: narrow); + } + Future?> _computeResults(MentionAutocompleteQuery query) async { final List results = []; - final Iterable users = store.users.values; - final iterator = users.iterator; + if (_sortedUsers == null) { + _sortUsers(); + } + + final sortedUsers = _sortedUsers!; + final iterator = sortedUsers.iterator; bool isDone = false; while (!isDone) { // CPU perf: End this task; enqueue a new one for resuming this work await Future(() {}); + if (_sortedUsers != sortedUsers) { + // The list of users this loop has been working from has become stale. + // Abort so _startSearch can retry with the new list. + throw ConcurrentModificationError(); + } + if (query != _query || !hasListeners) { // false if [dispose] has been called. return null; } for (int i = 0; i < 1000; i++) { - if (!iterator.moveNext()) { // Can throw ConcurrentModificationError + if (!iterator.moveNext()) { isDone = true; break; } @@ -277,7 +375,7 @@ class MentionAutocompleteView extends ChangeNotifier { } } } - return results; // TODO(#228) sort for most relevant first + return results; } } @@ -337,13 +435,21 @@ class MentionAutocompleteQuery { } class AutocompleteDataCache { + final Map _normalizedNamesByUser = {}; + + /// The lowercase `fullName` of [user]. + String normalizedNameForUser(User user) { + return _normalizedNamesByUser[user.userId] ??= user.fullName.toLowerCase(); + } + final Map> _nameWordsByUser = {}; List nameWordsForUser(User user) { - return _nameWordsByUser[user.userId] ??= user.fullName.toLowerCase().split(' '); + return _nameWordsByUser[user.userId] ??= normalizedNameForUser(user).split(' '); } void invalidateUser(int userId) { + _normalizedNamesByUser.remove(userId); _nameWordsByUser.remove(userId); } } diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index f0d48368d0..e538b90ded 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -381,6 +381,7 @@ class MessageListView with ChangeNotifier, _MessageSequence { for (final message in result.messages) { if (_messageVisible(message)) { _addMessage(message); + store.recentSenders.handleMessage(message); } } _fetched = true; @@ -417,6 +418,11 @@ class MessageListView with ChangeNotifier, _MessageSequence { ? result.messages // Avoid unnecessarily copying the list. : result.messages.where(_messageVisible); + for (final message in fetchedMessages) { + store.recentSenders.handleMessage(message); + } + store.autocompleteViewManager.handleOlderMessages(); + _insertAllMessages(0, fetchedMessages); _haveOldest = result.foundOldest; } finally { diff --git a/lib/model/recent_dm_conversations.dart b/lib/model/recent_dm_conversations.dart index 5fd6fcf9ee..d1918b1960 100644 --- a/lib/model/recent_dm_conversations.dart +++ b/lib/model/recent_dm_conversations.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; @@ -19,9 +21,22 @@ class RecentDmConversationsView extends ChangeNotifier { DmNarrow.ofRecentDmConversation(conversation, selfUserId: selfUserId), conversation.maxMessageId, )).toList()..sort((a, b) => -a.value.compareTo(b.value)); + final map = Map.fromEntries(entries); + final sorted = QueueList.from(entries.map((e) => e.key)); + + final msgsByUser = {}; + for (final entry in entries) { + final dmNarrow = entry.key; + final msg = entry.value; + for (final userId in dmNarrow.otherRecipientIds) { + // only take the latest message of a user among all the conversations. + msgsByUser.putIfAbsent(userId, () => msg); + } + } return RecentDmConversationsView._( - map: Map.fromEntries(entries), - sorted: QueueList.from(entries.map((e) => e.key)), + map: map, + sorted: sorted, + latestMessagesByRecipient: msgsByUser, selfUserId: selfUserId, ); } @@ -29,6 +44,7 @@ class RecentDmConversationsView extends ChangeNotifier { RecentDmConversationsView._({ required this.map, required this.sorted, + required this.latestMessagesByRecipient, required this.selfUserId, }); @@ -38,8 +54,24 @@ class RecentDmConversationsView extends ChangeNotifier { /// The [DmNarrow] keys of [map], sorted by latest message descending. final QueueList sorted; + /// Map from user ID to the latest message ID in any conversation with the user. + /// + /// Both 1:1 and group DM conversations are considered. + /// The self-user ID is excluded even if there is a self-DM conversation. + /// + /// (The identified message was not necessarily sent by the identified user; + /// it might have been sent by anyone in its conversation.) + final Map latestMessagesByRecipient; + final int selfUserId; + int compareByDms(User userA, User userB) { + final aLatestMessageId = latestMessagesByRecipient[userA.userId] ?? -1; + final bLatestMessageId = latestMessagesByRecipient[userB.userId] ?? -1; + + return bLatestMessageId.compareTo(aLatestMessageId); + } + /// Insert the key at the proper place in [sorted]. /// /// Optimized, taking O(1) time, for the case where that place is the start, @@ -58,7 +90,7 @@ class RecentDmConversationsView extends ChangeNotifier { } } - /// Handle [MessageEvent], updating [map] and [sorted]. + /// Handle [MessageEvent], updating [map], [sorted], and [latestMessagesByRecipient]. /// /// Can take linear time in general. That sounds inefficient... /// but it's what the webapp does, so must not be catastrophic. 🤷 @@ -117,6 +149,13 @@ class RecentDmConversationsView extends ChangeNotifier { _insertSorted(key, message.id); } } + for (final recipient in key.otherRecipientIds) { + latestMessagesByRecipient.update( + recipient, + (latestMessageId) => max(message.id, latestMessageId), + ifAbsent: () => message.id, + ); + } notifyListeners(); } diff --git a/lib/model/recent_senders.dart b/lib/model/recent_senders.dart new file mode 100644 index 0000000000..dbd4e3d739 --- /dev/null +++ b/lib/model/recent_senders.dart @@ -0,0 +1,156 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; + +import '../api/model/model.dart'; + +class MessageIdTracker { + final List _ids = []; + + void add(int id) { + if (_ids.isEmpty) { + _ids.add(id); + } else { + int i = lowerBound(_ids, id); + if (i < _ids.length && _ids[i] == id) return; // the [id] already exists, so do not add it. + _ids.insert(i, id); + } + } + + // TODO: remove + + /// The maximum id in the tracker list. + /// + /// Returns -1 if the tracker list is empty. + int get maxId => _ids.isNotEmpty ? _ids.last : -1; + + /// Getter for the tracked message IDs. + /// + /// This is intended for testing purposes only. + List get idsForTesting => _ids; + + @override + bool operator == (covariant MessageIdTracker other) { + if (identical(this, other)) return true; + + return _ids.equals(other._ids); + } + + @override + int get hashCode => Object.hashAll(_ids); +} + +/// A data structure to keep track of stream and topic messages. +/// +/// The owner should call [clear] in order to free resources. +class RecentSenders { + // streamSenders[streamId][senderId] = IdTracker + final Map> _streamSenders = {}; + + // topicSenders[streamId][topic][senderId] = IdTracker + final Map>> _topicSenders = {}; + + /// Whether stream senders and topic senders are both empty. + @visibleForTesting + bool get debugIsEmpty => _streamSenders.isEmpty && _topicSenders.isEmpty; + + /// Whether stream senders and topic senders are both not empty. + @visibleForTesting + bool get debugIsNotEmpty => _streamSenders.isNotEmpty && _topicSenders.isNotEmpty; + + void clear() { + _streamSenders.clear(); + _topicSenders.clear(); + } + + int _latestMessageIdOfSenderInStream({required int streamId, required int senderId}) { + return _streamSenders[streamId]?[senderId]?.maxId ?? -1; + } + + int _latestMessageIdOfSenderInTopic({ + required int streamId, + required String topic, + required int senderId, + }) { + return _topicSenders[streamId]?[topic]?[senderId]?.maxId ?? -1; + } + + void _addMessageInStream({ + required int streamId, + required int senderId, + required int messageId, + }) { + final sendersMap = _streamSenders[streamId] ??= {}; + final idTracker = sendersMap[senderId] ??= MessageIdTracker(); + idTracker.add(messageId); + } + + void _addMessageInTopic({ + required int streamId, + required String topic, + required int senderId, + required int messageId, + }) { + final topicsMap = _topicSenders[streamId] ??= {}; + final sendersMap = topicsMap[topic] ??= {}; + final idTracker = sendersMap[senderId] ??= MessageIdTracker(); + idTracker.add(messageId); + } + + /// Extracts and keeps track of the necessary data from a [message] only + /// if it is a stream message. + void handleMessage(Message message) { + if (message is! StreamMessage) { + return; + } + + final streamId = message.streamId; + final topic = message.subject; + final senderId = message.senderId; + final messageId = message.id; + + _addMessageInStream( + streamId: streamId, senderId: senderId, messageId: messageId); + _addMessageInTopic( + streamId: streamId, + topic: topic, + senderId: senderId, + messageId: messageId); + } + + // TODO: removeMessageInTopic + + /// Determines which of the two users has more recent activity. + /// + /// First checks for the activity in [topic] if provided. + /// + /// If no [topic] is provided, or the activity in the topic is the same (which + /// is extremely rare) or there is no activity in the topic at all, then + /// checks for the activity in the stream with [streamId]. + /// + /// Returns a negative number if [userA] has more recent activity than [userB], + /// returns a positive number if [userB] has more recent activity than [userA], + /// and returns `0` if both [userA] and [userB] have the same recent activity + /// (which is extremely rare) or has no activity at all. + int compareByRecency( + User userA, + User userB, { + required int streamId, + required String? topic, + }) { + if (topic != null) { + final aMessageId = _latestMessageIdOfSenderInTopic( + streamId: streamId, topic: topic, senderId: userA.userId); + final bMessageId = _latestMessageIdOfSenderInTopic( + streamId: streamId, topic: topic, senderId: userB.userId); + + final result = bMessageId.compareTo(aMessageId); + if (result != 0) return result; + } + + final aMessageId = + _latestMessageIdOfSenderInStream(streamId: streamId, senderId: userA.userId); + final bMessageId = + _latestMessageIdOfSenderInStream(streamId: streamId, senderId: userB.userId); + return bMessageId.compareTo(aMessageId); + } +} diff --git a/lib/model/store.dart b/lib/model/store.dart index a5d288fe02..dd6a3fb294 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -21,6 +21,7 @@ import 'autocomplete.dart'; import 'database.dart'; import 'message_list.dart'; import 'recent_dm_conversations.dart'; +import 'recent_senders.dart'; import 'stream.dart'; import 'unreads.dart'; @@ -287,6 +288,8 @@ class PerAccountStore extends ChangeNotifier with StreamStore { final Map users; + final RecentSenders recentSenders = RecentSenders(); + //////////////////////////////// // Streams, topics, and stuff about them. @@ -347,6 +350,7 @@ class PerAccountStore extends ChangeNotifier with StreamStore { void dispose() { unreads.dispose(); recentDmConversationsView.dispose(); + recentSenders.clear(); for (final view in _messageListViews.toList()) { view.dispose(); } @@ -436,7 +440,9 @@ class PerAccountStore extends ChangeNotifier with StreamStore { notifyListeners(); } else if (event is MessageEvent) { assert(debugLog("server event: message ${jsonEncode(event.message.toJson())}")); + recentSenders.handleMessage(event.message); recentDmConversationsView.handleMessageEvent(event); + autocompleteViewManager.handleMessageEvent(event); for (final view in _messageListViews) { view.maybeAddMessage(event.message); } diff --git a/test/model/recent_dm_conversations_checks.dart b/test/model/recent_dm_conversations_checks.dart index af92ca25ac..bbb78593f9 100644 --- a/test/model/recent_dm_conversations_checks.dart +++ b/test/model/recent_dm_conversations_checks.dart @@ -6,4 +6,6 @@ import 'package:zulip/model/recent_dm_conversations.dart'; extension RecentDmConversationsViewChecks on Subject { Subject> get map => has((v) => v.map, 'map'); Subject> get sorted => has((v) => v.sorted, 'sorted'); + Subject> get latestMessagesByRecipient => has( + (v) => v.latestMessagesByRecipient, 'latestMessagesByRecipient'); } diff --git a/test/model/recent_dm_conversations_test.dart b/test/model/recent_dm_conversations_test.dart index c09ca53527..a3f01b4d35 100644 --- a/test/model/recent_dm_conversations_test.dart +++ b/test/model/recent_dm_conversations_test.dart @@ -22,7 +22,8 @@ void main() { check(RecentDmConversationsView(selfUserId: eg.selfUser.userId, initial: [])) ..map.isEmpty() - ..sorted.isEmpty(); + ..sorted.isEmpty() + ..latestMessagesByRecipient.isEmpty(); check(RecentDmConversationsView(selfUserId: eg.selfUser.userId, initial: [ @@ -35,7 +36,8 @@ void main() { key([]): 200, key([1]): 100, }) - ..sorted.deepEquals([key([1, 2]), key([]), key([1])]); + ..sorted.deepEquals([key([1, 2]), key([]), key([1])]) + ..latestMessagesByRecipient.deepEquals({1: 300, 2: 300}); }); group('message event (new message)', () { @@ -55,7 +57,8 @@ void main() { key([1]): 200, key([1, 2]): 100, }) - ..sorted.deepEquals([key([1]), key([1, 2])]); + ..sorted.deepEquals([key([1]), key([1, 2])]) + ..latestMessagesByRecipient.deepEquals({1: 200, 2: 100}); }); test('stream message -> do nothing', () { @@ -65,7 +68,8 @@ void main() { ..addListener(() { listenersNotified = true; }) ..handleMessageEvent(MessageEvent(id: 1, message: eg.streamMessage())) ) ..map.deepEquals(expected.map) - ..sorted.deepEquals(expected.sorted); + ..sorted.deepEquals(expected.sorted) + ..latestMessagesByRecipient.deepEquals(expected.latestMessagesByRecipient); check(listenersNotified).isFalse(); }); @@ -80,7 +84,8 @@ void main() { key([1]): 200, key([1, 2]): 100, }) - ..sorted.deepEquals([key([2]), key([1]), key([1, 2])]); + ..sorted.deepEquals([key([2]), key([1]), key([1, 2])]) + ..latestMessagesByRecipient.deepEquals({1: 200, 2: 300}); check(listenersNotified).isTrue(); }); @@ -95,7 +100,8 @@ void main() { key([2]): 150, key([1, 2]): 100, }) - ..sorted.deepEquals([key([1]), key([2]), key([1, 2])]); + ..sorted.deepEquals([key([1]), key([2]), key([1, 2])]) + ..latestMessagesByRecipient.deepEquals({1: 200, 2: 150}); check(listenersNotified).isTrue(); }); @@ -110,7 +116,8 @@ void main() { key([1, 2]): 300, key([1]): 200, }) - ..sorted.deepEquals([key([1, 2]), key([1])]); + ..sorted.deepEquals([key([1, 2]), key([1])]) + ..latestMessagesByRecipient.deepEquals({1: 300, 2: 300}); check(listenersNotified).isTrue(); }); @@ -124,7 +131,8 @@ void main() { key([1]): 300, key([1, 2]): 100, }) - ..sorted.deepEquals([key([1]), key([1, 2])]); + ..sorted.deepEquals([key([1]), key([1, 2])]) + ..latestMessagesByRecipient.deepEquals({1: 300, 2: 100}); check(listenersNotified).isTrue(); }); @@ -135,7 +143,42 @@ void main() { check(setupView() ..handleMessageEvent(MessageEvent(id: 1, message: message)) ) ..map.deepEquals(expected.map) - ..sorted.deepEquals(expected.sorted); + ..sorted.deepEquals(expected.sorted) + ..latestMessagesByRecipient.deepEquals(expected.latestMessagesByRecipient); + }); + + test('new conversation with one existing and one new user, newest message', () { + bool listenersNotified = false; + final message = eg.dmMessage(id: 300, from: eg.selfUser, + to: [eg.user(userId: 1), eg.user(userId: 3)]); + check(setupView() + ..addListener(() { listenersNotified = true; }) + ..handleMessageEvent(MessageEvent(id: 1, message: message)) + ) ..map.deepEquals({ + key([1, 3]): 300, + key([1]): 200, + key([1, 2]): 100, + }) + ..sorted.deepEquals([key([1, 3]), key([1]), key([1, 2])]) + ..latestMessagesByRecipient.deepEquals({1: 300, 2: 100, 3: 300}); + check(listenersNotified).isTrue(); + }); + + test('new conversation with one existing and one new user, not newest message', () { + bool listenersNotified = false; + final message = eg.dmMessage(id: 150, from: eg.selfUser, + to: [eg.user(userId: 1), eg.user(userId: 3)]); + check(setupView() + ..addListener(() { listenersNotified = true; }) + ..handleMessageEvent(MessageEvent(id: 1, message: message)) + ) ..map.deepEquals({ + key([1]): 200, + key([1, 3]): 150, + key([1, 2]): 100, + }) + ..sorted.deepEquals([key([1]), key([1, 3]), key([1, 2])]) + ..latestMessagesByRecipient.deepEquals({1: 200, 2: 100, 3: 150}); + check(listenersNotified).isTrue(); }); }); }); diff --git a/test/model/recent_senders_test.dart b/test/model/recent_senders_test.dart new file mode 100644 index 0000000000..191bf6d536 --- /dev/null +++ b/test/model/recent_senders_test.dart @@ -0,0 +1,129 @@ +import 'package:checks/checks.dart'; +import 'package:test/scaffolding.dart'; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/model/recent_senders.dart'; +import '../example_data.dart' as eg; + +void main() { + group('MessageIdTracker', () { + late MessageIdTracker idTracker; + + void prepare() { + idTracker = MessageIdTracker(); + } + + test('starts with no ids', () { + prepare(); + check(idTracker.idsForTesting).isEmpty(); + }); + + test('calling add(id) adds the same id to the tracker just one time', () { + prepare(); + check(idTracker.idsForTesting).isEmpty(); + idTracker.add(1); + idTracker.add(1); + check(idTracker.idsForTesting.singleOrNull).equals(1); + }); + + test('ids are sorted ascendingly, with maxId pointing to the last element', () { + prepare(); + check(idTracker.idsForTesting).isEmpty(); + idTracker.add(1); + idTracker.add(9); + idTracker.add(-1); + idTracker.add(5); + check(idTracker.idsForTesting).deepEquals([-1, 1, 5, 9]); + check(idTracker.maxId).equals(idTracker.idsForTesting.last); + }); + }); + + group('RecentSenders', () { + late RecentSenders recentSenders; + + void prepare() { + recentSenders = RecentSenders(); + } + + test('starts with no stream or topic senders', () { + prepare(); + check(recentSenders.debugIsEmpty).equals(true); + }); + + test('only processes a stream message', () { + prepare(); + final dmMessage = eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]); + recentSenders.handleMessage(dmMessage); + check(recentSenders.debugIsEmpty).equals(true); + + final streamMessage = eg.streamMessage(); + recentSenders.handleMessage(streamMessage); + check(recentSenders.debugIsNotEmpty).equals(true); + }); + + group('compareByRecency', () { + final userA = eg.otherUser; + final userB = eg.thirdUser; + final stream = eg.stream(); + const topic1 = 'topic1'; + const topic2 = 'topic2'; + + void fillWithMessages(List messages) { + for (final message in messages) { + recentSenders.handleMessage(message); + } + } + + /// Determines the priority between [userA] and [userB] based on their activity. + /// + /// The activity is first looked for in [topic] then in [stream]. + /// + /// Returns a negative number if [userA] has more recent activity, + /// returns a positive number if [userB] has more recent activity, and + /// returns `0` if the activity is the same or there is no activity at all. + int priority({required String? topic}) { + return recentSenders.compareByRecency( + userA, + userB, + streamId: stream.streamId, + topic: topic, + ); + } + + test('prioritizes the user with more recent activity in the topic', () { + final userAMessage = eg.streamMessage(sender: userA, stream: stream, topic: topic1); + final userBMessage = eg.streamMessage(sender: userB, stream: stream, topic: topic1); + prepare(); + fillWithMessages([userAMessage, userBMessage]); + final priorityInTopic1 = priority(topic: topic1); + check(priorityInTopic1).isGreaterThan(0); // [userB] is more recent in topic1. + }); + + test('prioritizes the user with more recent activity in the stream ' + 'if there is no activity in the topic from both users', () { + final userAMessage = eg.streamMessage(sender: userA, stream: stream, topic: topic1); + final userBMessage = eg.streamMessage(sender: userB, stream: stream, topic: topic1); + prepare(); + fillWithMessages([userAMessage, userBMessage]); + final priorityInTopic2 = priority(topic: topic2); + check(priorityInTopic2).isGreaterThan(0); // [userB] is more recent in the stream. + }); + + test('prioritizes the user with more recent activity in the stream ' + 'if there is no topic provided', () { + final userAMessage = eg.streamMessage(sender: userA, stream: stream, topic: topic1); + final userBMessage = eg.streamMessage(sender: userB, stream: stream, topic: topic2); + prepare(); + fillWithMessages([userAMessage, userBMessage]); + final priorityInStream = priority(topic: null); + check(priorityInStream).isGreaterThan(0); // [userB] is more recent in the stream. + }); + + test('prioritizes none of the users if there is no activity in the stream from both users', () { + prepare(); + fillWithMessages([]); + final priorityInStream = priority(topic: null); + check(priorityInStream).equals(0); // none of the users has activity in the stream. + }); + }); + }); +}