|
| 1 | +import 'package:collection/collection.dart'; |
| 2 | +import 'package:flutter/foundation.dart'; |
| 3 | + |
| 4 | +import '../api/model/events.dart'; |
| 5 | +import '../api/model/model.dart'; |
| 6 | + |
| 7 | +/// A data structure to keep track of stream and topic messages of users (senders). |
| 8 | +/// |
| 9 | +/// Use [latestMessageIdOfSenderInStream] and [latestMessageIdOfSenderInTopic] |
| 10 | +/// to get the relevant data. |
| 11 | +class RecentSenders { |
| 12 | + // streamSenders[streamId][senderId] = MessageIdTracker |
| 13 | + @visibleForTesting |
| 14 | + final Map<int, Map<int, MessageIdTracker>> streamSenders = {}; |
| 15 | + |
| 16 | + // topicSenders[streamId][topic][senderId] = MessageIdTracker |
| 17 | + @visibleForTesting |
| 18 | + final Map<int, Map<String, Map<int, MessageIdTracker>>> topicSenders = {}; |
| 19 | + |
| 20 | + int? latestMessageIdOfSenderInStream({ |
| 21 | + required int streamId, |
| 22 | + required int senderId, |
| 23 | + }) => streamSenders[streamId]?[senderId]?.maxId; |
| 24 | + |
| 25 | + int? latestMessageIdOfSenderInTopic({ |
| 26 | + required int streamId, |
| 27 | + required String topic, |
| 28 | + required int senderId, |
| 29 | + }) => topicSenders[streamId]?[topic]?[senderId]?.maxId; |
| 30 | + |
| 31 | + /// Calls [handleMessage] for each of the message. |
| 32 | + /// |
| 33 | + /// [inReverse] is used for the optimization of the underlying |
| 34 | + /// [MessageIdTracker.add] method. It should be `false` (default) when |
| 35 | + /// [messages] are the initial messages in a narrow (fetched through |
| 36 | + /// [MessageListView.fetchInitial]), and `true` when they are the older |
| 37 | + /// messages in a narrow (fetched through [MessageListView.fetchOlder]). |
| 38 | + void handleMessages(List<Message> messages, {bool inReverse = false}) { |
| 39 | + for (int i = inReverse ? messages.length - 1 : 0; |
| 40 | + inReverse ? i >= 0 : i < messages.length; |
| 41 | + inReverse ? i-- : i++) { |
| 42 | + handleMessage(messages[i]); |
| 43 | + } |
| 44 | + } |
| 45 | + |
| 46 | + /// Records the necessary data from [message] if it is a [StreamMessage]. |
| 47 | + /// |
| 48 | + /// If [message] is not a [StreamMessage], this is a no-op. |
| 49 | + void handleMessage(Message message) { |
| 50 | + if (message is! StreamMessage) return; |
| 51 | + |
| 52 | + final StreamMessage(:streamId, :topic, :senderId, id: int messageId) = message; |
| 53 | + |
| 54 | + // Track message in stream. |
| 55 | + var sendersMap = streamSenders[streamId] ??= {}; |
| 56 | + var idTracker = sendersMap[senderId] ??= MessageIdTracker(); |
| 57 | + idTracker.add(messageId); |
| 58 | + |
| 59 | + // Track message in topic. |
| 60 | + final topicsMap = topicSenders[streamId] ??= {}; |
| 61 | + sendersMap = topicsMap[topic] ??= {}; |
| 62 | + idTracker = sendersMap[senderId] ??= MessageIdTracker(); |
| 63 | + idTracker.add(messageId); |
| 64 | + } |
| 65 | + |
| 66 | + void handleDeleteMessageEvent(DeleteMessageEvent event, Map<int, Message> cachedMessages) { |
| 67 | + if (event.messageType != MessageType.stream) return; |
| 68 | + |
| 69 | + for (final id in event.messageIds) { |
| 70 | + final message = cachedMessages[id] as StreamMessage?; |
| 71 | + if (message == null) break; |
| 72 | + final StreamMessage(:streamId, :topic, :senderId, id: int messageId) = message; |
| 73 | + |
| 74 | + _removeMessageInStream( |
| 75 | + streamId: streamId, senderId: senderId, messageId: messageId); |
| 76 | + |
| 77 | + _removeMessageInTopic(streamId: streamId, topic: topic, senderId: senderId, |
| 78 | + messageId: messageId); |
| 79 | + } |
| 80 | + } |
| 81 | + |
| 82 | + void _removeMessageInStream({ |
| 83 | + required int streamId, |
| 84 | + required int senderId, |
| 85 | + required int messageId, |
| 86 | + }) { |
| 87 | + if (streamSenders.isEmpty) return; |
| 88 | + |
| 89 | + final sendersMap = streamSenders[streamId]; |
| 90 | + if (sendersMap == null) return; |
| 91 | + |
| 92 | + final idTracker = sendersMap[senderId]; |
| 93 | + if (idTracker == null) return; |
| 94 | + |
| 95 | + idTracker.remove(messageId); |
| 96 | + if (idTracker.maxId == null) sendersMap.remove(senderId); |
| 97 | + if (sendersMap.isEmpty) streamSenders.remove(streamId); |
| 98 | + } |
| 99 | + |
| 100 | + void _removeMessageInTopic({ |
| 101 | + required int streamId, |
| 102 | + required String topic, |
| 103 | + required int senderId, |
| 104 | + required int messageId, |
| 105 | + }) { |
| 106 | + if (topicSenders.isEmpty) return; |
| 107 | + |
| 108 | + final topicsMap = topicSenders[streamId]; |
| 109 | + if (topicsMap == null) return; |
| 110 | + |
| 111 | + final sendersMap = topicsMap[topic]; |
| 112 | + if (sendersMap == null) return; |
| 113 | + |
| 114 | + final idTracker = sendersMap[senderId]; |
| 115 | + if (idTracker == null) return; |
| 116 | + |
| 117 | + idTracker.remove(messageId); |
| 118 | + if (idTracker.maxId == null) sendersMap.remove(senderId); |
| 119 | + if (sendersMap.isEmpty) topicsMap.remove(topic); |
| 120 | + if (topicsMap.isEmpty) topicSenders.remove(streamId); |
| 121 | + } |
| 122 | +} |
| 123 | + |
| 124 | +class MessageIdTracker { |
| 125 | + /// A list of distinct message IDs, sorted ascendingly. |
| 126 | + final QueueList<int> _ids = QueueList.from([]); |
| 127 | + |
| 128 | + /// The maximum id in the tracker list, or `null` if the list is empty. |
| 129 | + int? get maxId => _ids.lastOrNull; |
| 130 | + |
| 131 | + /// Add the message ID to the tracker list at the proper place, if not already present. |
| 132 | + /// |
| 133 | + /// Optimized, taking O(1) time, for the cases where that place is the start |
| 134 | + /// (message fetched through [MessageListView.fetchOlder]) or the end (message |
| 135 | + /// fetched through [MessageListView.fetchInitial] or |
| 136 | + /// [PerAccountStore.handleEvent]), because those are the common cases for a |
| 137 | + /// message the app receives. May take O(n) time in some rare cases. |
| 138 | + void add(int id) { |
| 139 | + final i = lowerBound(_ids, id); |
| 140 | + if (i < _ids.length && _ids[i] == id) { |
| 141 | + // The ID is already present. Nothing to do. |
| 142 | + return; |
| 143 | + } |
| 144 | + if (i == 0) { |
| 145 | + _ids.addFirst(id); |
| 146 | + } else if (i == _ids.length) { |
| 147 | + _ids.addLast(id); |
| 148 | + } else { |
| 149 | + _ids.insert(i, id); |
| 150 | + } |
| 151 | + } |
| 152 | + |
| 153 | + void remove(int id) => _ids.remove(id); |
| 154 | + |
| 155 | + @override |
| 156 | + bool operator ==(covariant MessageIdTracker other) { |
| 157 | + if (identical(this, other)) return true; |
| 158 | + |
| 159 | + return _ids.equals(other._ids); |
| 160 | + } |
| 161 | + |
| 162 | + @override |
| 163 | + int get hashCode => Object.hashAll(_ids); |
| 164 | + |
| 165 | + @override |
| 166 | + String toString() => _ids.toString(); |
| 167 | +} |
0 commit comments