diff --git a/lib/model/message.dart b/lib/model/message.dart index 4ccf45d2ca0..75e22f6bde7 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -175,7 +175,12 @@ class MessageStoreImpl with MessageStore { } void handleDeleteMessageEvent(DeleteMessageEvent event) { - // TODO handle DeleteMessageEvent, particularly in MessageListView + for (final messageId in event.messageIds) { + messages.remove(messageId); + } + for (final view in _messageListViews) { + view.handleDeleteMessageEvent(event); + } } void handleUpdateMessageFlagsEvent(UpdateMessageFlagsEvent event) { diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index 117b30e614e..e0f59d99c70 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -155,6 +155,30 @@ mixin _MessageSequence { _processMessage(messages.length - 1); } + /// Removes the given messages, if present. + /// + /// Returns true if at least one message was present, false otherwise. + /// If none of [messageIds] are found, this is a no-op. + bool _removeMessagesById(Iterable messageIds) { + final messagesToRemoveById = {}; + final contentToRemove = Set.identity(); + for (final messageId in messageIds) { + final index = _findMessageWithId(messageId); + if (index == -1) continue; + messagesToRemoveById.add(messageId); + contentToRemove.add(contents[index]); + } + if (messagesToRemoveById.isEmpty) return false; + + assert(contents.length == messages.length); + messages.removeWhere((message) => messagesToRemoveById.contains(message.id)); + contents.removeWhere((content) => contentToRemove.contains(content)); + assert(contents.length == messages.length); + _reprocessAll(); + + return true; + } + void _insertAllMessages(int index, Iterable toInsert) { // TODO parse/process messages in smaller batches, to not drop frames. // On a Pixel 5, a batch of 100 messages takes ~15-20ms in _insertAllMessages. @@ -429,6 +453,12 @@ class MessageListView with ChangeNotifier, _MessageSequence { } } + void handleDeleteMessageEvent(DeleteMessageEvent event) { + if (_removeMessagesById(event.messageIds)) { + notifyListeners(); + } + } + /// Add [MessageEvent.message] to this view, if it belongs here. void handleMessageEvent(MessageEvent event) { final message = event.message; diff --git a/test/example_data.dart b/test/example_data.dart index 36fc60fec05..8de15b91593 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -388,6 +388,21 @@ const _unreadMsgs = unreadMsgs; // Events. // +DeleteMessageEvent deleteMessageEvent(List messages) { + assert(messages.isNotEmpty); + final streamId = messages.first.streamId; + final topic = messages.first.topic; + assert(messages.every((m) => m.streamId == streamId)); + assert(messages.every((m) => m.topic == topic)); + return DeleteMessageEvent( + id: 0, + messageIds: messages.map((message) => message.id).toList(), + messageType: MessageType.stream, + streamId: messages[0].streamId, + topic: messages[0].topic, + ); +} + UpdateMessageEvent updateMessageEditEvent( Message origMessage, { int? userId = -1, // null means null; default is [selfUser.userId] diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index de0c7f32a7d..2369d25badb 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -271,6 +271,68 @@ void main() { checkInvariants(model); }); + group('DeleteMessageEvent', () { + final stream = eg.stream(); + final messages = List.generate(30, (i) => eg.streamMessage(stream: stream)); + + test('in narrow', () async { + await prepare(narrow: StreamNarrow(stream.streamId)); + await prepareMessages(foundOldest: true, messages: messages); + + check(model).messages.length.equals(30); + await store.handleEvent(eg.deleteMessageEvent(messages.sublist(0, 10))); + checkNotifiedOnce(); + check(model).messages.length.equals(20); + }); + + test('not all in narrow', () async { + await prepare(narrow: StreamNarrow(stream.streamId)); + await prepareMessages(foundOldest: true, messages: messages.sublist(5)); + + check(model).messages.length.equals(25); + await store.handleEvent(eg.deleteMessageEvent(messages.sublist(0, 10))); + checkNotifiedOnce(); + check(model).messages.length.equals(20); + }); + + test('not in narrow', () async { + await prepare(narrow: StreamNarrow(stream.streamId)); + await prepareMessages(foundOldest: true, messages: messages.sublist(5)); + + check(model).messages.length.equals(25); + await store.handleEvent(eg.deleteMessageEvent(messages.sublist(0, 5))); + checkNotNotified(); + check(model).messages.length.equals(25); + checkInvariants(model); + }); + + test('complete message deletion', () async { + await prepare(narrow: StreamNarrow(stream.streamId)); + await prepareMessages(foundOldest: true, messages: messages.sublist(0, 25)); + + check(model).messages.length.equals(25); + await store.handleEvent(eg.deleteMessageEvent(messages)); + checkNotifiedOnce(); + check(model).messages.length.equals(0); + }); + + test('non-consecutive message deletion', () async { + await prepare(narrow: StreamNarrow(stream.streamId)); + await prepareMessages(foundOldest: true, messages: messages); + final messageToDelete = messages.sublist(2, 5) + messages.sublist(10, 15); + + check(model).messages.length.equals(30); + await store.handleEvent(eg.deleteMessageEvent(messageToDelete)); + checkNotifiedOnce(); + final remainingMessageIds = [ + ...messages.sublist(0, 2), + ...messages.sublist(5, 10), + ...messages.sublist(15)].map((message) => message.id).toSet(); + check(model).messages.length.equals(22); + check(model.messages.map((message) => message.id)).containsInOrder(remainingMessageIds); + }); + }); + group('notifyListenersIfMessagePresent', () { test('message present', () async { await prepare(narrow: const CombinedFeedNarrow()); diff --git a/test/model/message_test.dart b/test/model/message_test.dart index 10e4334fcf3..248d9fcfc3e 100644 --- a/test/model/message_test.dart +++ b/test/model/message_test.dart @@ -375,6 +375,39 @@ void main() { }); }); + group('handleDeleteMessageEvent', () { + test('delete an unknown message', () async { + final message1 = eg.streamMessage(); + final message2 = eg.streamMessage(); + await prepare(); + await prepareMessages([message1]); + await store.handleEvent(eg.deleteMessageEvent([message2])); + checkNotNotified(); + check(store).messages.values.single.id.equals(message1.id); + }); + + test('delete messages', () async { + final message1 = eg.streamMessage(); + final message2 = eg.streamMessage(); + await prepare(); + await prepareMessages([message1, message2]); + await store.handleEvent(eg.deleteMessageEvent([message1, message2])); + checkNotifiedOnce(); + check(store).messages.isEmpty(); + }); + + test('delete an unknown message with a known message', () async { + final message1 = eg.streamMessage(); + final message2 = eg.streamMessage(); + final message3 = eg.streamMessage(); + await prepare(); + await prepareMessages([message1, message2]); + await store.handleEvent(eg.deleteMessageEvent([message2, message3])); + checkNotifiedOnce(); + check(store).messages.values.single.id.equals(message1.id); + }); + }); + group('handleReactionEvent', () { test('add reaction', () async { final originalMessage = eg.streamMessage(reactions: []);