diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 424e327543..1f9f31eee1 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -2546,24 +2546,6 @@ "min": {} } }, - "onlineDayAgo": "online {day} day ago", - "@onlineDayAgo": { - "placeholders": { - "day": {} - } - }, - "onlineWeekAgo": "online {week} week ago", - "@onlineWeekAgo": { - "placeholders": { - "week": {} - } - }, - "onlineMonthAgo": "online {month} month ago", - "@onlineMonthAgo": { - "placeholders": { - "month": {} - } - }, "noMessageHereYet": "No message here yet...", "sendMessageGuide": "Send a message or tap on the greeting bellow.", "youCreatedGroupChat": "You created a Group chat", diff --git a/lib/pages/chat/chat_app_bar_title.dart b/lib/pages/chat/chat_app_bar_title.dart index ee6fe20494..2fc56377fb 100644 --- a/lib/pages/chat/chat_app_bar_title.dart +++ b/lib/pages/chat/chat_app_bar_title.dart @@ -1,5 +1,6 @@ import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:fluffychat/pages/chat/chat_app_bar_title_style.dart'; +import 'package:fluffychat/utils/common_helper.dart'; import 'package:fluffychat/utils/room_status_extension.dart'; import 'package:fluffychat/utils/string_extension.dart'; import 'package:flutter/material.dart'; @@ -10,6 +11,7 @@ import 'package:fluffychat/pages/user_bottom_sheet/user_bottom_sheet.dart'; import 'package:fluffychat/utils/adaptive_bottom_sheet.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; @@ -17,7 +19,7 @@ class ChatAppBarTitle extends StatelessWidget { final List selectedEvents; final bool isArchived; final TextEditingController sendController; - final Stream getStreamInstance; + final Stream connectivityResultStream; final VoidCallback onPushDetails; final String? roomName; @@ -29,7 +31,7 @@ class ChatAppBarTitle extends StatelessWidget { required this.selectedEvents, required this.isArchived, required this.sendController, - required this.getStreamInstance, + required this.connectivityResultStream, required this.onPushDetails, }) : super(key: key); @@ -98,7 +100,10 @@ class ChatAppBarTitle extends StatelessWidget { overflow: TextOverflow.ellipsis, style: ChatAppBarTitleStyle.appBarTitleStyle(context), ), - _buildStatusContent(context, room!), + _ChatAppBarStatusContent( + connectivityResultStream: connectivityResultStream, + room: room!, + ), ], ), ), @@ -106,62 +111,182 @@ class ChatAppBarTitle extends StatelessWidget { ), ); } +} - StreamBuilder _buildStatusContent( - BuildContext context, - Room room, - ) { - final TextStyle? statusTextStyle = - ChatAppBarTitleStyle.statusTextStyle(context); +class _ChatAppBarStatusContent extends StatelessWidget { + const _ChatAppBarStatusContent({ + required this.connectivityResultStream, + required this.room, + }); + + final Stream connectivityResultStream; + final Room room; + + @override + Widget build(BuildContext context) { + if (room.isDirectChat) { + return _DirectChatAppBarStatusContent( + connectivityResultStream: connectivityResultStream, + room: room, + ); + } + + return _GroupChatAppBarStatusContent( + connectivityResultStream: connectivityResultStream, + room: room, + ); + } +} + +class _DirectChatAppBarStatusContent extends StatelessWidget { + const _DirectChatAppBarStatusContent({ + required this.connectivityResultStream, + required this.room, + }); + + final Stream connectivityResultStream; + final Room room; + + @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, + ); + 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); + } + }, + ); + }, + ); + } +} + +class _GroupChatAppBarStatusContent extends StatelessWidget { + const _GroupChatAppBarStatusContent({ + required this.connectivityResultStream, + required this.room, + }); + + final Stream connectivityResultStream; + final Room room; + + @override + Widget build(BuildContext context) { return StreamBuilder( - stream: getStreamInstance, + stream: connectivityResultStream, builder: (context, snapshot) { - if (snapshot.hasData && snapshot.data == ConnectivityResult.none) { - return Text( - L10n.of(context)!.noConnection, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: statusTextStyle, - ); + final connectivityResult = tryCast( + snapshot.data, + fallback: ConnectivityResult.none, + ); + + if (snapshot.hasData && connectivityResult == ConnectivityResult.none) { + return _ChatAppBarTitleText(text: L10n.of(context)!.noConnection); } final typingText = room.getLocalizedTypingText(context); if (typingText.isEmpty) { - return Text( - room.getLocalizedStatus(context).capitalize(context), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: statusTextStyle, + return _ChatAppBarTitleText( + text: room.getLocalizedStatus(context).capitalize(context), ); } else { - return IntrinsicWidth( - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: Text( - typingText, - maxLines: 1, - overflow: TextOverflow.clip, - style: statusTextStyle, - ), - ), - SizedBox( - width: 32, - height: 16, - child: Transform.translate( - offset: const Offset(0, -2), - child: LottieBuilder.asset( - 'assets/typing-indicator.zip', - fit: BoxFit.fitWidth, - width: 32, - ), - ), - ), - ], - ), - ); + return _ChatAppBarTitleTyping(typingText: typingText); } }, ); } } + +class _ChatAppBarTitleText extends StatelessWidget { + const _ChatAppBarTitleText({ + required this.text, + }); + + final String text; + + @override + Widget build(BuildContext context) { + final TextStyle? statusTextStyle = + ChatAppBarTitleStyle.statusTextStyle(context); + + return Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: statusTextStyle, + ); + } +} + +class _ChatAppBarTitleTyping extends StatelessWidget { + const _ChatAppBarTitleTyping({ + required this.typingText, + }); + + final String typingText; + + @override + Widget build(BuildContext context) { + final TextStyle? statusTextStyle = + ChatAppBarTitleStyle.statusTextStyle(context); + + return IntrinsicWidth( + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Text( + typingText, + maxLines: 1, + overflow: TextOverflow.clip, + style: statusTextStyle, + ), + ), + SizedBox( + width: 32, + height: 16, + child: Transform.translate( + offset: const Offset(0, -2), + child: LottieBuilder.asset( + 'assets/typing-indicator.zip', + fit: BoxFit.fitWidth, + width: 32, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/chat/chat_view.dart b/lib/pages/chat/chat_view.dart index e84b126f2e..73a18b6cac 100644 --- a/lib/pages/chat/chat_view.dart +++ b/lib/pages/chat/chat_view.dart @@ -174,7 +174,7 @@ class ChatView extends StatelessWidget { room: controller.room, isArchived: controller.isArchived, sendController: controller.sendController, - getStreamInstance: controller + connectivityResultStream: controller .networkConnectionService .getStreamInstance(), actions: _appBarActions(context), diff --git a/lib/utils/common_helper.dart b/lib/utils/common_helper.dart new file mode 100644 index 0000000000..1f9d7d7523 --- /dev/null +++ b/lib/utils/common_helper.dart @@ -0,0 +1,7 @@ +T? tryCast(dynamic value, {T? fallback}) { + if (value != null && value is T) { + return value; + } + + return fallback; +} diff --git a/lib/utils/date_time_extension.dart b/lib/utils/date_time_extension.dart index 44b8b47d0b..718d0963c9 100644 --- a/lib/utils/date_time_extension.dart +++ b/lib/utils/date_time_extension.dart @@ -126,4 +126,14 @@ extension DateTimeExtension on DateTime { String getFormattedCurrentDateTime() { return millisecondsSinceEpoch.toString(); } + + bool isLessThanOneHourAgo({DateTime? other}) { + other ??= DateTime.now(); + return other.difference(this) < const Duration(hours: 1); + } + + bool isLessThanTenHoursAgo({DateTime? other}) { + other ??= DateTime.now(); + return other.difference(this) < const Duration(hours: 10); + } } diff --git a/lib/utils/room_status_extension.dart b/lib/utils/room_status_extension.dart index 433159a399..4e70c2ae5c 100644 --- a/lib/utils/room_status_extension.dart +++ b/lib/utils/room_status_extension.dart @@ -1,3 +1,4 @@ +import 'package:fluffychat/utils/date_time_extension.dart'; import 'package:fluffychat/utils/string_extension.dart'; import 'package:flutter/widgets.dart'; @@ -10,50 +11,15 @@ extension RoomStatusExtension on Room { CachedPresence? get directChatPresence => client.presences[directChatMatrixID]; - String getLocalizedStatus(BuildContext context) { - if (isDirectChat) { - final directChatPresence = this.directChatPresence; - if (directChatPresence != null) { - if (directChatPresence.currentlyActive == true) { - return L10n.of(context)!.onlineStatus; - } - if (directChatPresence.lastActiveTimestamp == null) { - return L10n.of(context)!.onlineLongTimeAgo; - } - final time = directChatPresence.lastActiveTimestamp!; + Stream get directChatPresenceStream => + client.onPresenceChanged.stream; - if (DateTime.now().isBefore(time.add(const Duration(hours: 1)))) { - return L10n.of(context)! - .onlineMinAgo(DateTime.now().difference(time).inMinutes); - } else if (DateTime.now() - .isBefore(time.add(const Duration(hours: 24)))) { - final timeOffline = DateTime.now().difference(time); - return L10n.of(context)!.onlineHourAgo( - timeOffline.inHours, - timeOffline.inMinutes - (timeOffline.inHours * 60), - ); - } else if (DateTime.now().isBefore(time.add(const Duration(days: 7)))) { - final timeOffline = DateTime.now().difference(time); - return L10n.of(context)!.onlineDayAgo(timeOffline.inDays); - } else if (DateTime.now() - .isBefore(time.add(const Duration(days: 30)))) { - final timeOffline = DateTime.now().difference(time); - return L10n.of(context)! - .onlineWeekAgo((timeOffline.inDays / 7).truncate()); - } else if (DateTime.now() - .isBefore(time.add(const Duration(days: 365)))) { - final timeOffline = DateTime.now().difference(time); - return L10n.of(context)! - .onlineMonthAgo((timeOffline.inDays / 30).truncate()); - } - } - return L10n.of(context)!.onlineLongTimeAgo; + String getLocalizedStatus(BuildContext context, {CachedPresence? presence}) { + if (isDirectChat) { + return _getLocalizedStatusDirectChat(presence, context); } - final totalMembers = - (summary.mInvitedMemberCount ?? 0) + (summary.mJoinedMemberCount ?? 0); - - return L10n.of(context)!.membersCount(totalMembers.toString()); + return _getLocalizedStatusGroupChat(context); } String getLocalizedTypingText(BuildContext context) { @@ -120,4 +86,38 @@ extension RoomStatusExtension on Room { } return lastReceipts.toList(); } + + String _getLocalizedStatusGroupChat(BuildContext context) { + final totalMembers = + (summary.mInvitedMemberCount ?? 0) + (summary.mJoinedMemberCount ?? 0); + + return L10n.of(context)!.membersCount(totalMembers.toString()); + } + + String _getLocalizedStatusDirectChat( + CachedPresence? directChatPresence, + BuildContext context, + ) { + if (directChatPresence != null) { + if (directChatPresence.presence == PresenceType.online) { + return L10n.of(context)!.onlineStatus; + } + final lastActiveDateTime = directChatPresence.lastActiveTimestamp; + final currentDateTime = DateTime.now(); + if (lastActiveDateTime != null) { + if (lastActiveDateTime.isLessThanOneHourAgo()) { + return L10n.of(context)!.onlineMinAgo( + currentDateTime.difference(lastActiveDateTime).inMinutes, + ); + } else if (lastActiveDateTime.isLessThanTenHoursAgo()) { + final timeOffline = currentDateTime.difference(lastActiveDateTime); + return L10n.of(context)!.onlineHourAgo( + timeOffline.inHours, + timeOffline.inMinutes - (timeOffline.inHours * 60), + ); + } + } + } + return L10n.of(context)!.offline; + } } diff --git a/lib/widgets/matrix.dart b/lib/widgets/matrix.dart index b7b5774b4a..6ae429abae 100644 --- a/lib/widgets/matrix.dart +++ b/lib/widgets/matrix.dart @@ -593,6 +593,7 @@ class MatrixState extends State with WidgetsBindingObserver { state != AppLifecycleState.paused; client.backgroundSync = foreground; client.syncPresence = foreground ? null : PresenceType.unavailable; + client.sync(setPresence: client.syncPresence); client.requestHistoryOnLimitedTimeline = !foreground; backgroundPush?.clearAllNotifications(); } diff --git a/test/date_time_extension_test.dart b/test/date_time_extension_test.dart new file mode 100644 index 0000000000..9d11c5341e --- /dev/null +++ b/test/date_time_extension_test.dart @@ -0,0 +1,93 @@ +import 'package:fluffychat/utils/date_time_extension.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group("Relative datetime tests", () { + test( + 'isLessThanOneHourAgo should return true if user disconnected less than 1 hour ago', + () { + // 2021-01-01 12:00:00 + final DateTime fakeDateNow = DateTime(2021, 1, 1, 12, 0, 0); + // 2021-01-01 11:20:00 + final DateTime fakeDateLastTimeDisconnected = + DateTime(2021, 1, 1, 11, 20, 0); + + final bool result = + fakeDateLastTimeDisconnected.isLessThanOneHourAgo(other: fakeDateNow); + expect(result, true); + }); + + test( + 'isLessThanOneHourAgo should return false if user disconnected more than 1 hour ago', + () { + // 2021-01-01 12:00:00 + final DateTime fakeDateNow = DateTime(2021, 1, 1, 12, 0, 0); + // 2021-01-01 10:20:00 + final DateTime fakeDateLastTimeDisconnected = + DateTime(2021, 1, 1, 10, 20, 0); + + final bool result = + fakeDateLastTimeDisconnected.isLessThanOneHourAgo(other: fakeDateNow); + expect(result, false); + }); + + test( + 'isLessThanOneHourAgo should return false if user disconnected exactly 1 hour ago', + () { + // 2021-01-01 12:00:00 + final DateTime fakeDateNow = DateTime(2021, 1, 1, 12, 0, 0); + // 2021-01-01 11:00:00 + final DateTime fakeDateLastTimeDisconnected = + DateTime(2021, 1, 1, 11, 0, 0); + + final bool result = + fakeDateLastTimeDisconnected.isLessThanOneHourAgo(other: fakeDateNow); + expect(result, false); + }); + + test( + 'isLessThanTenHoursAgo should return true if user disconnected less than 10 hours ago', + () { + // 2021-01-01 12:00:00 + final DateTime fakeDateNow = DateTime(2021, 1, 1, 12, 0, 0); + // 2021-01-01 03:00:00 + final DateTime fakeDateLastTimeDisconnected = + DateTime(2021, 1, 1, 3, 0, 0); + + final bool result = fakeDateLastTimeDisconnected.isLessThanTenHoursAgo( + other: fakeDateNow, + ); + expect(result, true); + }); + + test( + 'isLessThanTenHoursAgo should return false if user disconnected more than 10 hours ago', + () { + // 2021-01-01 12:00:00 + final DateTime fakeDateNow = DateTime(2021, 1, 1, 12, 0, 0); + // 2020-01-01 01:00:00 + final DateTime fakeDateLastTimeDisconnected = + DateTime(2021, 1, 1, 1, 0, 0); + + final bool result = fakeDateLastTimeDisconnected.isLessThanTenHoursAgo( + other: fakeDateNow, + ); + expect(result, false); + }); + + test( + 'isLessThanTenHoursAgo should return false if user disconnected exactly 10 hours ago', + () { + // 2021-01-01 12:00:00 + final DateTime fakeDateNow = DateTime(2021, 1, 1, 12, 0, 0); + // 2021-01-01 02:00:00 + final DateTime fakeDateLastTimeDisconnected = + DateTime(2021, 1, 1, 2, 0, 0); + + final bool result = fakeDateLastTimeDisconnected.isLessThanTenHoursAgo( + other: fakeDateNow, + ); + expect(result, false); + }); + }); +}