diff --git a/packages/neon/neon_talk/lib/src/app.dart b/packages/neon/neon_talk/lib/src/app.dart index 49fe390618c..80cd6f45ca9 100644 --- a/packages/neon/neon_talk/lib/src/app.dart +++ b/packages/neon/neon_talk/lib/src/app.dart @@ -9,6 +9,7 @@ import 'package:neon_talk/src/options.dart'; import 'package:neon_talk/src/pages/main.dart'; import 'package:neon_talk/src/routes.dart'; import 'package:nextcloud/nextcloud.dart'; +import 'package:rxdart/rxdart.dart'; /// Implementation of the server `talk` app. @experimental @@ -39,4 +40,7 @@ class TalkApp extends AppImplementation { @override final RouteBase route = $talkAppRoute; + + @override + BehaviorSubject getUnreadCounter(TalkBloc bloc) => bloc.unreadCounter; } diff --git a/packages/neon/neon_talk/lib/src/blocs/talk.dart b/packages/neon/neon_talk/lib/src/blocs/talk.dart index 410f0b877ae..eede3d39aa5 100644 --- a/packages/neon/neon_talk/lib/src/blocs/talk.dart +++ b/packages/neon/neon_talk/lib/src/blocs/talk.dart @@ -1,19 +1,66 @@ +import 'dart:async'; + +import 'package:built_collection/built_collection.dart'; 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:nextcloud/spreed.dart' as spreed; +import 'package:rxdart/rxdart.dart'; /// Bloc for fetching Talk rooms sealed class TalkBloc implements InteractiveBloc { /// Creates a new Talk Bloc instance. @internal factory TalkBloc(Account account) => _TalkBloc(account); + + /// The list of rooms. + BehaviorSubject>> get rooms; + + /// The total number of unread messages. + BehaviorSubject get unreadCounter; } class _TalkBloc extends InteractiveBloc implements TalkBloc { - _TalkBloc(this.account); + _TalkBloc(this.account) { + rooms.listen((result) { + if (!result.hasData) { + return; + } + + var unread = 0; + for (final room in result.requireData) { + unread += room.unreadMessages; + } + unreadCounter.add(unread); + }); + + unawaited(refresh()); + } final Account account; @override - Future refresh() async {} + final rooms = BehaviorSubject(); + + @override + final unreadCounter = BehaviorSubject(); + + @override + void dispose() { + unawaited(rooms.close()); + unawaited(unreadCounter.close()); + super.dispose(); + } + + @override + Future refresh() async { + await RequestManager.instance.wrapNextcloud( + account: account, + cacheKey: 'talk-rooms', + subject: rooms, + rawResponse: account.client.spreed.room.getRoomsRaw(), + unwrap: (response) => response.body.ocs.data, + ); + } } diff --git a/packages/neon/neon_talk/lib/src/pages/main.dart b/packages/neon/neon_talk/lib/src/pages/main.dart index da60c8ea868..08d8df2f4ed 100644 --- a/packages/neon/neon_talk/lib/src/pages/main.dart +++ b/packages/neon/neon_talk/lib/src/pages/main.dart @@ -1,10 +1,65 @@ import 'package:flutter/material.dart'; +import 'package:neon_framework/blocs.dart'; +import 'package:neon_framework/utils.dart'; +import 'package:neon_framework/widgets.dart'; +import 'package:neon_talk/src/blocs/talk.dart'; +import 'package:neon_talk/src/widgets/unread_indicator.dart'; +import 'package:nextcloud/spreed.dart' as spreed; /// The main page displaying the chat list. -class TalkMainPage extends StatelessWidget { +class TalkMainPage extends StatefulWidget { /// Creates a new Talk main page. const TalkMainPage({super.key}); @override - Widget build(BuildContext context) => const Placeholder(); + State createState() => _TalkMainPageState(); +} + +class _TalkMainPageState extends State { + late TalkBloc bloc; + + @override + void initState() { + super.initState(); + + bloc = NeonProvider.of(context); + + bloc.errors.listen((error) { + NeonError.showSnackbar(context, error); + }); + } + + @override + Widget build(BuildContext context) => ResultBuilder.behaviorSubject( + subject: bloc.rooms, + builder: (context, rooms) => NeonListView( + scrollKey: 'talk-rooms', + isLoading: rooms.isLoading, + error: rooms.error, + onRefresh: bloc.refresh, + itemCount: rooms.data?.length ?? 0, + itemBuilder: (context, index) => buildRoom(rooms.requireData[index]), + ), + ); + + Widget buildRoom(spreed.Room room) => ListTile( + title: Text(room.displayName), + subtitle: buildMessagePreview(room.lastMessage.chatMessage), + trailing: TalkUnreadIndicator( + room: room, + ), + ); + + Widget buildMessagePreview(spreed.ChatMessage? chatMessage) { + final buffer = StringBuffer(); + + if (chatMessage != null) { + buffer.write('${chatMessage.actorDisplayName}: ${chatMessage.message}'); + } + + return Text( + buffer.toString(), + maxLines: 1, + ); + } } diff --git a/packages/neon/neon_talk/lib/src/widgets/unread_indicator.dart b/packages/neon/neon_talk/lib/src/widgets/unread_indicator.dart new file mode 100644 index 00000000000..ba83096bd41 --- /dev/null +++ b/packages/neon/neon_talk/lib/src/widgets/unread_indicator.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:nextcloud/spreed.dart' as spreed; + +/// Displays the number of unread messages and whether the user was mentioned directly or indirectly for a given [room]. +class TalkUnreadIndicator extends StatelessWidget { + /// Creates a new Talk unread indicator. + const TalkUnreadIndicator({ + required this.room, + super.key, + }); + + /// The room that the indicator will display unread messages and mentions for. + final spreed.Room room; + + @override + Widget build(BuildContext context) { + if (room.unreadMessages == 0) { + return const SizedBox.shrink(); + } + + final Color textColor; + final Color? backgroundColor; + final Color borderColor; + + if (room.unreadMentionDirect || spreed.RoomType.fromValue(room.type).isSingleUser) { + textColor = Theme.of(context).colorScheme.onPrimary; + backgroundColor = Theme.of(context).colorScheme.primary; + borderColor = Theme.of(context).colorScheme.onBackground; + } else { + textColor = Theme.of(context).colorScheme.onBackground; + backgroundColor = null; + borderColor = room.unreadMention ? Theme.of(context).colorScheme.primary : Colors.grey; + } + + return Chip( + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.all(Radius.circular(50)), + side: BorderSide( + color: borderColor, + ), + ), + padding: const EdgeInsets.all(2), + backgroundColor: backgroundColor, + label: Text( + room.unreadMessages.toString(), + style: TextStyle( + fontWeight: FontWeight.bold, + fontFamily: 'monospace', + color: textColor, + ), + ), + ); + } +} diff --git a/packages/neon/neon_talk/pubspec.yaml b/packages/neon/neon_talk/pubspec.yaml index 3205f0a4da9..65428bcd91c 100644 --- a/packages/neon/neon_talk/pubspec.yaml +++ b/packages/neon/neon_talk/pubspec.yaml @@ -19,10 +19,15 @@ dependencies: url: https://github.com/nextcloud/neon path: packages/neon_framework nextcloud: ^5.0.2 + rxdart: ^0.27.0 dev_dependencies: build_runner: ^2.4.8 + flutter_test: + sdk: flutter go_router_builder: ^2.4.1 + http: ^1.2.1 + mocktail: ^1.0.3 neon_lints: git: url: https://github.com/nextcloud/neon diff --git a/packages/neon/neon_talk/test/bloc_test.dart b/packages/neon/neon_talk/test/bloc_test.dart new file mode 100644 index 00000000000..27fc4f2659c --- /dev/null +++ b/packages/neon/neon_talk/test/bloc_test.dart @@ -0,0 +1,134 @@ +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'; +import 'package:neon_talk/src/blocs/talk.dart'; + +Map getRoom({ + required int id, + required int unreadMessages, +}) => + { + 'actorId': '', + 'actorType': '', + 'attendeeId': 0, + 'attendeePermissions': 0, + 'avatarVersion': '', + 'breakoutRoomMode': 0, + 'breakoutRoomStatus': 0, + 'callFlag': 0, + 'callPermissions': 0, + 'callRecording': 0, + 'callStartTime': 0, + 'canDeleteConversation': false, + 'canEnableSIP': false, + 'canLeaveConversation': false, + 'canStartCall': false, + 'defaultPermissions': 0, + 'description': '', + 'displayName': '', + 'hasCall': false, + 'hasPassword': false, + 'id': id, + 'isFavorite': false, + 'lastActivity': 0, + 'lastCommonReadMessage': 0, + 'lastMessage': [], + 'lastPing': 0, + 'lastReadMessage': 0, + 'listable': 0, + 'lobbyState': 0, + 'lobbyTimer': 0, + 'messageExpiration': 0, + 'name': '', + 'notificationCalls': 0, + 'notificationLevel': 0, + 'objectId': '', + 'objectType': '', + 'participantFlags': 0, + 'participantType': 0, + 'permissions': 0, + 'readOnly': 0, + 'sessionId': '', + 'sipEnabled': 0, + 'token': '', + 'type': 0, + 'unreadMention': false, + 'unreadMentionDirect': false, + 'unreadMessages': unreadMessages, + }; + +Account mockTalkAccount() => mockServer({ + RegExp(r'/ocs/v2\.php/apps/spreed/api/v4/room'): { + 'get': (match, queryParameters) => Response( + json.encode({ + 'ocs': { + 'meta': {'status': '', 'statuscode': 0}, + 'data': [ + getRoom( + id: 0, + unreadMessages: 0, + ), + getRoom( + id: 1, + unreadMessages: 1, + ), + getRoom( + id: 2, + unreadMessages: 2, + ), + ], + }, + }), + 200, + ), + }, + }); + +void main() { + late Account account; + late TalkBloc bloc; + + setUpAll(() { + final storage = MockNeonStorage(); + when(() => storage.requestCache).thenReturn(null); + }); + + setUp(() { + account = mockTalkAccount(); + bloc = TalkBloc(account); + }); + + tearDown(() { + bloc.dispose(); + }); + + test('refresh', () async { + expect( + bloc.rooms.transformResult((e) => BuiltList(e.map((r) => r.id))), + emitsInOrder([ + Result>.loading(), + Result.success(BuiltList([0, 1, 2])), + Result.success(BuiltList([0, 1, 2])).asLoading(), + Result.success(BuiltList([0, 1, 2])), + ]), + ); + expect( + bloc.unreadCounter, + emitsInOrder([ + 3, + 3, + 3, + ]), + ); + + // 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(); + }); +} diff --git a/packages/neon/neon_talk/test/goldens/unread_indicator_no_unread_messages.png b/packages/neon/neon_talk/test/goldens/unread_indicator_no_unread_messages.png new file mode 100644 index 00000000000..128801a1568 Binary files /dev/null and b/packages/neon/neon_talk/test/goldens/unread_indicator_no_unread_messages.png differ diff --git a/packages/neon/neon_talk/test/goldens/unread_indicator_unread_mention.png b/packages/neon/neon_talk/test/goldens/unread_indicator_unread_mention.png new file mode 100644 index 00000000000..4304bff7f71 Binary files /dev/null and b/packages/neon/neon_talk/test/goldens/unread_indicator_unread_mention.png differ diff --git a/packages/neon/neon_talk/test/goldens/unread_indicator_unread_mention_direct.png b/packages/neon/neon_talk/test/goldens/unread_indicator_unread_mention_direct.png new file mode 100644 index 00000000000..331cdb4f789 Binary files /dev/null and b/packages/neon/neon_talk/test/goldens/unread_indicator_unread_mention_direct.png differ diff --git a/packages/neon/neon_talk/test/goldens/unread_indicator_unread_messages.png b/packages/neon/neon_talk/test/goldens/unread_indicator_unread_messages.png new file mode 100644 index 00000000000..cf82ba2abf5 Binary files /dev/null and b/packages/neon/neon_talk/test/goldens/unread_indicator_unread_messages.png differ diff --git a/packages/neon/neon_talk/test/goldens/unread_indicator_unread_single_user_messages.png b/packages/neon/neon_talk/test/goldens/unread_indicator_unread_single_user_messages.png new file mode 100644 index 00000000000..331cdb4f789 Binary files /dev/null and b/packages/neon/neon_talk/test/goldens/unread_indicator_unread_single_user_messages.png differ diff --git a/packages/neon/neon_talk/test/unread_indicator_test.dart b/packages/neon/neon_talk/test/unread_indicator_test.dart new file mode 100644 index 00000000000..586f6cb3069 --- /dev/null +++ b/packages/neon/neon_talk/test/unread_indicator_test.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:neon_talk/src/widgets/unread_indicator.dart'; +import 'package:nextcloud/spreed.dart' as spreed; + +class MockRoom extends Mock implements spreed.Room {} + +void main() { + testWidgets('No unread messages', (tester) async { + final room = MockRoom(); + when(() => room.unreadMessages).thenReturn(0); + + await tester.pumpWidget( + TalkUnreadIndicator( + room: room, + ), + ); + await expectLater( + find.byType(TalkUnreadIndicator), + matchesGoldenFile('goldens/unread_indicator_no_unread_messages.png'), + ); + }); + + testWidgets('Unread messages', (tester) async { + final room = MockRoom(); + when(() => room.unreadMessages).thenReturn(42); + when(() => room.unreadMention).thenReturn(false); + when(() => room.unreadMentionDirect).thenReturn(false); + when(() => room.type).thenReturn(spreed.RoomType.group.value); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: TalkUnreadIndicator( + room: room, + ), + ), + ), + ); + await expectLater( + find.byType(TalkUnreadIndicator), + matchesGoldenFile('goldens/unread_indicator_unread_messages.png'), + ); + }); + + testWidgets('Unread single user messages', (tester) async { + final room = MockRoom(); + when(() => room.unreadMessages).thenReturn(42); + when(() => room.unreadMention).thenReturn(false); + when(() => room.unreadMentionDirect).thenReturn(false); + when(() => room.type).thenReturn(spreed.RoomType.oneToOne.value); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: TalkUnreadIndicator( + room: room, + ), + ), + ), + ); + await expectLater( + find.byType(TalkUnreadIndicator), + matchesGoldenFile('goldens/unread_indicator_unread_single_user_messages.png'), + ); + }); + + testWidgets('Unread mention', (tester) async { + final room = MockRoom(); + when(() => room.unreadMessages).thenReturn(42); + when(() => room.unreadMention).thenReturn(true); + when(() => room.unreadMentionDirect).thenReturn(false); + when(() => room.type).thenReturn(spreed.RoomType.group.value); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: TalkUnreadIndicator( + room: room, + ), + ), + ), + ); + await expectLater( + find.byType(TalkUnreadIndicator), + matchesGoldenFile('goldens/unread_indicator_unread_mention.png'), + ); + }); + + testWidgets('Unread mention direct', (tester) async { + final room = MockRoom(); + when(() => room.unreadMessages).thenReturn(42); + when(() => room.unreadMention).thenReturn(true); + when(() => room.unreadMentionDirect).thenReturn(true); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: TalkUnreadIndicator( + room: room, + ), + ), + ), + ); + await expectLater( + find.byType(TalkUnreadIndicator), + matchesGoldenFile('goldens/unread_indicator_unread_mention_direct.png'), + ); + }); +}