diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index a760083419..30f44a28a9 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -1180,6 +1180,7 @@ "type": "text", "placeholders": {} }, + "loading": "Loading status...", "loadMore": "Load moreā€¦", "@loadMore": { "type": "text", @@ -1376,6 +1377,11 @@ "type": "text", "placeholders": {} }, + "aWhileAgo": "a while ago", + "@aWhileAgo": { + "type": "text", + "placeholders": {} + }, "ok": "Ok", "@ok": { "type": "text", @@ -2566,6 +2572,12 @@ "hour": {} } }, + "onlineDayAgo": "online {day}d ago", + "@onlineDayAgo": { + "placeholders": { + "day": {} + } + }, "noMessageHereYet": "No message here yet...", "@noMessageHereYet": {}, "sendMessageGuide": "Send a message or tap on the greeting bellow.", diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 1f627d4486..3198be5910 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -12,6 +12,7 @@ import 'package:fluffychat/presentation/model/chat/view_event_list_ui_state.dart import 'package:fluffychat/utils/extension/basic_event_extension.dart'; import 'package:fluffychat/utils/extension/event_status_custom_extension.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/filtered_timeline_extension.dart'; +import 'package:fluffychat/utils/room_status_extension.dart'; import 'package:fluffychat/widgets/context_menu/context_menu_action.dart'; import 'package:fluffychat/widgets/mixins/popup_menu_widget_style.dart'; import 'package:fluffychat/widgets/mixins/twake_context_menu_mixin.dart'; @@ -258,6 +259,39 @@ class ChatController extends State SuggestionsController> suggestionsController = SuggestionsController(); + ValueNotifier cachedPresenceNotifier = ValueNotifier(null); + + StreamController cachedPresenceStreamController = + StreamController.broadcast(); + + Future initCachedPresence() async { + cachedPresenceNotifier.value = room?.directChatPresence; + if (room?.directChatMatrixID != null) { + Matrix.of(context).client.onlatestPresenceChanged.stream.listen((event) { + if (event.userid == room!.directChatMatrixID) { + Logs().v( + 'onlatestPresenceChanged: ${event.presence}, ${event.lastActiveTimestamp}', + ); + cachedPresenceStreamController.add(event); + } + }); + try { + final getPresenceResponse = await client.getPresence( + room!.directChatMatrixID!, + ); + + cachedPresenceNotifier.value = CachedPresence.fromPresenceResponse( + getPresenceResponse, + room!.directChatMatrixID!, + ); + } catch (e) { + Logs().e('Failed to get presence', e); + cachedPresenceNotifier.value = + CachedPresence.neverSeen(room!.directChatMatrixID!); + } + } + } + bool isUnpinEvent(Event event) => room?.pinnedEventIds .firstWhereOrNull((eventId) => eventId == event.eventId) != @@ -1923,6 +1957,7 @@ class ChatController extends State } _handleReceivedShareFiles(); _listenRoomUpdateEvent(); + initCachedPresence(); }); } @@ -1968,6 +2003,8 @@ class ChatController extends State keyboardVisibilitySubscription?.cancel(); InViewNotifierListCustom.of(context)?.dispose(); replyEventNotifier.dispose(); + cachedPresenceStreamController.close(); + cachedPresenceNotifier.dispose(); super.dispose(); } diff --git a/lib/pages/chat/chat_app_bar_title.dart b/lib/pages/chat/chat_app_bar_title.dart index caf6bb1ba2..2e16579362 100644 --- a/lib/pages/chat/chat_app_bar_title.dart +++ b/lib/pages/chat/chat_app_bar_title.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:fluffychat/pages/chat/chat_app_bar_title_style.dart'; import 'package:fluffychat/resource/image_paths.dart'; @@ -11,7 +13,6 @@ import 'package:lottie/lottie.dart'; import 'package:matrix/matrix.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:fluffychat/widgets/avatar/avatar.dart'; -import 'package:rxdart/rxdart.dart'; class ChatAppBarTitle extends StatelessWidget { final Widget? actions; @@ -22,6 +23,8 @@ class ChatAppBarTitle extends StatelessWidget { final Stream connectivityResultStream; final VoidCallback onPushDetails; final String? roomName; + final ValueNotifier cachedPresenceNotifier; + final StreamController? cachedPresenceStreamController; const ChatAppBarTitle({ super.key, @@ -33,6 +36,8 @@ class ChatAppBarTitle extends StatelessWidget { required this.sendController, required this.connectivityResultStream, required this.onPushDetails, + required this.cachedPresenceNotifier, + this.cachedPresenceStreamController, }); @override @@ -106,6 +111,9 @@ class ChatAppBarTitle extends StatelessWidget { _ChatAppBarStatusContent( connectivityResultStream: connectivityResultStream, room: room!, + cachedPresenceNotifier: cachedPresenceNotifier, + cachedPresenceStreamController: + cachedPresenceStreamController, ), ], ), @@ -120,10 +128,14 @@ class _ChatAppBarStatusContent extends StatelessWidget { const _ChatAppBarStatusContent({ required this.connectivityResultStream, required this.room, + required this.cachedPresenceNotifier, + this.cachedPresenceStreamController, }); final Stream connectivityResultStream; final Room room; + final ValueNotifier cachedPresenceNotifier; + final StreamController? cachedPresenceStreamController; @override Widget build(BuildContext context) { @@ -131,6 +143,8 @@ class _ChatAppBarStatusContent extends StatelessWidget { return _DirectChatAppBarStatusContent( connectivityResultStream: connectivityResultStream, room: room, + cachedPresenceNotifier: cachedPresenceNotifier, + cachedPresenceStreamController: cachedPresenceStreamController!, ); } @@ -145,50 +159,61 @@ class _DirectChatAppBarStatusContent extends StatelessWidget { const _DirectChatAppBarStatusContent({ required this.connectivityResultStream, required this.room, + required this.cachedPresenceNotifier, + required this.cachedPresenceStreamController, }); final Stream connectivityResultStream; final Room room; + final ValueNotifier cachedPresenceNotifier; + final StreamController cachedPresenceStreamController; @override Widget build(BuildContext context) { CachedPresence? directChatPresence = room.directChatPresence; - return FutureBuilder( - future: room.client.getPresence(room.directChatMatrixID!), - builder: (context, futureSnapshot) { - if (futureSnapshot.hasData) { - directChatPresence = CachedPresence.fromPresenceResponse( - futureSnapshot.data!, - room.directChatMatrixID!, - ); - } - return StreamBuilder( - stream: CombineLatestStream.list( - [connectivityResultStream, room.directChatPresenceStream], - ), - builder: (context, snapshot) { - final connectivityResult = tryCast( - snapshot.data?[0], - fallback: ConnectivityResult.none, - ); - directChatPresence = tryCast( - snapshot.data?[1], - fallback: directChatPresence, + return ValueListenableBuilder( + valueListenable: cachedPresenceNotifier, + builder: (context, directChatCachedPresence, child) { + return StreamBuilder( + stream: connectivityResultStream, + builder: (context, connectivitySnapshot) { + return StreamBuilder( + stream: cachedPresenceStreamController.stream, + builder: (context, cachedPresenceSnapshot) { + final connectivityResult = tryCast( + connectivitySnapshot.data, + fallback: ConnectivityResult.none, + ); + directChatPresence = tryCast( + cachedPresenceSnapshot.data, + fallback: directChatCachedPresence, + ); + if (connectivitySnapshot.hasData && + connectivityResult == ConnectivityResult.none) { + return ChatAppBarTitleText( + text: L10n.of(context)!.noConnection, + ); + } + if (directChatPresence == null) { + return ChatAppBarTitleText( + text: L10n.of(context)!.loading, + ); + } + final typingText = room.getLocalizedTypingText(context); + if (typingText.isEmpty) { + return ChatAppBarTitleText( + text: room + .getLocalizedStatus( + context, + presence: directChatPresence, + ) + .capitalize(context), + ); + } else { + return _ChatAppBarTitleTyping(typingText: typingText); + } + }, ); - if (snapshot.hasData && - connectivityResult == ConnectivityResult.none) { - return _ChatAppBarTitleText(text: L10n.of(context)!.noConnection); - } - final typingText = room.getLocalizedTypingText(context); - if (typingText.isEmpty) { - return _ChatAppBarTitleText( - text: room - .getLocalizedStatus(context, presence: directChatPresence) - .capitalize(context), - ); - } else { - return _ChatAppBarTitleTyping(typingText: typingText); - } }, ); }, @@ -216,11 +241,11 @@ class _GroupChatAppBarStatusContent extends StatelessWidget { ); if (snapshot.hasData && connectivityResult == ConnectivityResult.none) { - return _ChatAppBarTitleText(text: L10n.of(context)!.noConnection); + return ChatAppBarTitleText(text: L10n.of(context)!.noConnection); } final typingText = room.getLocalizedTypingText(context); if (typingText.isEmpty) { - return _ChatAppBarTitleText( + return ChatAppBarTitleText( text: room.getLocalizedStatus(context).capitalize(context), ); } else { @@ -231,8 +256,9 @@ class _GroupChatAppBarStatusContent extends StatelessWidget { } } -class _ChatAppBarTitleText extends StatelessWidget { - const _ChatAppBarTitleText({ +class ChatAppBarTitleText extends StatelessWidget { + const ChatAppBarTitleText({ + super.key, required this.text, }); diff --git a/lib/pages/chat/chat_view.dart b/lib/pages/chat/chat_view.dart index 337bb82972..55c66525c5 100644 --- a/lib/pages/chat/chat_view.dart +++ b/lib/pages/chat/chat_view.dart @@ -136,6 +136,10 @@ class ChatView extends StatelessWidget with MessageContentMixin { actions: _appBarActions(context), onPushDetails: controller.onPushDetails, roomName: controller.roomName, + cachedPresenceNotifier: + controller.cachedPresenceNotifier, + cachedPresenceStreamController: + controller.cachedPresenceStreamController, ), ), ], diff --git a/lib/utils/date_time_extension.dart b/lib/utils/date_time_extension.dart index 016957b97c..a121a05aef 100644 --- a/lib/utils/date_time_extension.dart +++ b/lib/utils/date_time_extension.dart @@ -160,9 +160,14 @@ extension DateTimeExtension on DateTime { return other.difference(this) < const Duration(hours: 1); } - bool isLessThanTenHoursAgo({DateTime? other}) { + bool isLessThanADayAgo({DateTime? other}) { other ??= DateTime.now(); - return other.difference(this) < const Duration(hours: 10); + return other.difference(this) < const Duration(hours: 24); + } + + bool isLessThan30DaysAgo({DateTime? other}) { + other ??= DateTime.now(); + return other.difference(this) < const Duration(days: 30); } String _formatDateWithLocale(BuildContext context, String pattern) { diff --git a/lib/utils/room_status_extension.dart b/lib/utils/room_status_extension.dart index c0eb53c25e..776d563c3c 100644 --- a/lib/utils/room_status_extension.dart +++ b/lib/utils/room_status_extension.dart @@ -16,7 +16,7 @@ extension RoomStatusExtension on Room { String getLocalizedStatus(BuildContext context, {CachedPresence? presence}) { if (isDirectChat) { - return _getLocalizedStatusDirectChat(presence, context); + return getLocalizedStatusDirectChat(presence, context); } return _getLocalizedStatusGroupChat(context); @@ -98,7 +98,7 @@ extension RoomStatusExtension on Room { return L10n.of(context)!.countMembers(totalMembers); } - String _getLocalizedStatusDirectChat( + String getLocalizedStatusDirectChat( CachedPresence? directChatPresence, BuildContext context, ) { @@ -115,11 +115,17 @@ extension RoomStatusExtension on Room { return L10n.of(context)!.onlineMinAgo( currentDateTime.difference(lastActiveDateTime).inMinutes, ); - } else if (lastActiveDateTime.isLessThanTenHoursAgo()) { + } else if (lastActiveDateTime.isLessThanADayAgo()) { final timeOffline = currentDateTime.difference(lastActiveDateTime); return L10n.of(context)!.onlineHourAgo( (timeOffline.inMinutes / 60).round(), ); + } else if (lastActiveDateTime.isLessThan30DaysAgo()) { + return L10n.of(context)!.onlineDayAgo( + currentDateTime.difference(lastActiveDateTime).inDays, + ); + } else { + return L10n.of(context)!.aWhileAgo; } } } diff --git a/test/utils/get_localize_status_test.dart b/test/utils/get_localize_status_test.dart new file mode 100644 index 0000000000..c1c284fe99 --- /dev/null +++ b/test/utils/get_localize_status_test.dart @@ -0,0 +1,352 @@ +import 'package:fluffychat/utils/room_status_extension.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:matrix/matrix.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:mockito/annotations.dart'; + +import 'get_localize_status_test.mocks.dart'; + +@GenerateMocks( + [ + Logs, + Client, + ], +) +void main() async { + TestWidgetsFlutterBinding.ensureInitialized(); + await initializeDateFormatting(); + + group("getLocalizedStatus function test", () { + const textWidgetKey = ValueKey('textWidget'); + + testWidgets( + 'GIVEN the presenceType be online\n' + 'THEN should display the status as active now\n', + (WidgetTester tester) async { + // Given + final presence = CachedPresence( + PresenceType.online, + 0, + "", + true, + "testuserid", + ); + + // WHEN + await prepareTextWidget(presence, textWidgetKey, tester); + + //EXPECT + final textWidgetFinder = find.byKey(textWidgetKey); + + expect(textWidgetFinder, findsOneWidget); + + final Text textWidget = tester.widget(textWidgetFinder) as Text; + + expect(textWidget.data, isNotNull); + + expect(textWidget.data, equals('Active now')); + }); + + testWidgets( + 'GIVEN the presence time to be one minute from present with currently active be false\n' + 'THEN should display the status as active now\n', + (WidgetTester tester) async { + // Given + final lessThanOneMinuteAgo = const Duration(seconds: 30).inMilliseconds; + final presence = CachedPresence( + PresenceType.offline, + lessThanOneMinuteAgo, + "", + false, + "testuserid", + ); + + // WHEN + await prepareTextWidget(presence, textWidgetKey, tester); + + final textWidgetFinder = find.byKey(textWidgetKey); + + expect(textWidgetFinder, findsOneWidget); + + final Text textWidget = tester.widget(textWidgetFinder) as Text; + + expect(textWidget.data, isNotNull); + + expect(textWidget.data, equals('Active now')); + }); + + testWidgets( + 'GIVEN the presence time to be 10 minute from present with currently active be false\n' + 'THEN should display the status as online 10 minutes ago\n', + (WidgetTester tester) async { + // Given + final lessThan10MinutesAgo = (const Duration(minutes: 10)).inMilliseconds; + final presence = CachedPresence( + PresenceType.offline, + lessThan10MinutesAgo, + "", + false, + "testuserid", + ); + + // WHEN + await prepareTextWidget(presence, textWidgetKey, tester); + + final textWidgetFinder = find.byKey(textWidgetKey); + + expect(textWidgetFinder, findsOneWidget); + + final Text textWidget = tester.widget(textWidgetFinder) as Text; + + expect(textWidget.data, isNotNull); + + expect(textWidget.data, equals('online 10m ago')); + }); + + testWidgets( + 'GIVEN the presence time to be 10 minute from present with currently active be false\n' + 'THEN should display the status as online 10 minutes ago\n', + (WidgetTester tester) async { + // Given + final lessThan10MinutesAgo = (const Duration(minutes: 10)).inMilliseconds; + final presence = CachedPresence( + PresenceType.offline, + lessThan10MinutesAgo, + "", + false, + "testuserid", + ); + + // WHEN + await prepareTextWidget(presence, textWidgetKey, tester); + + final textWidgetFinder = find.byKey(textWidgetKey); + + expect(textWidgetFinder, findsOneWidget); + + final Text textWidget = tester.widget(textWidgetFinder) as Text; + + expect(textWidget.data, isNotNull); + + expect(textWidget.data, equals('online 10m ago')); + }); + + testWidgets( + 'GIVEN the presence time to be 10 minute from present with currently active be false\n' + 'THEN should display the status as online 10 minutes ago\n', + (WidgetTester tester) async { + // Given + final lessThan20hoursAgo = (const Duration(hours: 20)).inMilliseconds; + final presence = CachedPresence( + PresenceType.offline, + lessThan20hoursAgo, + "", + false, + "testuserid", + ); + + // WHEN + await prepareTextWidget(presence, textWidgetKey, tester); + + final textWidgetFinder = find.byKey(textWidgetKey); + + expect(textWidgetFinder, findsOneWidget); + + final Text textWidget = tester.widget(textWidgetFinder) as Text; + + expect(textWidget.data, isNotNull); + + expect(textWidget.data, equals('online 20h ago')); + }); + + testWidgets( + 'GIVEN the presence time to be 5 days ago from present with currently active be false\n' + 'THEN should display the status as online 5d ago\n', + (WidgetTester tester) async { + // Given + final lessThan5daysAgo = (const Duration(days: 5)).inMilliseconds; + final presence = CachedPresence( + PresenceType.offline, + lessThan5daysAgo, + "", + false, + "testuserid", + ); + + // WHEN + await prepareTextWidget(presence, textWidgetKey, tester); + + final textWidgetFinder = find.byKey(textWidgetKey); + + expect(textWidgetFinder, findsOneWidget); + + final Text textWidget = tester.widget(textWidgetFinder) as Text; + + expect(textWidget.data, isNotNull); + + expect(textWidget.data, equals('online 5d ago')); + }); + + testWidgets( + 'GIVEN the presence time to be more than 30d ago from present with currently active be false\n' + 'THEN should display the status as a while ago\n', + (WidgetTester tester) async { + // Given + final lessThan60daysAgo = (const Duration(days: 60)).inMilliseconds; + final presence = CachedPresence( + PresenceType.offline, + lessThan60daysAgo, + "", + false, + "testuserid", + ); + + // WHEN + await prepareTextWidget(presence, textWidgetKey, tester); + + final textWidgetFinder = find.byKey(textWidgetKey); + + expect(textWidgetFinder, findsOneWidget); + + final Text textWidget = tester.widget(textWidgetFinder) as Text; + + expect(textWidget.data, isNotNull); + + expect(textWidget.data, equals('a while ago')); + }); + + testWidgets( + 'GIVEN the presence time to be more than 30d ago from present with currently active be false\n' + 'THEN should display the status as a while ago\n', + (WidgetTester tester) async { + // Given + final lessThan60daysAgo = (const Duration(days: 60)).inMilliseconds; + final presence = CachedPresence( + PresenceType.offline, + lessThan60daysAgo, + "", + false, + "testuserid", + ); + + // WHEN + await prepareTextWidget(presence, textWidgetKey, tester); + + final textWidgetFinder = find.byKey(textWidgetKey); + + expect(textWidgetFinder, findsOneWidget); + + final Text textWidget = tester.widget(textWidgetFinder) as Text; + + expect(textWidget.data, isNotNull); + + expect(textWidget.data, equals('a while ago')); + }); + + testWidgets( + 'GIVEN the presence to be unavailable \n' + 'THEN should display the status as offline\n', + (WidgetTester tester) async { + // Given + final presence = CachedPresence( + PresenceType.unavailable, + null, + "", + false, + "testuserid", + ); + + // WHEN + await prepareTextWidget(presence, textWidgetKey, tester); + + final textWidgetFinder = find.byKey(textWidgetKey); + + expect(textWidgetFinder, findsOneWidget); + + final Text textWidget = tester.widget(textWidgetFinder) as Text; + + expect(textWidget.data, isNotNull); + + expect(textWidget.data, equals('Offline')); + }); + + testWidgets( + 'GIVEN the presence to be unavailable \n' + 'THEN should display the status as offline\n', + (WidgetTester tester) async { + // Given + final presence = CachedPresence( + PresenceType.unavailable, + null, + "", + false, + "testuserid", + ); + + // WHEN + await prepareTextWidget(presence, textWidgetKey, tester); + + final textWidgetFinder = find.byKey(textWidgetKey); + + expect(textWidgetFinder, findsOneWidget); + + final Text textWidget = tester.widget(textWidgetFinder) as Text; + + expect(textWidget.data, isNotNull); + + expect(textWidget.data, equals('Offline')); + }); + + testWidgets( + 'GIVEN the presence to be null \n' + 'THEN should display the status as offline\n', + (WidgetTester tester) async { + // Given + const presence = null; + + // WHEN + await prepareTextWidget(presence, textWidgetKey, tester); + + final textWidgetFinder = find.byKey(textWidgetKey); + + expect(textWidgetFinder, findsOneWidget); + + final Text textWidget = tester.widget(textWidgetFinder) as Text; + + expect(textWidget.data, isNotNull); + + expect(textWidget.data, equals('Offline')); + }); + }); +} + +Future prepareTextWidget( + CachedPresence? presence, + ValueKey textWidgetKey, + WidgetTester tester, +) async { + final client = MockClient(); + final room = Room(id: 'testid', client: client); + + // WHEN + final textWidgetBuilder = Builder( + builder: (BuildContext context) { + final displayText = room.getLocalizedStatusDirectChat(presence, context); + return Text( + key: textWidgetKey, + displayText, + ); + }, + ); + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: L10n.localizationsDelegates, + supportedLocales: L10n.supportedLocales, + home: textWidgetBuilder, + ), + ); +}