diff --git a/packages/neon/neon_talk/lib/src/blocs/room.dart b/packages/neon/neon_talk/lib/src/blocs/room.dart index 0951d710ace..9e7071ab2a5 100644 --- a/packages/neon/neon_talk/lib/src/blocs/room.dart +++ b/packages/neon/neon_talk/lib/src/blocs/room.dart @@ -6,6 +6,7 @@ import 'package:meta/meta.dart'; import 'package:neon_framework/blocs.dart'; import 'package:neon_framework/models.dart'; import 'package:neon_framework/utils.dart'; +import 'package:neon_talk/src/blocs/talk.dart'; import 'package:nextcloud/nextcloud.dart'; import 'package:nextcloud/spreed.dart' as spreed; import 'package:rxdart/rxdart.dart'; @@ -16,6 +17,7 @@ import 'package:rxdart/subjects.dart'; abstract class TalkRoomBloc implements InteractiveBloc { /// Creates a new Talk room bloc. factory TalkRoomBloc({ + required TalkBloc talkBloc, required Account account, required spreed.Room room, }) = _TalkRoomBloc; @@ -37,10 +39,52 @@ abstract class TalkRoomBloc implements InteractiveBloc { class _TalkRoomBloc extends InteractiveBloc implements TalkRoomBloc { _TalkRoomBloc({ + required this.talkBloc, required this.account, required spreed.Room room, }) : room = BehaviorSubject.seeded(Result.success(room)), token = room.token { + messages.listen((result) { + if (!result.hasSuccessfulData) { + return; + } + + // TODO: More room fields might be changed based on the received messages (e.g. room was renamed). + + final lastMessage = result.requireData.firstOrNull; + if (lastMessage == null) { + return; + } + + final value = this.room.value; + this.room.add( + value.copyWith( + data: value.requireData.rebuild( + (b) => b + ..lastActivity = lastMessage.timestamp + ..lastMessage = ( + baseMessage: null, + builtListNever: null, + // TODO: This manual conversion is only necessary because the interface isn't used everywhere: https://github.com/nextcloud/neon/issues/1995 + chatMessage: spreed.ChatMessage.fromJson(lastMessage.toJson()), + ) + ..lastCommonReadMessage = lastCommonRead.valueOrNull + // The following fields can be set because we know that any updates to the messages (sending/polling) updates the last read message. + ..lastReadMessage = lastMessage.id + ..unreadMention = false + ..unreadMentionDirect = false + ..unreadMessages = 0, + ), + ), + ); + }); + + this.room.listen((result) { + if (result.hasSuccessfulData) { + talkBloc.updateRoom(result.requireData); + } + }); + unawaited(() async { while (pollLoop) { final lastKnownMessageId = @@ -86,6 +130,7 @@ class _TalkRoomBloc extends InteractiveBloc implements TalkRoomBloc { @override final log = Logger('TalkRoomBloc'); + final TalkBloc talkBloc; final Account account; final String token; bool pollLoop = true; diff --git a/packages/neon/neon_talk/lib/src/blocs/talk.dart b/packages/neon/neon_talk/lib/src/blocs/talk.dart index b6f02d14aaf..b5a99430d20 100644 --- a/packages/neon/neon_talk/lib/src/blocs/talk.dart +++ b/packages/neon/neon_talk/lib/src/blocs/talk.dart @@ -21,6 +21,9 @@ abstract class TalkBloc implements InteractiveBloc { /// Creates a new Talk room. void createRoom(spreed.RoomType type, String? roomName, core.AutocompleteResult? invite); + /// Updates a single room with new data. + void updateRoom(spreed.Room room); + /// The list of rooms. BehaviorSubject>> get rooms; @@ -45,12 +48,15 @@ class _TalkBloc extends InteractiveBloc implements TalkBloc { }); unawaited(refresh()); + + timer = TimerBloc().registerTimer(const Duration(seconds: 30), refresh); } @override final log = Logger('TalkBloc'); final Account account; + late final NeonTimer timer; @override final rooms = BehaviorSubject(); @@ -60,6 +66,7 @@ class _TalkBloc extends InteractiveBloc implements TalkBloc { @override void dispose() { + timer.cancel(); unawaited(rooms.close()); unawaited(unreadCounter.close()); super.dispose(); @@ -94,4 +101,18 @@ class _TalkBloc extends InteractiveBloc implements TalkBloc { ); }); } + + @override + void updateRoom(spreed.Room room) { + final value = rooms.valueOrNull; + if (value == null || !value.hasData) { + return; + } + + rooms.add( + value.copyWith( + data: value.requireData.map((r) => r.id == room.id ? room : r).toBuiltList(), + ), + ); + } } diff --git a/packages/neon/neon_talk/lib/src/pages/main.dart b/packages/neon/neon_talk/lib/src/pages/main.dart index 787d366d859..a5af68c1520 100644 --- a/packages/neon/neon_talk/lib/src/pages/main.dart +++ b/packages/neon/neon_talk/lib/src/pages/main.dart @@ -122,6 +122,7 @@ class _TalkMainPageState extends State { MaterialPageRoute( builder: (context) => NeonProvider( create: (_) => TalkRoomBloc( + talkBloc: bloc, account: account, room: room, ), diff --git a/packages/neon/neon_talk/test/bloc_test.dart b/packages/neon/neon_talk/test/bloc_test.dart index ead9e252d6d..039e6860359 100644 --- a/packages/neon/neon_talk/test/bloc_test.dart +++ b/packages/neon/neon_talk/test/bloc_test.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:built_collection/built_collection.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:neon_framework/blocs.dart'; import 'package:neon_framework/models.dart'; import 'package:neon_framework/testing.dart'; @@ -134,4 +135,25 @@ void main() { ), ); }); + + test('updateRoom', () async { + expect( + bloc.rooms.transformResult((e) => BuiltList(e.map((r) => r.displayName))), + emitsInOrder([ + Result>.loading(), + Result.success(BuiltList(['0', '1', '2'])), + Result.success(BuiltList(['update', '1', '2'])), + ]), + ); + + // The delay is necessary to avoid a race condition with loading twice at the same time + await Future.delayed(const Duration(milliseconds: 1)); + + final room = MockRoom(); + when(() => room.id).thenReturn(0); + when(() => room.displayName).thenReturn('update'); + when(() => room.unreadMessages).thenReturn(0); + + bloc.updateRoom(room); + }); } diff --git a/packages/neon/neon_talk/test/room_bloc_test.dart b/packages/neon/neon_talk/test/room_bloc_test.dart index f84cc506203..2bf04bf94d4 100644 --- a/packages/neon/neon_talk/test/room_bloc_test.dart +++ b/packages/neon/neon_talk/test/room_bloc_test.dart @@ -8,6 +8,7 @@ import 'package:neon_framework/blocs.dart'; import 'package:neon_framework/models.dart'; import 'package:neon_framework/testing.dart'; import 'package:neon_talk/src/blocs/room.dart'; +import 'package:neon_talk/src/blocs/talk.dart'; import 'testing.dart'; @@ -88,7 +89,12 @@ Account mockTalkAccount() { void main() { late Account account; - late TalkRoomBloc bloc; + late TalkBloc talkBloc; + late TalkRoomBloc roomBloc; + + setUpAll(() { + registerFallbackValue(MockRoom()); + }); setUp(() { FakeNeonStorage.setup(); @@ -100,7 +106,9 @@ void main() { when(() => room.lastMessage).thenReturn((baseMessage: null, builtListNever: null, chatMessage: null)); account = mockTalkAccount(); - bloc = TalkRoomBloc( + talkBloc = MockTalkBloc(); + roomBloc = TalkRoomBloc( + talkBloc: talkBloc, account: account, room: room, ); @@ -109,22 +117,24 @@ void main() { tearDown(() async { // Wait for all events to be processed await Future.delayed(const Duration(milliseconds: 1)); - bloc.dispose(); + roomBloc.dispose(); }); test('refresh', () async { expect( - bloc.room.transformResult((e) => e.token), + roomBloc.room.transformResult((e) => e.token), emitsInOrder([ Result.success('abcd').asLoading(), Result.success('abcd'), + Result.success('abcd'), Result.success('abcd').asLoading(), Result.success('abcd'), + Result.success('abcd'), ]), ); expect( - bloc.messages.transformResult((e) => BuiltList(e.map((m) => m.id))), + roomBloc.messages.transformResult((e) => BuiltList(e.map((m) => m.id))), emitsInOrder([ Result>.loading(), Result.success(BuiltList([2, 1, 0])), @@ -134,48 +144,72 @@ void main() { ); expect( - bloc.lastCommonRead, + roomBloc.lastCommonRead, emitsInOrder([0, 0]), ); // The delay is necessary to avoid a race condition with loading twice at the same time await Future.delayed(const Duration(milliseconds: 1)); - await bloc.refresh(); + await roomBloc.refresh(); + + verify(() => talkBloc.updateRoom(any())).called(4); }); test('sendMessage', () async { expect( - bloc.messages.transformResult((e) => BuiltList(e.map((m) => m.id))), + roomBloc.messages.transformResult((e) => BuiltList(e.map((m) => m.id))), emitsInOrder([ Result>.loading(), Result.success(BuiltList([2, 1, 0])), Result.success(BuiltList([3, 2, 1, 0])), ]), ); + expect( + roomBloc.room.transformResult((e) => e.lastMessage.chatMessage?.id), + emitsInOrder([ + Result.loading(), + Result.success(null), + Result.success(2), + Result.success(3), + ]), + ); expect( - bloc.lastCommonRead, + roomBloc.lastCommonRead, emitsInOrder([0, 1]), ); // The delay is necessary to avoid a race condition with loading twice at the same time await Future.delayed(const Duration(milliseconds: 1)); - bloc.sendMessage(''); + roomBloc.sendMessage(''); + + verify(() => talkBloc.updateRoom(any())).called(3); }); test('polling', () async { expect( - bloc.messages.transformResult((e) => BuiltList(e.map((m) => m.id))), + roomBloc.messages.transformResult((e) => BuiltList(e.map((m) => m.id))), emitsInOrder([ Result>.loading(), Result.success(BuiltList([2, 1, 0])), Result.success(BuiltList([4, 3, 2, 1, 0])), ]), ); + expect( + roomBloc.room.transformResult((e) => e.lastMessage.chatMessage?.id), + emitsInOrder([ + Result.loading(), + Result.success(null), + Result.success(2), + Result.success(4), + ]), + ); expect( - bloc.lastCommonRead, + roomBloc.lastCommonRead, emitsInOrder([0, 0]), ); + + verify(() => talkBloc.updateRoom(any())).called(1); }); } diff --git a/packages/neon/neon_talk/test/testing.dart b/packages/neon/neon_talk/test/testing.dart index ebb3ee860dd..72f96f3dbbf 100644 --- a/packages/neon/neon_talk/test/testing.dart +++ b/packages/neon/neon_talk/test/testing.dart @@ -36,7 +36,7 @@ Map getRoom({ 'canStartCall': false, 'defaultPermissions': 0, 'description': '', - 'displayName': '', + 'displayName': (id ?? 0).toString(), 'hasCall': false, 'hasPassword': false, 'id': id ?? 0,