diff --git a/packages/neon/neon_talk/lib/l10n/en.arb b/packages/neon/neon_talk/lib/l10n/en.arb index 7dc6d05dc6d..68f60c1a151 100644 --- a/packages/neon/neon_talk/lib/l10n/en.arb +++ b/packages/neon/neon_talk/lib/l10n/en.arb @@ -1,3 +1,4 @@ { - "@@locale": "en" + "@@locale": "en", + "actorSelf": "You" } diff --git a/packages/neon/neon_talk/lib/l10n/localizations.dart b/packages/neon/neon_talk/lib/l10n/localizations.dart index ef0da4204dd..3cbc878a192 100644 --- a/packages/neon/neon_talk/lib/l10n/localizations.dart +++ b/packages/neon/neon_talk/lib/l10n/localizations.dart @@ -88,6 +88,12 @@ abstract class TalkLocalizations { /// A list of this localizations delegate's supported locales. static const List supportedLocales = [Locale('en')]; + + /// No description provided for @actorSelf. + /// + /// In en, this message translates to: + /// **'You'** + String get actorSelf; } class _TalkLocalizationsDelegate extends LocalizationsDelegate { diff --git a/packages/neon/neon_talk/lib/l10n/localizations_en.dart b/packages/neon/neon_talk/lib/l10n/localizations_en.dart index a2a6c311caa..092b731f4f5 100644 --- a/packages/neon/neon_talk/lib/l10n/localizations_en.dart +++ b/packages/neon/neon_talk/lib/l10n/localizations_en.dart @@ -3,4 +3,7 @@ import 'localizations.dart'; /// The translations for English (`en`). class TalkLocalizationsEn extends TalkLocalizations { TalkLocalizationsEn([String locale = 'en']) : super(locale); + + @override + String get actorSelf => 'You'; } 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..1873436161b 100644 --- a/packages/neon/neon_talk/lib/src/blocs/talk.dart +++ b/packages/neon/neon_talk/lib/src/blocs/talk.dart @@ -1,19 +1,70 @@ +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) => BuiltList( + response.body.ocs.data.rebuild( + (b) => b.sort((a, b) => b.lastActivity.compareTo(a.lastActivity)), + ), + ), + ); + } } diff --git a/packages/neon/neon_talk/lib/src/pages/main.dart b/packages/neon/neon_talk/lib/src/pages/main.dart index da60c8ea868..31d1eb76891 100644 --- a/packages/neon/neon_talk/lib/src/pages/main.dart +++ b/packages/neon/neon_talk/lib/src/pages/main.dart @@ -1,10 +1,74 @@ +import 'dart:async'; + 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/message_preview.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 String actorId; + late TalkBloc bloc; + late StreamSubscription errorsSubscription; + + @override + void initState() { + super.initState(); + + actorId = NeonProvider.of(context).activeAccount.value!.username; + bloc = NeonProvider.of(context); + bloc.errors.listen((error) { + NeonError.showSnackbar(context, error); + }); + } + + @override + void dispose() { + unawaited(errorsSubscription.cancel()); + super.dispose(); + } + + @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) { + final roomType = spreed.RoomType.fromValue(room.type); + return ListTile( + title: Text(room.displayName), + subtitle: room.lastMessage.chatMessage != null + ? TalkMessagePreview( + actorId: actorId, + roomType: roomType, + chatMessage: room.lastMessage.chatMessage!, + ) + : null, + trailing: room.unreadMessages > 0 + ? TalkUnreadIndicator( + room: room, + ) + : null, + ); + } } diff --git a/packages/neon/neon_talk/lib/src/utils/message.dart b/packages/neon/neon_talk/lib/src/utils/message.dart new file mode 100644 index 00000000000..b67413a28da --- /dev/null +++ b/packages/neon/neon_talk/lib/src/utils/message.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; +import 'package:nextcloud/spreed.dart' as spreed; + +/// Builds a [TextSpan] for the given [chatMessage]. +TextSpan buildChatMessage({ + required spreed.ChatMessage chatMessage, +}) => + TextSpan( + text: chatMessage.message, + ); diff --git a/packages/neon/neon_talk/lib/src/widgets/message_preview.dart b/packages/neon/neon_talk/lib/src/widgets/message_preview.dart new file mode 100644 index 00000000000..a0b5111710c --- /dev/null +++ b/packages/neon/neon_talk/lib/src/widgets/message_preview.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:neon_talk/l10n/localizations.dart'; +import 'package:neon_talk/src/utils/message.dart'; +import 'package:nextcloud/spreed.dart' as spreed; + +/// Displays a preview of the [chatMessage] including the display name of the sender. +class TalkMessagePreview extends StatelessWidget { + /// Creates a new Talk message preview. + const TalkMessagePreview({ + required this.actorId, + required this.roomType, + required this.chatMessage, + super.key, + }); + + /// ID of the current actor. + final String actorId; + + /// Type of the room + final spreed.RoomType roomType; + + /// The chat message to preview. + final spreed.ChatMessage chatMessage; + + @override + Widget build(BuildContext context) { + String? actorName; + if (chatMessage.actorId == actorId) { + actorName = TalkLocalizations.of(context).actorSelf; + } else if (!roomType.isSingleUser) { + actorName = chatMessage.actorDisplayName; + } + + return RichText( + maxLines: 1, + overflow: TextOverflow.ellipsis, + text: TextSpan( + style: TextStyle( + color: Theme.of(context).colorScheme.onBackground, + ), + children: [ + if (actorName != null) + TextSpan( + text: '$actorName: ', + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + buildChatMessage( + chatMessage: chatMessage, + ), + ], + ), + ); + } +} 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..22de077f7ac --- /dev/null +++ b/packages/neon/neon_talk/lib/src/widgets/unread_indicator.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:nextcloud/spreed.dart' as spreed; + +/// Displays the number of unread messages and whether the user was mentioned 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) { + assert(room.unreadMessages > 0, 'Need at least on unread message'); + + final colorScheme = Theme.of(context).colorScheme; + + final highlight = room.unreadMention || spreed.RoomType.fromValue(room.type).isSingleUser; + final backgroundColor = highlight ? colorScheme.primaryContainer : colorScheme.background; + final textColor = highlight ? colorScheme.onPrimaryContainer : colorScheme.onBackground; + + return Chip( + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.all(Radius.circular(50)), + side: BorderSide( + color: colorScheme.primaryContainer, + ), + ), + padding: const EdgeInsets.all(2), + backgroundColor: backgroundColor, + avatar: room.unreadMentionDirect + ? Icon( + Icons.alternate_email, + size: 20, + color: textColor, + ) + : null, + 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_unread_mention.png b/packages/neon/neon_talk/test/goldens/unread_indicator_unread_mention.png new file mode 100644 index 00000000000..409f2232584 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..30eee5cea56 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..670e2d99a23 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..409f2232584 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/message_preview_test.dart b/packages/neon/neon_talk/test/message_preview_test.dart new file mode 100644 index 00000000000..563c3e29c51 --- /dev/null +++ b/packages/neon/neon_talk/test/message_preview_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:neon_talk/l10n/localizations.dart'; +import 'package:neon_talk/src/widgets/message_preview.dart'; +import 'package:nextcloud/spreed.dart' as spreed; + +class MockChatMessage extends Mock implements spreed.ChatMessage {} + +Widget wrapWidget(Widget child) => MaterialApp( + localizationsDelegates: TalkLocalizations.localizationsDelegates, + home: Material( + child: child, + ), + ); + +void main() { + testWidgets('Group self', (tester) async { + final chatMessage = MockChatMessage(); + when(() => chatMessage.actorId).thenReturn('test'); + when(() => chatMessage.message).thenReturn('message'); + + await tester.pumpWidget( + wrapWidget( + TalkMessagePreview( + actorId: 'test', + roomType: spreed.RoomType.group, + chatMessage: chatMessage, + ), + ), + ); + expect(find.text('You: message', findRichText: true), findsOne); + }); + + testWidgets('Group other', (tester) async { + final chatMessage = MockChatMessage(); + when(() => chatMessage.actorId).thenReturn('test'); + when(() => chatMessage.actorDisplayName).thenReturn('Test'); + when(() => chatMessage.message).thenReturn('message'); + + await tester.pumpWidget( + wrapWidget( + TalkMessagePreview( + actorId: 'abc', + roomType: spreed.RoomType.group, + chatMessage: chatMessage, + ), + ), + ); + expect(find.text('Test: message', findRichText: true), findsOne); + }); + + testWidgets('One to one self', (tester) async { + final chatMessage = MockChatMessage(); + when(() => chatMessage.actorId).thenReturn('test'); + when(() => chatMessage.message).thenReturn('message'); + + await tester.pumpWidget( + wrapWidget( + TalkMessagePreview( + actorId: 'test', + roomType: spreed.RoomType.oneToOne, + chatMessage: chatMessage, + ), + ), + ); + expect(find.text('You: message', findRichText: true), findsOne); + }); + + testWidgets('One to one other', (tester) async { + final chatMessage = MockChatMessage(); + when(() => chatMessage.actorId).thenReturn('test'); + when(() => chatMessage.message).thenReturn('message'); + + await tester.pumpWidget( + wrapWidget( + TalkMessagePreview( + actorId: 'abc', + roomType: spreed.RoomType.oneToOne, + chatMessage: chatMessage, + ), + ), + ); + expect(find.text('message', findRichText: true), findsOne); + }); +} 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..c60a272b94f --- /dev/null +++ b/packages/neon/neon_talk/test/unread_indicator_test.dart @@ -0,0 +1,94 @@ +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 {} + +Widget wrapWidget(Widget child) => MaterialApp( + home: Material( + child: child, + ), + ); + +void main() { + 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( + wrapWidget( + 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( + wrapWidget( + 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( + wrapWidget( + 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( + wrapWidget( + TalkUnreadIndicator( + room: room, + ), + ), + ); + await expectLater( + find.byType(TalkUnreadIndicator), + matchesGoldenFile('goldens/unread_indicator_unread_mention_direct.png'), + ); + }); +}