diff --git a/docusaurus/docs/Flutter/02-stream_chat_flutter/03-custom_widgets/03-customize_message_actions.mdx b/docusaurus/docs/Flutter/02-stream_chat_flutter/03-custom_widgets/03-customize_message_actions.mdx index b329f7648..865cccb70 100644 --- a/docusaurus/docs/Flutter/02-stream_chat_flutter/03-custom_widgets/03-customize_message_actions.mdx +++ b/docusaurus/docs/Flutter/02-stream_chat_flutter/03-custom_widgets/03-customize_message_actions.mdx @@ -29,11 +29,18 @@ By default we render the following message actions: * pin message +* mark unread + :::note Edit and delete message are only available on messages sent by the user. Additionally, pinning a message requires you to add the roles which are allowed to pin messages. ::: +:::note +Mark unread message is only available on messages sent by other users and only when read events are enabled for the channel. +Additionally, it's not possible to mark messages inside threads as unread. +::: + ### Partially remove some message actions For example, if you only want to keep "copy message" and "delete message": @@ -49,6 +56,7 @@ StreamMessageListView( showDeleteMessage: details.isMyMessage, showReplyMessage: false, showThreadReplyMessage: false, + showMarkUnreadMessage: false, ); }, ) diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index d1b342ee9..0f62575a2 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -1,3 +1,20 @@ +## Unreleased + +✅ Added +`StreamMessageListView` will now by default show unread indicator floating on top of the message list that will scroll to last read message when tapped and mark channel as unread when dismissed. + +- Added `showUnreadIndicator` parameter to `StreamMessageListView` that controls visibility of new channel unread indicator +- Added `unreadIndicatorBuilder` parameter to `StreamMessageListView` that allows to provide custom unread indicator builder +- Added `markReadWhenAtTheBottom` parameter to `StreamMessageListView` that will toggle, previously default, behaviour of marking channel as read when message list is scrolled to the bottom (now default is `false`) +- Added `showUnreadCountOnScrollToBottom` parameter to `StreamMessageListView` that will toggle, previously shown by default, unread messages counter on the scroll to bottom button (no default is `false`) + +Added Mark as Unread option to `StreamMessageWidget` context menu that will show for non-thread messages of other users and mark channel as unread from selected message onwards. + +- Added `showMarkUnreadMessage` to `StreamMessageWidget` that controls visibility of Mark as Unread option. + +🔄 Changed + + ## 7.1.0 🐞 Fixed diff --git a/packages/stream_chat_flutter/lib/src/localization/translations.dart b/packages/stream_chat_flutter/lib/src/localization/translations.dart index b893b1f08..2e8a97e92 100644 --- a/packages/stream_chat_flutter/lib/src/localization/translations.dart +++ b/packages/stream_chat_flutter/lib/src/localization/translations.dart @@ -208,6 +208,15 @@ abstract class Translations { /// based on [pinned] String togglePinUnpinText({required bool pinned}); + /// The text for marking message as unread functionality in [MessageWidget] + String get markAsUnreadLabel; + + /// The text for unread count indicator + String unreadCountIndicatorLabel({required int unreadCount}); + + /// The text of an error shown when marking a message as unread fails + String get markUnreadError; + /// The text for showing delete/retry-delete based on [isDeleteFailed] String toggleDeleteRetryDeleteMessageText({required bool isDeleteFailed}); @@ -575,6 +584,14 @@ class DefaultTranslations implements Translations { return 'Pin to Conversation'; } + @override + String get markAsUnreadLabel => 'Mark as Unread'; + + @override + String unreadCountIndicatorLabel({required int unreadCount}) { + return '$unreadCount unread'; + } + @override String toggleDeleteRetryDeleteMessageText({required bool isDeleteFailed}) { if (isDeleteFailed) return 'Retry Deleting Message'; @@ -807,4 +824,9 @@ Attachment limit exceeded: it's not possible to add more than $limit attachments @override String get allowFileAccessMessage => 'Allow access to files'; + + @override + String get markUnreadError => + 'Error marking message unread. Cannot mark unread messages older than the' + ' newest 100 channel messages.'; } diff --git a/packages/stream_chat_flutter/lib/src/message_actions_modal/mark_unread_message_button.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal/mark_unread_message_button.dart new file mode 100644 index 000000000..8f251f37a --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_actions_modal/mark_unread_message_button.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// {@template markUnreadMessageButton} +/// Allows a user to mark message (and all messages onwards) as unread. +/// +/// Used by [MessageActionsModal]. Should not be used by itself. +/// {@endtemplate} +class MarkUnreadMessageButton extends StatelessWidget { + /// {@macro markUnreadMessageButton} + const MarkUnreadMessageButton({ + super.key, + required this.onTap, + }); + + /// The callback to perform when the button is tapped. + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final streamChatThemeData = StreamChatTheme.of(context); + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16), + child: Row( + children: [ + StreamSvgIcon.messageUnread( + color: streamChatThemeData.primaryIconTheme.color, + size: 24, + ), + const SizedBox(width: 16), + Text( + context.translations.markAsUnreadLabel, + style: streamChatThemeData.textTheme.body, + ), + ], + ), + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_actions_modal/message_actions_modal.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal/message_actions_modal.dart index 2fb837152..f42a8ddb8 100644 --- a/packages/stream_chat_flutter/lib/src/message_actions_modal/message_actions_modal.dart +++ b/packages/stream_chat_flutter/lib/src/message_actions_modal/message_actions_modal.dart @@ -2,6 +2,7 @@ import 'dart:ui'; import 'package:flutter/material.dart' hide ButtonStyle; import 'package:stream_chat_flutter/src/message_actions_modal/mam_widgets.dart'; +import 'package:stream_chat_flutter/src/message_actions_modal/mark_unread_message_button.dart'; import 'package:stream_chat_flutter/src/message_widget/reactions/reactions_align.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -25,6 +26,7 @@ class MessageActionsModal extends StatefulWidget { this.showReplyMessage = true, this.showResendMessage = true, this.showThreadReplyMessage = true, + this.showMarkUnreadMessage = true, this.showFlagButton = true, this.showPinButton = true, this.editMessageInputBuilder, @@ -72,6 +74,9 @@ class MessageActionsModal extends StatefulWidget { /// Flag for showing resend action final bool showResendMessage; + /// Flag for showing mark unread action + final bool showMarkUnreadMessage; + /// Flag for showing reply action final bool showReplyMessage; @@ -178,6 +183,22 @@ class _MessageActionsModalState extends State { message: widget.message, onThreadReplyTap: widget.onThreadReplyTap, ), + if (widget.showMarkUnreadMessage) + MarkUnreadMessageButton(onTap: () async { + try { + await channel.markUnread(widget.message.id); + } catch (ex) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.translations.markUnreadError, + ), + ), + ); + } + + Navigator.of(context).pop(); + }), if (widget.showResendMessage) ResendMessageButton( message: widget.message, diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart index f3ed40843..9ebae75dd 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart @@ -1,5 +1,6 @@ // ignore_for_file: lines_longer_than_80_chars import 'dart:async'; +import 'dart:math'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; @@ -81,7 +82,11 @@ class StreamMessageListView extends StatefulWidget { const StreamMessageListView({ super.key, this.showScrollToBottom = true, + this.showUnreadCountOnScrollToBottom = false, this.scrollToBottomBuilder, + this.showUnreadIndicator = true, + this.unreadIndicatorBuilder, + this.markReadWhenAtTheBottom = false, this.messageBuilder, this.parentMessageBuilder, this.parentMessage, @@ -165,10 +170,14 @@ class StreamMessageListView extends StatefulWidget { /// built using [threadBuilder] final ThreadTapCallback? onThreadTap; - /// If true will show a scroll to bottom message when there are new - /// messages and the scroll offset is not zero + /// If true will show a scroll to bottom button when + /// the scroll offset is not zero final bool showScrollToBottom; + /// If true will show an indicator with number of unread messages + /// on scroll to bottom button + final bool showUnreadCountOnScrollToBottom; + /// Function used to build a custom scroll to bottom widget /// /// Provides the current unread messages count and a reference @@ -190,6 +199,37 @@ class StreamMessageListView extends StatefulWidget { Future Function(int) scrollToBottomDefaultTapAction, )? scrollToBottomBuilder; + /// If true will show an indicator with number of unread messages + /// that will scroll to latest read message when tapped and mark + /// channel as read when dismissed + final bool showUnreadIndicator; + + /// Function used to build a custom unread indicator widget + /// + /// Provides the current unread messages count and a reference + /// to the function that is executed on tap to scroll to latest + /// read message by default + /// + /// As an example: + /// ``` + /// MessageListView( + /// unreadIndicatorBuilder: (unreadCount, defaultTapAction, dismissAction) { + /// return InkWell( + /// onTap: () => defaultTapAction(unreadCount), + /// child: Text('Scroll To Unread'), + /// ); + /// }, + /// ), + /// ``` + final Widget Function( + int unreadCount, + Future Function(String) scrollToUnreadDefaultTapAction, + Future Function() dismissIndicatorDefaultTapAction, + )? unreadIndicatorBuilder; + + /// If true will mark channel as read when the user scrolls to the bottom of the list + final bool markReadWhenAtTheBottom; + /// Parent message in case of a thread final Message? parentMessage; @@ -337,6 +377,7 @@ class _StreamMessageListViewState extends State { widget.messageListController ?? _defaultController; StreamSubscription? _messageNewListener; + StreamSubscription? _userReadListener; Read? _userRead; Message? _oldestUnreadMessage; @@ -368,7 +409,10 @@ class _StreamMessageListViewState extends State { (it) => it.user.id == streamChannel?.channel.client.state.currentUser?.id, ); + _messageNewListener?.cancel(); + _userReadListener?.cancel(); + unreadCount = streamChannel?.channel.state?.unreadCount ?? 0; initialIndex = getInitialIndex( widget.initialScrollIndex, @@ -404,6 +448,14 @@ class _StreamMessageListViewState extends State { } }); + _userReadListener = + streamChannel!.channel.state?.readStream.listen((event) { + setState(() { + unreadCount = streamChannel!.channel.state?.unreadCount ?? 0; + _userRead = streamChannel!.channel.state?.currentUserRead; + }); + }); + if (_isThreadConversation) { streamChannel!.getReplies(widget.parentMessage!.id); } @@ -418,6 +470,7 @@ class _StreamMessageListViewState extends State { streamChannel!.reloadChannel(); } _messageNewListener?.cancel(); + _userReadListener?.cancel(); _itemPositionListener.itemPositions .removeListener(_handleItemPositionsChanged); super.dispose(); @@ -427,37 +480,39 @@ class _StreamMessageListViewState extends State { Widget build(BuildContext context) { return Portal( labels: const [kPortalMessageListViewLabel], - child: MessageListCore( - paginationLimit: widget.paginationLimit, - messageFilter: widget.messageFilter, - loadingBuilder: widget.loadingBuilder ?? - (context) => const Center( - child: CircularProgressIndicator.adaptive(), - ), - emptyBuilder: widget.emptyBuilder ?? - (context) => Center( - child: Text( - context.translations.emptyChatMessagesText, - style: _streamTheme.textTheme.footnote.copyWith( - color: _streamTheme.colorTheme.textHighEmphasis - .withOpacity(0.5), + child: ScaffoldMessenger( + child: MessageListCore( + paginationLimit: widget.paginationLimit, + messageFilter: widget.messageFilter, + loadingBuilder: widget.loadingBuilder ?? + (context) => const Center( + child: CircularProgressIndicator.adaptive(), + ), + emptyBuilder: widget.emptyBuilder ?? + (context) => Center( + child: Text( + context.translations.emptyChatMessagesText, + style: _streamTheme.textTheme.footnote.copyWith( + color: _streamTheme.colorTheme.textHighEmphasis + .withOpacity(0.5), + ), ), ), - ), - messageListBuilder: widget.messageListBuilder ?? - (context, list) => _buildListView(list), - messageListController: _messageListController, - parentMessage: widget.parentMessage, - errorBuilder: widget.errorBuilder ?? - (BuildContext context, Object error) => Center( - child: Text( - context.translations.genericErrorText, - style: _streamTheme.textTheme.footnote.copyWith( - color: _streamTheme.colorTheme.textHighEmphasis - .withOpacity(0.5), + messageListBuilder: widget.messageListBuilder ?? + (context, list) => _buildListView(list), + messageListController: _messageListController, + parentMessage: widget.parentMessage, + errorBuilder: widget.errorBuilder ?? + (BuildContext context, Object error) => Center( + child: Text( + context.translations.genericErrorText, + style: _streamTheme.textTheme.footnote.copyWith( + color: _streamTheme.colorTheme.textHighEmphasis + .withOpacity(0.5), + ), ), ), - ), + ), ), ); } @@ -794,6 +849,20 @@ class _StreamMessageListViewState extends State { ); }, ), + if (widget.showFloatingDateDivider) + Positioned( + top: 20, + left: 0, + right: 0, + child: FloatingDateDivider( + itemCount: itemCount, + reverse: widget.reverse, + itemPositionListener: _itemPositionListener.itemPositions, + messages: messages, + dateDividerBuilder: widget.dateDividerBuilder, + isThreadConversation: _isThreadConversation, + ), + ), if (widget.showScrollToBottom) BetterStreamBuilder( stream: streamChannel!.channel.state!.isUpToDateStream, @@ -809,20 +878,7 @@ class _StreamMessageListViewState extends State { }, ), ), - if (widget.showFloatingDateDivider) - Positioned( - top: 20, - left: 0, - right: 0, - child: FloatingDateDivider( - itemCount: itemCount, - reverse: widget.reverse, - itemPositionListener: _itemPositionListener.itemPositions, - messages: messages, - dateDividerBuilder: widget.dateDividerBuilder, - isThreadConversation: _isThreadConversation, - ), - ), + if (widget.showUnreadIndicator) _buildShowUnreadBottom(), ], ); @@ -858,13 +914,38 @@ class _StreamMessageListViewState extends State { _messageListController.paginateData!(direction: direction); Future scrollToBottomDefaultTapAction(int unreadCount) async { - this.unreadCount = unreadCount; - if (unreadCount > 0) { - streamChannel!.channel.markRead(); + // If the channel is not up to date, we need to reload it before scrolling + // to the end of the list. + if (!_upToDate) { + // Reset the pagination variables. + initialIndex = 0; + initialAlignment = 0; + _bottomPaginationActive = false; + + // Reload the channel to get the latest messages. + await streamChannel!.reloadChannel(); + + // Wait for the frame to be rendered with the updated channel state. + await WidgetsBinding.instance.endOfFrame; } + // Scroll to the end of the list. + if (_scrollController?.isAttached == true) { + _scrollController!.scrollTo( + index: max( + messages.toList().indexWhere((element) => + element.id == + streamChannel! + .channel.state?.currentUserRead?.lastReadMessageId), + 0), + duration: const Duration(seconds: 1), + curve: Curves.easeInOut, + ); + } + } + + Future scrollToUnreadDefaultTapAction(String lastReadMessageId) async { // If the channel is not up to date, we need to reload it before scrolling - // to the end of the list. if (!_upToDate) { // Reset the pagination variables. initialIndex = 0; @@ -881,13 +962,21 @@ class _StreamMessageListViewState extends State { // Scroll to the end of the list. if (_scrollController?.isAttached == true) { _scrollController!.scrollTo( - index: 0, + index: max( + messages + .toList() + .indexWhere((element) => element.id == lastReadMessageId), + 0), duration: const Duration(seconds: 1), curve: Curves.easeInOut, ); } } + Future dismissIndicatorDefaultTapAction() async { + await streamChannel!.channel.markRead(); + } + Widget _buildDateDivider(Message message) { final divider = widget.dateDividerBuilder != null ? widget.dateDividerBuilder!( @@ -945,6 +1034,7 @@ class _StreamMessageListViewState extends State { showCopyMessage: false, showDeleteMessage: false, showEditMessage: false, + showMarkUnreadMessage: false, message: message, reverse: isMyMessage, showUsername: !isMyMessage, @@ -1027,6 +1117,7 @@ class _StreamMessageListViewState extends State { streamChannel!.channel.state!.members.any((e) => e.userId == streamChannel!.channel.client.state.currentUser!.id); + return Positioned( bottom: 8, right: 8, @@ -1048,7 +1139,7 @@ class _StreamMessageListViewState extends State { color: _streamTheme.colorTheme.textHighEmphasis, ), ), - if (showUnreadCount) + if (showUnreadCount && widget.showUnreadCountOnScrollToBottom) Positioned( left: 0, right: 0, @@ -1083,6 +1174,74 @@ class _StreamMessageListViewState extends State { ); } + Widget _buildShowUnreadBottom() { + return StreamBuilder( + stream: streamChannel!.channel.state!.unreadCountStream, + builder: (_, snapshot) { + if (snapshot.hasError) { + return const Offstage(); + } else if (!snapshot.hasData) { + return const Offstage(); + } + final unreadCount = snapshot.data!; + + if (widget.unreadIndicatorBuilder != null) { + return widget.unreadIndicatorBuilder!( + unreadCount, + scrollToUnreadDefaultTapAction, + dismissIndicatorDefaultTapAction, + ); + } + + final showUnread = unreadCount > 0 && + streamChannel!.channel.state!.members.any((e) => + e.userId == + streamChannel!.channel.client.state.currentUser!.id); + + if (!showUnread) return const Offstage(); + + final lastReadMessageId = + streamChannel!.channel.state!.currentUserRead?.lastReadMessageId; + + return Positioned( + top: 8, + child: GestureDetector( + onTap: lastReadMessageId != null + ? () => scrollToUnreadDefaultTapAction(lastReadMessageId) + : null, + child: Container( + // width: 120, + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + height: 40, + decoration: BoxDecoration( + color: _streamTheme.colorTheme.textLowEmphasis, + borderRadius: BorderRadius.circular(18), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Text( + context.translations + .unreadCountIndicatorLabel(unreadCount: unreadCount), + style: TextStyle(color: _streamTheme.colorTheme.barsBg), + ), + const SizedBox(width: 16), + GestureDetector( + onTap: dismissIndicatorDefaultTapAction, + child: Icon( + Icons.close, + color: _streamTheme.colorTheme.barsBg, + ), + ), + ], + ), + ), + ), + ); + }, + ); + } + Widget buildMessage(Message message, List messages, int index) { if ((message.isSystem || message.isError) && message.text?.isNotEmpty == true) { @@ -1143,6 +1302,10 @@ class _StreamMessageListViewState extends State { !hasReplies && (hasTimeDiff || !isNextUserSame); + final showMarkUnread = streamChannel?.channel.config?.readEvents == true && + !isMyMessage && + (!isThreadMessage || _isThreadConversation); + final showUserAvatar = isMyMessage ? DisplayWidget.gone : (hasTimeDiff || !isNextUserSame) @@ -1174,6 +1337,7 @@ class _StreamMessageListViewState extends State { showTimestamp: showTimeStamp, showSendingIndicator: showSendingIndicator, showUserAvatar: showUserAvatar, + showMarkUnreadMessage: showMarkUnread, onQuotedMessageTap: (quotedMessageId) async { if (messages.map((e) => e.id).contains(quotedMessageId)) { final index = messages.indexWhere((m) => m.id == quotedMessageId); @@ -1327,7 +1491,8 @@ class _StreamMessageListViewState extends State { if (channel != null) { if (_upToDate && channel.config?.readEvents == true && - channel.state!.unreadCount > 0) { + channel.state!.unreadCount > 0 && + widget.markReadWhenAtTheBottom) { streamChannel!.channel.markRead(); } } diff --git a/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart b/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart index 3ecd20e59..34eb602dd 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart @@ -69,6 +69,7 @@ class StreamMessageWidget extends StatefulWidget { this.showEditMessage = true, this.showReplyMessage = true, this.showThreadReplyMessage = true, + this.showMarkUnreadMessage = true, this.showResendMessage = true, this.showCopyMessage = true, this.showFlagButton = true, @@ -272,6 +273,11 @@ class StreamMessageWidget extends StatefulWidget { /// {@endtemplate} final bool showThreadReplyMessage; + /// {@template showMarkUnreadMessage} + /// Show mark unread action + /// {@endtemplate} + final bool showMarkUnreadMessage; + /// {@template showEditMessage} /// Show edit action /// {@endtemplate} @@ -412,6 +418,7 @@ class StreamMessageWidget extends StatefulWidget { bool? showFlagButton, bool? showPinButton, bool? showPinHighlight, + bool? showMarkUnreadMessage, List? attachmentBuilders, bool? translateUserAvatar, OnQuotedMessageTap? onQuotedMessageTap, @@ -473,6 +480,8 @@ class StreamMessageWidget extends StatefulWidget { showFlagButton: showFlagButton ?? this.showFlagButton, showPinButton: showPinButton ?? this.showPinButton, showPinHighlight: showPinHighlight ?? this.showPinHighlight, + showMarkUnreadMessage: + showMarkUnreadMessage ?? this.showMarkUnreadMessage, attachmentBuilders: attachmentBuilders ?? this.attachmentBuilders, translateUserAvatar: translateUserAvatar ?? this.translateUserAvatar, onQuotedMessageTap: onQuotedMessageTap ?? this.onQuotedMessageTap, @@ -763,6 +772,26 @@ class _StreamMessageWidgetState extends State }, ), ], + if (widget.showMarkUnreadMessage) + StreamChatContextMenuItem( + leading: StreamSvgIcon.messageUnread(), + title: Text(context.translations.markAsUnreadLabel), + onClick: () async { + try { + await channel.markUnread(widget.message.id); + } catch (ex) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.translations.markUnreadError, + ), + ), + ); + } + + Navigator.of(context, rootNavigator: true).pop(); + }, + ), if (shouldShowThreadReplyAction) StreamChatContextMenuItem( leading: StreamSvgIcon.thread(), @@ -1000,6 +1029,7 @@ class _StreamMessageWidgetState extends State showThreadReplyMessage: shouldShowThreadReplyAction, showFlagButton: widget.showFlagButton, showPinButton: widget.showPinButton, + showMarkUnreadMessage: widget.showMarkUnreadMessage, customActions: widget.customActions, ), ); diff --git a/packages/stream_chat_flutter/lib/src/misc/stream_svg_icon.dart b/packages/stream_chat_flutter/lib/src/misc/stream_svg_icon.dart index a040bd427..75044ee4d 100644 --- a/packages/stream_chat_flutter/lib/src/misc/stream_svg_icon.dart +++ b/packages/stream_chat_flutter/lib/src/misc/stream_svg_icon.dart @@ -417,6 +417,19 @@ class StreamSvgIcon extends StatelessWidget { ); } + /// [StreamSvgIcon] type + factory StreamSvgIcon.messageUnread({ + double? size, + Color? color, + }) { + return StreamSvgIcon( + assetName: 'Icon_message_unread.svg', + color: color, + width: size, + height: size, + ); + } + /// [StreamSvgIcon] type factory StreamSvgIcon.thread({ double? size, diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_message_unread.svg b/packages/stream_chat_flutter/lib/svgs/Icon_message_unread.svg new file mode 100644 index 000000000..6c8a32c50 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_message_unread.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index eb277844c..8b95331f4 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -70,6 +70,7 @@ flutter: uses-material-design: true dev_dependencies: + faker_dart: ^0.2.1 flutter_test: sdk: flutter golden_toolkit: ^0.15.0 diff --git a/packages/stream_chat_flutter/test/src/localization/default_translations_test.dart b/packages/stream_chat_flutter/test/src/localization/default_translations_test.dart index eefa35932..18eb53cd8 100644 --- a/packages/stream_chat_flutter/test/src/localization/default_translations_test.dart +++ b/packages/stream_chat_flutter/test/src/localization/default_translations_test.dart @@ -169,5 +169,9 @@ void main() { expect(translations.galleryPaginationText, isNotNull); expect(translations.fileText, isNotNull); expect(translations.replyToMessageLabel, isNotNull); + expect(translations.unreadCountIndicatorLabel(unreadCount: 2), isNotNull); + expect(translations.unreadMessagesSeparatorText(), isNotNull); + expect(translations.markUnreadError, isNotNull); + expect(translations.markAsUnreadLabel, isNotNull); }); } diff --git a/packages/stream_chat_flutter/test/src/message_actions_modal/message_actions_modal_test.dart b/packages/stream_chat_flutter/test/src/message_actions_modal/message_actions_modal_test.dart index 05263cab8..f6739c3ae 100644 --- a/packages/stream_chat_flutter/test/src/message_actions_modal/message_actions_modal_test.dart +++ b/packages/stream_chat_flutter/test/src/message_actions_modal/message_actions_modal_test.dart @@ -61,6 +61,7 @@ void main() { expect(find.text('Edit Message'), findsOneWidget); expect(find.text('Delete Message'), findsOneWidget); expect(find.text('Copy Message'), findsOneWidget); + expect(find.text('Mark as Unread'), findsOneWidget); }, ); @@ -852,4 +853,55 @@ void main() { expect(find.text('Something went wrong'), findsOneWidget); }, ); + + testWidgets( + 'tapping on unread message should call client.unread', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); + + final themeData = ThemeData(); + final streamTheme = StreamChatThemeData.fromTheme(themeData); + + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => StreamChat( + client: client, + streamChatThemeData: streamTheme, + child: child, + ), + theme: themeData, + home: Scaffold( + body: StreamChannel( + showLoading: false, + channel: channel, + child: SizedBox( + child: MessageActionsModal( + messageWidget: const Text('test'), + message: Message( + id: 'testid', + text: 'test', + user: User( + id: 'user-id', + ), + ), + messageTheme: streamTheme.ownMessageTheme, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Mark as Unread')); + await tester.pumpAndSettle(); + + verify(() => channel.markUnread(any())).called(1); + }, + ); } diff --git a/packages/stream_chat_flutter/test/src/message_list_view/message_list_view_test.dart b/packages/stream_chat_flutter/test/src/message_list_view/message_list_view_test.dart index 7bb4a954b..1e1b1975d 100644 --- a/packages/stream_chat_flutter/test/src/message_list_view/message_list_view_test.dart +++ b/packages/stream_chat_flutter/test/src/message_list_view/message_list_view_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import '../../test_utils/data_generator.dart'; import '../mocks.dart'; void main() { @@ -128,4 +129,107 @@ void main() { findsOneWidget, ); }); + + testWidgets('renders a non empty message list view with unread messages', + (tester) async { + final user = OwnUser(id: 'testid'); + final message = Message( + id: 'message1', + text: 'Hello world!', + user: User( + id: 'testid', + name: 'Test User', + ), + ); + + when(() => channelClientState.read) + .thenReturn([Read(lastRead: DateTime.now(), user: user)]); + + when(() => channelClientState.messagesStream).thenAnswer( + (_) => Stream.value([message]), + ); + when(() => channelClientState.messages).thenReturn([message]); + + const nonEmptyWidgetKey = Key('non_empty_widget'); + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp( + home: DefaultAssetBundle( + bundle: rootBundle, + child: StreamChat( + client: client, + streamChatThemeData: StreamChatThemeData.light().copyWith( + messageListViewTheme: const StreamMessageListViewThemeData( + backgroundColor: Colors.grey, + backgroundImage: DecorationImage( + image: AssetImage('images/placeholder.png'), + fit: BoxFit.none, + ), + ), + ), + child: StreamChannel( + channel: channel, + child: const StreamMessageListView( + key: nonEmptyWidgetKey, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + }); + + expect(find.byType(StreamMessageListView), findsOneWidget); + expect(find.byKey(nonEmptyWidgetKey), findsOneWidget); + }); + + testWidgets('scrolls to bottom when arrow button is pressed', (tester) async { + final own = OwnUser(id: 'ownid'); + final other = User(id: 'otherid'); + final messages = generateConversation(20, users: [own, other]); + + when(() => channelClientState.messagesStream).thenAnswer( + (_) => Stream.value(messages), + ); + when(() => channelClientState.messages).thenReturn(messages); + + const nonEmptyWidgetKey = Key('non_empty_widget'); + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp( + home: DefaultAssetBundle( + bundle: rootBundle, + child: StreamChat( + client: client, + streamChatThemeData: StreamChatThemeData.light().copyWith( + messageListViewTheme: const StreamMessageListViewThemeData( + backgroundColor: Colors.grey, + backgroundImage: DecorationImage( + image: AssetImage('images/placeholder.png'), + fit: BoxFit.none, + ), + ), + ), + child: StreamChannel( + channel: channel, + child: const StreamMessageListView( + key: nonEmptyWidgetKey, + initialScrollIndex: 5, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + }); + + expect(find.byType(FloatingActionButton), findsOneWidget); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + + expect(find.byType(FloatingActionButton), findsNothing); + }); } diff --git a/packages/stream_chat_flutter/test/test_utils/data_generator.dart b/packages/stream_chat_flutter/test/test_utils/data_generator.dart new file mode 100644 index 000000000..5759136ba --- /dev/null +++ b/packages/stream_chat_flutter/test/test_utils/data_generator.dart @@ -0,0 +1,63 @@ +import 'package:faker_dart/faker_dart.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +List generateConversation( + int count, { + List? users, + int? noOfUsers, + int unreadCount = 0, +}) { + assert( + users == null || noOfUsers == null, + 'Only one of users or noOfUsers ' + 'should be provided'); + assert(count > 0, 'Count should be greater than 0'); + assert(count > unreadCount, 'Count should be greater than unreadCount'); + + users ??= generateUsers(noOfUsers!); + + final faker = Faker.instance; + + final messages = []; + for (var i = 0; i < count - unreadCount; i++) { + final user = users[i % users.length]; + messages.add( + Message( + id: faker.datatype.uuid(), + text: faker.lorem.sentence(), + user: user, + createdAt: DateTime.now().subtract(Duration(minutes: i)), + ), + ); + } + + for (var i = 0; i < unreadCount; i++) { + final user = users.where((element) => element is! OwnUser).first; + messages.add( + Message( + id: faker.datatype.uuid(), + text: faker.lorem.sentence(), + user: user, + createdAt: + DateTime.now().subtract(Duration(minutes: i + count - unreadCount)), + ), + ); + } + + return messages; +} + +List generateUsers(int count) { + final faker = Faker.instance; + final users = []; + for (var i = 0; i < count; i++) { + users.add( + User( + id: faker.datatype.uuid(), + name: faker.name.fullName(), + ), + ); + } + + return users; +} diff --git a/packages/stream_chat_localizations/example/lib/add_new_lang.dart b/packages/stream_chat_localizations/example/lib/add_new_lang.dart index 3837851d6..b0d47d8ed 100644 --- a/packages/stream_chat_localizations/example/lib/add_new_lang.dart +++ b/packages/stream_chat_localizations/example/lib/add_new_lang.dart @@ -468,6 +468,19 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { @override String get allowFileAccessMessage => 'Allow access to files'; + + @override + String get markAsUnreadLabel => 'Mark as unread'; + + @override + String unreadCountIndicatorLabel({required int unreadCount}) { + return '$unreadCount unread'; + } + + @override + String get markUnreadError => + 'Error marking message unread. Cannot mark unread messages older than' + ' the newest 100 channel messages.'; } void main() async { diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart index 76a06f5c8..02344f6c5 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart @@ -448,4 +448,18 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { @override String get allowFileAccessMessage => "Permet l'accés als fitxers"; + + @override + String get markAsUnreadLabel => 'Marcar com no llegit'; + + @override + String unreadCountIndicatorLabel({required int unreadCount}) { + return '$unreadCount sense llegir'; + } + + @override + String get markUnreadError => + 'Error en marcar el missatge com a no llegit. No es poden marcar' + ' missatges no llegits més antics que els 100 missatges més recents del' + ' canal.'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart index 5bbb8bbf8..6df9da7fe 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart @@ -442,4 +442,18 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { @override String get allowFileAccessMessage => 'Zugriff auf Dateien zulassen'; + + @override + String get markAsUnreadLabel => 'Als ungelesen markieren'; + + @override + String unreadCountIndicatorLabel({required int unreadCount}) { + return '$unreadCount ungelesen'; + } + + @override + String get markUnreadError => + 'Fehler beim Markieren der Nachricht als ungelesen. Kann keine älteren' + ' ungelesenen Nachrichten markieren als die neuesten 100' + ' Kanalnachrichten.'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart index 4431347ab..28d4a0b86 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart @@ -445,4 +445,17 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { @override String get allowFileAccessMessage => 'Allow access to files'; + + @override + String get markAsUnreadLabel => 'Mark as Unread'; + + @override + String unreadCountIndicatorLabel({required int unreadCount}) { + return '$unreadCount unread'; + } + + @override + String get markUnreadError => + 'Error marking message unread. Cannot mark unread messages older' + ' than the newest 100 channel messages.'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart index 71cdac1d1..5febd851a 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart @@ -450,4 +450,17 @@ No es posible añadir más de $limit archivos adjuntos @override String get allowFileAccessMessage => 'Permitir el acceso a los archivos'; + + @override + String get markAsUnreadLabel => 'Marcar como no leído'; + + @override + String unreadCountIndicatorLabel({required int unreadCount}) { + return '$unreadCount no leídos'; + } + + @override + String get markUnreadError => + 'Error al marcar el mensaje como no leído. No se pueden marcar mensajes' + ' no leídos más antiguos que los últimos 100 mensajes del canal.'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart index ac6efb6a3..c20be9b43 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart @@ -450,4 +450,18 @@ Limite de pièces jointes dépassée : il n'est pas possible d'ajouter plus de $ @override String get allowFileAccessMessage => "Autoriser l'accès aux fichiers"; + + @override + String get markAsUnreadLabel => 'Marquer comme non lu'; + + @override + String unreadCountIndicatorLabel({required int unreadCount}) { + return '$unreadCount non lus'; + } + + @override + String get markUnreadError => + 'Erreur lors de la marque du message comme non lu. Impossible de marquer' + ' des messages non lus plus anciens que les 100 derniers messages' + ' du canal.'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart index 20535ba4a..84d054ed4 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart @@ -443,4 +443,17 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { @override String get allowFileAccessMessage => 'फाइलों तक पहुंच की अनुमति दें'; + + @override + String get markAsUnreadLabel => 'अपठित चिह्नित करें'; + + @override + String unreadCountIndicatorLabel({required int unreadCount}) { + return '$unreadCount अपठित'; + } + + @override + String get markUnreadError => + 'संदेश को अपठित मार्क करने में त्रुटि। सबसे नए 100 चैनल संदेश से पहले के' + ' सभी अपठित संदेशों को अपठित मार्क नहीं किया जा सकता है।'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart index 2fe3499c5..55505f146 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart @@ -452,4 +452,18 @@ Attenzione: il limite massimo di $limit file è stato superato. @override String get allowFileAccessMessage => "Consenti l'accesso ai file"; + + @override + String get markAsUnreadLabel => 'Contrassegna come non letto'; + + @override + String unreadCountIndicatorLabel({required int unreadCount}) { + return '$unreadCount non letti'; + } + + @override + String get markUnreadError => + 'Errore durante la marcatura del messaggio come non letto. Impossibile' + ' marcare messaggi non letti più vecchi dei più recenti 100 messaggi' + ' del canale.'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart index e91962be1..d03842fb0 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart @@ -428,4 +428,16 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { @override String get allowFileAccessMessage => 'ファイルへのアクセスを許可する'; + + @override + String get markAsUnreadLabel => '未読としてマーク'; + + @override + String unreadCountIndicatorLabel({required int unreadCount}) { + return '$unreadCount 未読'; + } + + @override + String get markUnreadError => + 'メッセージを未読にする際にエラーが発生しました。最新の100件のチャンネルメッセージより古い未読メッセージはマークできません。'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart index 619af125e..044dd1a29 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart @@ -428,4 +428,17 @@ class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations { @override String get allowFileAccessMessage => '파일에 대한 액세스 허용'; + + @override + String get markAsUnreadLabel => '읽지 않음으로 표시'; + + @override + String unreadCountIndicatorLabel({required int unreadCount}) { + return '$unreadCount 읽지 않음'; + } + + @override + String get markUnreadError => + '메시지를 읽지 않음으로 표시하는 중 오류가 발생했습니다. 가장 최근 100개의 채널 메시지보다 오래된 읽지 않은 메시지는' + ' 표시할 수 없습니다.'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart index 28d5502f1..b9b8b2442 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart @@ -435,4 +435,17 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { @override String get allowFileAccessMessage => 'Gi tilgang til filer'; + + @override + String get markAsUnreadLabel => 'Merk som ulest'; + + @override + String unreadCountIndicatorLabel({required int unreadCount}) { + return '$unreadCount uleste'; + } + + @override + String get markUnreadError => + 'Feil ved merking av melding som ulest. Kan ikke merke meldinger som' + ' uleste som er eldre enn de 100 nyeste kanalmeldingene.'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart index eeaad7384..c796b7f9c 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart @@ -448,4 +448,17 @@ Não é possível adicionar mais de $limit arquivos de uma vez @override String get allowFileAccessMessage => 'Permitir acesso aos arquivos'; + + @override + String get markAsUnreadLabel => 'Marcar como não lida'; + + @override + String unreadCountIndicatorLabel({required int unreadCount}) { + return '$unreadCount não lidas'; + } + + @override + String get markUnreadError => + 'Erro ao marcar a mensagem como não lida. Não é possível marcar mensagens' + ' não lidas mais antigas do que as 100 mensagens mais recentes do canal.'; } diff --git a/packages/stream_chat_localizations/test/translations_test.dart b/packages/stream_chat_localizations/test/translations_test.dart index 60789f89e..590165c25 100644 --- a/packages/stream_chat_localizations/test/translations_test.dart +++ b/packages/stream_chat_localizations/test/translations_test.dart @@ -199,6 +199,11 @@ void main() { expect(localizations.unreadMessagesSeparatorText(), isNotNull); expect(localizations.enableFileAccessMessage, isNotNull); expect(localizations.allowFileAccessMessage, isNotNull); + expect( + localizations.unreadCountIndicatorLabel(unreadCount: 2), isNotNull); + expect(localizations.unreadMessagesSeparatorText(), isNotNull); + expect(localizations.markUnreadError, isNotNull); + expect(localizations.markAsUnreadLabel, isNotNull); }); }