diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index 994507ace9..f51a8a6328 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -2608,15 +2608,7 @@ class ChannelClientState { final draft = event.draft; if (draft == null) return; - if (draft.parentId case final parentId?) { - for (final message in messages) { - if (message.id == parentId) { - return updateMessage(message.copyWith(draft: draft)); - } - } - } - - updateChannelState(channelState.copyWith(draft: draft)); + return updateDraft(draft); }), ); } @@ -2627,19 +2619,7 @@ class ChannelClientState { final draft = event.draft; if (draft == null) return; - if (draft.parentId case final parentId?) { - for (final message in messages) { - if (message.id == parentId) { - return updateMessage( - message.copyWith(draft: null), - ); - } - } - } - - updateChannelState( - channelState.copyWith(draft: null), - ); + return deleteDraft(draft); }), ); } @@ -2781,6 +2761,48 @@ class ChannelClientState { ); } + /// Updates the [draft] in the channel state or the message if it exists. + void updateDraft(Draft draft) { + if (draft.parentId case final parentId?) { + for (final message in messages) { + if (message.id == parentId) { + return updateMessage(message.copyWith(draft: draft)); + } + } + } + + updateChannelState( + channelState.copyWith( + draft: draft, + ), + ); + } + + /// Deletes the [draft] from the state if it exists. + void deleteDraft(Draft draft) async { + // Delete the draft from the persistence client. + await _channel._client.chatPersistenceClient?.deleteDraftMessageByCid( + draft.channelCid, + parentId: draft.parentId, + ); + + if (draft.parentId case final parentId?) { + for (final message in messages) { + if (message.id == parentId) { + return updateMessage( + message.copyWith(draft: null), + ); + } + } + } + + updateChannelState( + channelState.copyWith( + draft: null, + ), + ); + } + /// Updates the [message] in the state if it exists. Adds it otherwise. void updateMessage(Message message) { // Determine if the message should be displayed in the channel view. diff --git a/packages/stream_chat/lib/src/db/chat_persistence_client.dart b/packages/stream_chat/lib/src/db/chat_persistence_client.dart index df3d8ce293..ade11ffab4 100644 --- a/packages/stream_chat/lib/src/db/chat_persistence_client.dart +++ b/packages/stream_chat/lib/src/db/chat_persistence_client.dart @@ -79,11 +79,9 @@ abstract class ChatPersistenceClient { PaginationParams? messagePagination, }); - /// Get stored [Draft] message by providing channel [cid]. - Future getDraftMessageByCid(String cid); - - /// Get stored [Draft] message by providing parent message [id]. - Future getDraftMessageByParentId(String parentId); + /// Get stored [Draft] message by providing channel [cid] and a optional + /// [parentId] for thread messages. + Future getDraftMessageByCid(String cid, {String? parentId}); /// Get [ChannelState] data by providing channel [cid] Future getChannelStateByCid( @@ -166,8 +164,9 @@ abstract class ChatPersistenceClient { /// Remove a channel by [channelId] Future deleteChannels(List cids); - /// Removes all the draft messages by draft [messageIds] - Future deleteDraftMessagesByIds(List messageIds); + /// Removes the draft message by matching [DraftMessages.channelCid] and + /// [DraftMessages.parentId]. + Future deleteDraftMessageByCid(String cid, {String? parentId}); /// Updates the message data of a particular channel [cid] with /// the new [messages] data @@ -278,6 +277,7 @@ abstract class ChatPersistenceClient { final channelWithPinnedMessages = ?>{}; final channelWithReads = ?>{}; final channelWithMembers = ?>{}; + final drafts = []; final users = []; final reactions = []; @@ -287,9 +287,6 @@ abstract class ChatPersistenceClient { final pollVotes = []; final pollVotesToDelete = []; - final drafts = []; - final draftsToDelete = []; - for (final state in channelStates) { final channel = state.channel; // Continue if channel is not available. @@ -339,8 +336,6 @@ abstract class ChatPersistenceClient { ...?pinnedMessages?.map((it) => it.draft), ].nonNulls); - draftsToDelete.addAll(drafts.map((it) => it.message.id)); - users.addAll([ channel.createdBy, ...?messages?.map((it) => it.user), @@ -361,7 +356,6 @@ abstract class ChatPersistenceClient { deleteReactionsByMessageId(reactionsToDelete), deletePinnedMessageReactionsByMessageId(pinnedReactionsToDelete), deletePollVotesByPollIds(pollVotesToDelete), - deleteDraftMessagesByIds(draftsToDelete), ]); // Updating first as does not depend on any other table. diff --git a/packages/stream_chat/test/src/db/chat_persistence_client_test.dart b/packages/stream_chat/test/src/db/chat_persistence_client_test.dart index da1f06d481..00adbe6686 100644 --- a/packages/stream_chat/test/src/db/chat_persistence_client_test.dart +++ b/packages/stream_chat/test/src/db/chat_persistence_client_test.dart @@ -58,7 +58,7 @@ class TestPersistenceClient extends ChatPersistenceClient { Future deletePollVotesByPollIds(List pollIds) => Future.value(); @override - Future deleteDraftMessagesByIds(List messageIds) => + Future deleteDraftMessageByCid(String cid, {String? parentId}) => Future.value(); @override @@ -105,17 +105,14 @@ class TestPersistenceClient extends ChatPersistenceClient { Future> getReadsByCid(String cid) async => []; @override - Future getDraftMessageByCid(String cid) async => Draft( + Future getDraftMessageByCid( + String cid, { + String? parentId, + }) async => + Draft( channelCid: cid, - createdAt: DateTime.now(), - message: DraftMessage(id: 'message-id', text: 'message-text'), - ); - - @override - Future getDraftMessageByParentId(String parentId) async => Draft( - channelCid: 'test:cid', - createdAt: DateTime.now(), parentId: parentId, + createdAt: DateTime.now(), message: DraftMessage(id: 'message-id', text: 'message-text'), ); @@ -218,9 +215,10 @@ void main() { persistenceClient.updatePolls([poll]); }); - test('deleteDraftMessagesByIds', () { - const messageIds = ['message-id']; - persistenceClient.deleteDraftMessagesByIds(messageIds); + test('deleteDraftMessageByCid', () { + const cid = 'test:cid'; + const parentId = 'parent-id'; + persistenceClient.deleteDraftMessageByCid(cid, parentId: parentId); }); test('updateDraftMessages', () async { diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index ac260389e1..774bd6e467 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -1,3 +1,10 @@ +## Upcoming + +✅ Added + +- Added support for Draft messages preview. +- Added a new `StreamDraftListView` for displaying draft messages. + ## 9.8.0 🐞 Fixed diff --git a/packages/stream_chat_flutter/lib/src/channel/stream_draft_message_preview_text.dart b/packages/stream_chat_flutter/lib/src/channel/stream_draft_message_preview_text.dart new file mode 100644 index 0000000000..2ec1fa48a4 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/channel/stream_draft_message_preview_text.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// A widget that renders a preview of the draft message text. +class StreamDraftMessagePreviewText extends StatelessWidget { + /// Creates a new instance of [StreamDraftMessagePreviewText]. + const StreamDraftMessagePreviewText({ + super.key, + required this.draftMessage, + this.textStyle, + }); + + /// The draft message to display. + final DraftMessage draftMessage; + + /// The style to use for the text. + final TextStyle? textStyle; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + final colorTheme = theme.colorTheme; + + final previewTextSpan = TextSpan( + text: '${context.translations.draftLabel}: ', + style: textStyle?.copyWith( + fontWeight: FontWeight.bold, + color: colorTheme.accentPrimary, + ), + children: [ + TextSpan(text: draftMessage.text, style: textStyle), + ], + ); + + return Text.rich( + maxLines: 1, + previewTextSpan, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.start, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/localization/translations.dart b/packages/stream_chat_flutter/lib/src/localization/translations.dart index 57d3eb0078..0b5ddad493 100644 --- a/packages/stream_chat_flutter/lib/src/localization/translations.dart +++ b/packages/stream_chat_flutter/lib/src/localization/translations.dart @@ -535,6 +535,9 @@ abstract class Translations { /// The text for poll when someone created String pollSomeoneCreatedText(String username); + + /// The label for draft message + String get draftLabel; } /// Default implementation of Translation strings for the stream chat widgets @@ -1198,4 +1201,7 @@ Attachment limit exceeded: it's not possible to add more than $limit attachments @override String pollSomeoneCreatedText(String username) => '$username created'; + + @override + String get draftLabel => 'Draft'; } diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_message_input.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_input.dart index fe443b513f..617627f8e7 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/stream_message_input.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_message_input.dart @@ -532,6 +532,8 @@ class StreamMessageInputState extends State ..addListener(_onChangedDebounced); } + StreamSubscription? _draftStreamSubscription; + @override void initState() { super.initState(); @@ -543,18 +545,42 @@ class StreamMessageInputState extends State _effectiveFocusNode.addListener(_focusNodeListener); WidgetsBinding.instance.endOfFrame.then((_) { - if (!mounted) return; + if (mounted) return _initializeState(); + }); + } - // Call the listener once to make sure the initial state is reflected - // correctly in the UI. - _onChangedDebounced.call(); + void _initializeState() { + // Call the listener once to make sure the initial state is reflected + // correctly in the UI. + _onChangedDebounced.call(); - // Resumes the cooldown if the channel has currently an active cooldown. - if (!_isEditing) { - final channel = StreamChannel.of(context).channel; - _effectiveController.startCooldown(channel.getRemainingCooldown()); - } - }); + final channel = StreamChannel.of(context).channel; + final config = StreamChatConfiguration.of(context); + + // Resumes the cooldown if the channel has currently an active cooldown. + if (!_isEditing) { + _effectiveController.startCooldown(channel.getRemainingCooldown()); + } + + // Starts listening to the draft stream for the current channel/thread. + if (config.draftMessagesEnabled) { + final draftStream = switch (_effectiveController.message.parentId) { + final parentId? => channel.state?.threadDraftStream(parentId), + _ => channel.state?.draftStream, + }; + + _draftStreamSubscription = draftStream?.distinct().listen(_onDraftUpdate); + } + } + + void _onDraftUpdate(Draft? draft) { + // If the draft is removed, reset the controller. + if (draft == null) return _effectiveController.reset(); + + // Otherwise, update the controller with the draft message. + if (draft.message case final draftMessage) { + _effectiveController.message = draftMessage.toMessage(); + } } @override @@ -1220,7 +1246,7 @@ class StreamMessageInputState extends State hintType = HintType.searchGif; } else if (_effectiveController.attachments.isNotEmpty) { hintType = HintType.addACommentOrSend; - } else if (_effectiveController.cooldownTimeOut > 0) { + } else if (_effectiveController.isSlowModeActive) { hintType = HintType.slowModeOn; } else { hintType = HintType.writeAMessage; @@ -1431,11 +1457,8 @@ class StreamMessageInputState extends State /// Sends the current message Future sendMessage() async { - if (_effectiveController.cooldownTimeOut > 0 || - (_effectiveController.text.trim().isEmpty && - _effectiveController.attachments.isEmpty)) { - return; - } + if (_effectiveController.isSlowModeActive) return; + if (!widget.validator(_effectiveController.message)) return; final streamChannel = StreamChannel.of(context); final channel = streamChannel.channel; @@ -1451,29 +1474,19 @@ class StreamMessageInputState extends State color: StreamChatTheme.of(context).colorTheme.accentError, size: 24, ), - title: 'Links are disabled', - details: 'Sending links is not allowed in this conversation.', + title: context.translations.linkDisabledError, + details: context.translations.linkDisabledDetails, okText: context.translations.okLabel, ); return; } - final containsCommand = message.command != null; - // If the message contains command we should append it to the text - // before sending it. - if (containsCommand) { - message = message.copyWith(text: '/${message.command} ${message.text}'); - } - - var shouldKeepFocus = widget.shouldKeepFocusAfterMessage; - shouldKeepFocus ??= !_commandEnabled; - + _maybeDeleteDraftMessage(message); widget.onQuotedMessageCleared?.call(); - _effectiveController.reset(); - if (widget.preMessageSending != null) { - message = await widget.preMessageSending!(message); + if (widget.preMessageSending case final onPreMessageSending?) { + message = await onPreMessageSending.call(message); } // If the channel is not up to date, we should reload it before sending @@ -1489,7 +1502,7 @@ class StreamMessageInputState extends State await _sendOrUpdateMessage(message: message); if (mounted) { - if (shouldKeepFocus) { + if (widget.shouldKeepFocusAfterMessage ?? !_commandEnabled) { FocusScope.of(context).requestFocus(_effectiveFocusNode); } else { FocusScope.of(context).unfocus(); @@ -1542,6 +1555,56 @@ class StreamMessageInputState extends State ); } + void _maybeUpdateOrDeleteDraftMessage() { + final message = _effectiveController.message; + final isMessageValid = widget.validator.call(message); + + // If the message is valid, we need to create or update it as a draft + // message for the channel or thread. + if (isMessageValid) return _maybeUpdateDraftMessage(message); + + // Otherwise, we need to delete the draft message. + return _maybeDeleteDraftMessage(message); + } + + void _maybeUpdateDraftMessage(Message message) { + final channel = StreamChannel.of(context).channel; + final draft = switch (message.parentId) { + final parentId? => channel.state?.threadDraft(parentId), + null => channel.state?.draft, + }; + + final draftMessage = message.toDraftMessage(); + + // If the draft message didn't change, we don't need to update it. + if (draft?.message == draftMessage) return; + + return channel.createDraft(draftMessage).ignore(); + } + + void _maybeDeleteDraftMessage(Message message) { + final channel = StreamChannel.of(context).channel; + final draft = switch (message.parentId) { + final parentId? => channel.state?.threadDraft(parentId), + null => channel.state?.draft, + }; + + // If there is no draft message, we don't need to delete it. + if (draft == null) return; + + return channel.deleteDraft(parentId: message.parentId).ignore(); + } + + @override + void deactivate() { + final config = StreamChatConfiguration.of(context); + if (config.draftMessagesEnabled) { + _maybeUpdateOrDeleteDraftMessage(); + } + + super.deactivate(); + } + @override void dispose() { _effectiveController.removeListener(_onChangedDebounced); @@ -1550,6 +1613,7 @@ class StreamMessageInputState extends State _focusNode?.dispose(); _onChangedDebounced.cancel(); _audioRecorderController.dispose(); + _draftStreamSubscription?.cancel(); super.dispose(); } } diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_tile.dart b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_tile.dart index f0025608ad..5221595748 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_tile.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_tile.dart @@ -1,5 +1,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:stream_chat_flutter/src/channel/stream_draft_message_preview_text.dart'; import 'package:stream_chat_flutter/src/message_widget/sending_indicator_builder.dart'; import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; import 'package:stream_chat_flutter/src/misc/timestamp.dart'; @@ -380,10 +382,28 @@ class _ChannelLastMessageTextState extends State { @override Widget build(BuildContext context) { - return BetterStreamBuilder>( - stream: widget.channel.state!.messagesStream, - initialData: widget.channel.state!.messages, - builder: (context, messages) { + final channelState = widget.channel.state; + if (channelState == null) return const Empty(); + + return BetterStreamBuilder<(Draft?, List)>( + stream: CombineLatestStream.combine2( + channelState.draftStream, + channelState.messagesStream, + (draft, messages) => (draft, messages), + ), + initialData: (channelState.draft, channelState.messages), + builder: (context, data) { + final (draft, messages) = data; + + // Prioritize the draft message if it exists. + if (draft?.message case final draftMessage?) { + return StreamDraftMessagePreviewText( + draftMessage: draftMessage, + textStyle: widget.textStyle, + ); + } + + // Otherwise, show the channel last message if it exists. final message = messages.lastWhereOrNull(widget.lastMessagePredicate); final latestLastMessage = [message, _currentLastMessage].latest; @@ -399,7 +419,7 @@ class _ChannelLastMessageTextState extends State { return StreamMessagePreviewText( message: latestLastMessage, textStyle: widget.textStyle, - channel: widget.channel.state?.channelState.channel, + channel: channelState.channelState.channel, ); }, ); diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/draft_scroll_view/stream_draft_list_tile.dart b/packages/stream_chat_flutter/lib/src/scroll_view/draft_scroll_view/stream_draft_list_tile.dart new file mode 100644 index 0000000000..adaf12e85e --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/scroll_view/draft_scroll_view/stream_draft_list_tile.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/channel/stream_draft_message_preview_text.dart'; +import 'package:stream_chat_flutter/src/misc/timestamp.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// {@template streamDraftListTile} +/// A widget that displays a draft in a list. +/// +/// This widget is used in the [StreamDraftListView] to display a draft. +/// +/// The widget displays the channel name, the draft message preview, and the +/// timestamp. +/// {@endtemplate} +class StreamDraftListTile extends StatelessWidget { + /// {@macro streamDraftListTile} + const StreamDraftListTile({ + super.key, + required this.draft, + this.currentUser, + this.onTap, + this.onLongPress, + }); + + /// The draft to display. + final Draft draft; + + /// The current user. + final User? currentUser; + + /// Called when the user taps this list tile. + final GestureTapCallback? onTap; + + /// Called when the user long-presses on this list tile. + final GestureLongPressCallback? onLongPress; + + @override + Widget build(BuildContext context) { + final theme = StreamDraftListTileTheme.of(context); + + return Material( + color: theme.backgroundColor, + child: InkWell( + onTap: onTap, + onLongPress: onLongPress, + child: Container( + padding: theme.padding, + child: Column( + spacing: 6, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (draft.channel case final channel?) + DraftTitle( + channelName: channel.formatName(currentUser: currentUser), + ), + DraftMessageContent( + draft: draft, + currentUser: currentUser, + ), + ], + ), + ), + ), + ); + } +} + +/// {@template draftTitle} +/// A widget that displays the channel name. +/// {@endtemplate} +class DraftTitle extends StatelessWidget { + /// {@macro draftTitle} + const DraftTitle({ + super.key, + this.channelName, + }); + + /// The channel name to display. + final String? channelName; + + @override + Widget build(BuildContext context) { + final theme = StreamDraftListTileTheme.of(context); + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + size: 16, + Icons.edit_note_rounded, + color: theme.draftChannelNameStyle?.color, + ), + const SizedBox(width: 4), + Flexible( + child: Text( + channelName ?? context.translations.noTitleText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.draftChannelNameStyle, + ), + ), + ], + ); + } +} + +/// {@template draftMessageContent} +/// A widget that displays the draft message content. +/// {@endtemplate} +class DraftMessageContent extends StatelessWidget { + /// {@macro draftMessageContent} + const DraftMessageContent({ + super.key, + required this.draft, + this.currentUser, + }); + + /// The draft to display. + final Draft draft; + + /// The current user. + final User? currentUser; + + @override + Widget build(BuildContext context) { + final theme = StreamDraftListTileTheme.of(context); + + return Row( + children: [ + Expanded( + child: StreamDraftMessagePreviewText( + draftMessage: draft.message, + textStyle: theme.draftMessageStyle, + ), + ), + StreamTimestamp( + date: draft.createdAt.toLocal(), + style: theme.draftTimestampStyle, + ), + ], + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/draft_scroll_view/stream_draft_list_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/draft_scroll_view/stream_draft_list_view.dart new file mode 100644 index 0000000000..1c59029017 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/scroll_view/draft_scroll_view/stream_draft_list_view.dart @@ -0,0 +1,377 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_widget.dart'; +import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_error.dart'; +import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_indicator.dart'; +import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_loading_widget.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// Default separator builder for [StreamDraftListView]. +Widget defaultDraftListViewSeparatorBuilder( + BuildContext context, + List drafts, + int index, +) => + const StreamDraftListSeparator(); + +/// Signature for the item builder that creates the children of the +/// [StreamDraftListView]. +typedef StreamDraftListViewIndexedWidgetBuilder + = StreamScrollViewIndexedWidgetBuilder; + +/// {@template streamDraftListView} +/// A [ListView] that shows a list of [Draft]'s. It uses a +/// [StreamDraftListController] to load the drafts in paginated form. +/// +/// Example: +/// +/// ```dart +/// StreamDraftListView( +/// controller: controller, +/// onDraftTap: (draft) { +/// // Handle draft tap event +/// }, +/// onDraftLongPress: (draft) { +/// // Handle draft long press event +/// }, +/// ) +/// ``` +/// +/// See also: +/// * [StreamDraftListTile] +/// * [StreamDraftListController] +/// {@endtemplate} +class StreamDraftListView extends StatelessWidget { + /// {@macro streamDraftListView} + const StreamDraftListView({ + super.key, + required this.controller, + this.itemBuilder, + this.separatorBuilder = defaultDraftListViewSeparatorBuilder, + this.emptyBuilder, + this.loadingBuilder, + this.errorBuilder, + this.onDraftTap, + this.onDraftLongPress, + this.loadMoreTriggerIndex = 3, + this.scrollDirection = Axis.vertical, + this.reverse = false, + this.scrollController, + this.primary, + this.physics, + this.shrinkWrap = false, + this.padding, + this.addAutomaticKeepAlives = true, + this.addRepaintBoundaries = true, + this.addSemanticIndexes = true, + this.cacheExtent, + this.dragStartBehavior = DragStartBehavior.start, + this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, + this.restorationId, + this.clipBehavior = Clip.hardEdge, + }); + + /// The [StreamDraftListController] used to control the drafts in the list. + final StreamDraftListController controller; + + /// A builder that is called to build items in the [ListView]. + final StreamDraftListViewIndexedWidgetBuilder? itemBuilder; + + /// A builder that is called to build the list separator. + final PagedValueScrollViewIndexedWidgetBuilder separatorBuilder; + + /// A builder that is called to build the empty state of the list. + final WidgetBuilder? emptyBuilder; + + /// A builder that is called to build the loading state of the list. + final WidgetBuilder? loadingBuilder; + + /// A builder that is called to build the error state of the list. + final Widget Function(BuildContext, StreamChatError)? errorBuilder; + + /// Called when the user taps this list tile. + final void Function(Draft)? onDraftTap; + + /// Called when the user long-presses on this list tile. + final void Function(Draft)? onDraftLongPress; + + /// The index to take into account when triggering [controller.loadMore]. + final int loadMoreTriggerIndex; + + /// {@template flutter.widgets.scroll_view.scrollDirection} + /// The axis along which the scroll view scrolls. + /// + /// Defaults to [Axis.vertical]. + /// {@endtemplate} + final Axis scrollDirection; + + /// The amount of space by which to inset the children. + final EdgeInsetsGeometry? padding; + + /// Whether to wrap each child in an [AutomaticKeepAlive]. + /// + /// Typically, children in lazy list are wrapped in [AutomaticKeepAlive] + /// widgets so that children can use [KeepAliveNotification]s to preserve + /// their state when they would otherwise be garbage collected off-screen. + /// + /// This feature (and [addRepaintBoundaries]) must be disabled if the children + /// are going to manually maintain their [KeepAlive] state. It may also be + /// more efficient to disable this feature if it is known ahead of time that + /// none of the children will ever try to keep themselves alive. + /// + /// Defaults to true. + final bool addAutomaticKeepAlives; + + /// Whether to wrap each child in a [RepaintBoundary]. + /// + /// Typically, children in a scrolling container are wrapped in repaint + /// boundaries so that they do not need to be repainted as the list scrolls. + /// If the children are easy to repaint (e.g., solid color blocks or a short + /// snippet of text), it might be more efficient to not add a repaint boundary + /// and simply repaint the children during scrolling. + /// + /// Defaults to true. + final bool addRepaintBoundaries; + + /// Whether to wrap each child in an [IndexedSemantics]. + /// + /// Typically, children in a scrolling container must be annotated with a + /// semantic index in order to generate the correct accessibility + /// announcements. This should only be set to false if the indexes have + /// already been provided by an [IndexedSemantics] widget. + /// + /// Defaults to true. + /// + /// See also: + /// + /// * [IndexedSemantics], for an explanation of how to manually + /// provide semantic indexes. + final bool addSemanticIndexes; + + /// {@template flutter.widgets.scroll_view.reverse} + /// Whether the scroll view scrolls in the reading direction. + /// + /// For example, if [scrollDirection] is [Axis.vertical], then the scroll view + /// scrolls from top to bottom when [reverse] is false and from bottom to top + /// when [reverse] is true. + /// + /// Defaults to false. + /// {@endtemplate} + final bool reverse; + + /// {@template flutter.widgets.scroll_view.controller} + /// An object that can be used to control the position to which this scroll + /// view is scrolled. + /// + /// Must be null if [primary] is true. + /// + /// A [ScrollController] serves several purposes. It can be used to control + /// the initial scroll position (see [ScrollController.initialScrollOffset]). + /// It can be used to control whether the scroll view should automatically + /// save and restore its scroll position in the [PageStorage] (see + /// [ScrollController.keepScrollOffset]). It can be used to read the current + /// scroll position (see [ScrollController.offset]), or change it (see + /// [ScrollController.animateTo]). + /// {@endtemplate} + final ScrollController? scrollController; + + /// {@template flutter.widgets.scroll_view.primary} + /// Whether this is the primary scroll view associated with the parent + /// [PrimaryScrollController]. + /// + /// When this is true, the scroll view is scrollable even if it does not have + /// sufficient content to actually scroll. Otherwise, by default the user can + /// only scroll the view if it has sufficient content. See [physics]. + /// + /// Also when true, the scroll view is used for default [ScrollAction]s. If a + /// ScrollAction is not handled by an otherwise focused part of the + /// application, the ScrollAction will be evaluated using this scroll view, + /// for example, when executing [Shortcuts] key events like page up and down. + /// + /// On iOS, this also identifies the scroll view that will scroll to top in + /// response to a tap in the status bar. + /// {@endtemplate} + /// + /// Defaults to true when [scrollController] is null. + final bool? primary; + + /// {@template flutter.widgets.scroll_view.shrinkWrap} + /// Whether the extent of the scroll view in the [scrollDirection] should be + /// determined by the contents being viewed. + /// + /// If the scroll view does not shrink wrap, then the scroll view will expand + /// to the maximum allowed size in the [scrollDirection]. If the scroll view + /// has unbounded constraints in the [scrollDirection], then [shrinkWrap] must + /// be true. + /// + /// Shrink wrapping the content of the scroll view is significantly more + /// expensive than expanding to the maximum allowed size because the content + /// can expand and contract during scrolling, which means the size of the + /// scroll view needs to be recomputed whenever the scroll position changes. + /// + /// Defaults to false. + /// {@endtemplate} + final bool shrinkWrap; + + /// {@template flutter.widgets.scroll_view.physics} + /// How the scroll view should respond to user input. + /// + /// For example, determines how the scroll view continues to animate after the + /// user stops dragging the scroll view. + /// + /// Defaults to matching platform conventions. Furthermore, if [primary] is + /// false, then the user cannot scroll if there is insufficient content to + /// scroll, while if [primary] is true, they can always attempt to scroll. + /// + /// To force the scroll view to always be scrollable even if there is + /// insufficient content, as if [primary] was true but without necessarily + /// setting it to true, provide an [AlwaysScrollableScrollPhysics] physics + /// object, as in: + /// + /// ```dart + /// physics: const AlwaysScrollableScrollPhysics(), + /// ``` + /// + /// To force the scroll view to use the default platform conventions and not + /// be scrollable if there is insufficient content, regardless of the value of + /// [primary], provide an explicit [ScrollPhysics] object, as in: + /// + /// ```dart + /// physics: const ScrollPhysics(), + /// ``` + /// + /// The physics can be changed dynamically (by providing a new object in a + /// subsequent build), but new physics will only take effect if the _class_ of + /// the provided object changes. Merely constructing a new instance with a + /// different configuration is insufficient to cause the physics to be + /// reapplied. (This is because the final object used is generated + /// dynamically, which can be relatively expensive, and it would be + /// inefficient to speculatively create this object each frame to see if the + /// physics should be updated.) + /// {@endtemplate} + /// + /// If an explicit [ScrollBehavior] is provided to [scrollBehavior], the + /// [ScrollPhysics] provided by that behavior will take precedence after + /// [physics]. + final ScrollPhysics? physics; + + /// {@macro flutter.rendering.RenderViewportBase.cacheExtent} + final double? cacheExtent; + + /// {@macro flutter.widgets.scrollable.dragStartBehavior} + final DragStartBehavior dragStartBehavior; + + /// {@template flutter.widgets.scroll_view.keyboardDismissBehavior} + /// [ScrollViewKeyboardDismissBehavior] the defines how this [ScrollView] will + /// dismiss the keyboard automatically. + /// {@endtemplate} + final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior; + + /// {@macro flutter.widgets.scrollable.restorationId} + final String? restorationId; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.hardEdge]. + final Clip clipBehavior; + + @override + Widget build(BuildContext context) { + return PagedValueListView( + scrollDirection: scrollDirection, + padding: padding, + physics: physics, + reverse: reverse, + controller: controller, + scrollController: scrollController, + primary: primary, + shrinkWrap: shrinkWrap, + addAutomaticKeepAlives: addAutomaticKeepAlives, + addRepaintBoundaries: addRepaintBoundaries, + addSemanticIndexes: addSemanticIndexes, + keyboardDismissBehavior: keyboardDismissBehavior, + restorationId: restorationId, + dragStartBehavior: dragStartBehavior, + cacheExtent: cacheExtent, + clipBehavior: clipBehavior, + loadMoreTriggerIndex: loadMoreTriggerIndex, + separatorBuilder: separatorBuilder, + itemBuilder: (context, drafts, index) { + final draft = drafts[index]; + final currentUser = StreamChat.of(context).currentUser; + final onTap = onDraftTap; + final onLongPress = onDraftLongPress; + + final tile = StreamDraftListTile( + draft: draft, + currentUser: currentUser, + onTap: onTap == null ? null : () => onTap(draft), + onLongPress: onLongPress == null ? null : () => onLongPress(draft), + ); + + return itemBuilder?.call(context, drafts, index, tile) ?? tile; + }, + emptyBuilder: (context) { + final chatThemeData = StreamChatTheme.of(context); + return emptyBuilder?.call(context) ?? + Center( + child: Padding( + padding: const EdgeInsets.all(8), + child: StreamScrollViewEmptyWidget( + emptyIcon: StreamSvgIcon( + size: 148, + icon: StreamSvgIcons.edit, + color: chatThemeData.colorTheme.disabled, + ), + emptyTitle: Text( + context.translations.emptyMessagesText, + style: chatThemeData.textTheme.headline, + ), + ), + ), + ); + }, + loadMoreErrorBuilder: (context, error) => + StreamScrollViewLoadMoreError.list( + onTap: controller.retry, + error: Text(context.translations.loadingMessagesError), + ), + loadMoreIndicatorBuilder: (context) => const Center( + child: Padding( + padding: EdgeInsets.all(16), + child: StreamScrollViewLoadMoreIndicator(), + ), + ), + loadingBuilder: (context) => + loadingBuilder?.call(context) ?? + const Center( + child: StreamScrollViewLoadingWidget(), + ), + errorBuilder: (context, error) => + errorBuilder?.call(context, error) ?? + Center( + child: StreamScrollViewErrorWidget( + errorTitle: Text(context.translations.loadingMessagesError), + onRetryPressed: controller.refresh, + ), + ), + ); + } +} + +/// A widget that is used to display a separator between +/// [StreamDraftListTile] items. +class StreamDraftListSeparator extends StatelessWidget { + /// Creates a new instance of [StreamDraftListSeparator]. + const StreamDraftListSeparator({super.key}); + + @override + Widget build(BuildContext context) { + final effect = StreamChatTheme.of(context).colorTheme.borderBottom; + return Container( + height: 1, + // ignore: deprecated_member_use + color: effect.color!.withOpacity(effect.alpha ?? 1.0), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart index 4ad89cfbb4..44d5af7f12 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart @@ -1,5 +1,6 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/channel/stream_draft_message_preview_text.dart'; import 'package:stream_chat_flutter/src/misc/timestamp.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -42,41 +43,44 @@ class StreamThreadListTile extends StatelessWidget { ?.firstWhereOrNull((read) => read.user.id == currentUser?.id) ?.unreadMessages; - return InkWell( - onTap: onTap, - onLongPress: onLongPress, - child: Container( - padding: theme.padding, - color: theme.backgroundColor, - child: Column( - spacing: 6, - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (thread.channel case final channel?) - ThreadTitle( - channelName: channel.formatName(currentUser: currentUser), - ), - Row( - children: [ - if (thread.parentMessage case final parentMessage?) - Expanded( - child: ThreadReplyToContent( - language: language, - prefix: context.translations.repliedToLabel, - parentMessage: parentMessage, + return Material( + color: theme.backgroundColor, + child: InkWell( + onTap: onTap, + onLongPress: onLongPress, + child: Container( + padding: theme.padding, + child: Column( + spacing: 6, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (thread.channel case final channel?) + ThreadTitle( + channelName: channel.formatName(currentUser: currentUser), + ), + Row( + children: [ + if (thread.parentMessage case final parentMessage?) + Expanded( + child: ThreadReplyToContent( + language: language, + prefix: context.translations.repliedToLabel, + parentMessage: parentMessage, + ), ), - ), - if (unreadMessageCount case final count? when count > 0) - ThreadUnreadCount(unreadCount: count), - ], - ), - if (thread.latestReplies.lastOrNull case final latestReply?) - ThreadLatestReply( - language: language, - latestReply: latestReply, + if (unreadMessageCount case final count? when count > 0) + ThreadUnreadCount(unreadCount: count), + ], ), - ], + if (thread.latestReplies.lastOrNull case final latestReply?) + ThreadLatestReply( + language: language, + latestReply: latestReply, + draftMessage: thread.draft?.message, + ), + ], + ), ), ), ); @@ -203,12 +207,16 @@ class ThreadLatestReply extends StatelessWidget { const ThreadLatestReply({ super.key, this.language, + this.draftMessage, required this.latestReply, }); /// The language of the message. final String? language; + /// The draft message in the thread. + final DraftMessage? draftMessage; + /// The latest reply in the thread. final Message latestReply; @@ -232,10 +240,21 @@ class ThreadLatestReply extends StatelessWidget { Row( children: [ Expanded( - child: StreamMessagePreviewText( - language: language, - message: latestReply, - textStyle: theme.threadLatestReplyMessageStyle, + child: Builder( + builder: (context) { + if (draftMessage case final draftMessage?) { + return StreamDraftMessagePreviewText( + draftMessage: draftMessage, + textStyle: theme.threadLatestReplyMessageStyle, + ); + } + + return StreamMessagePreviewText( + language: language, + message: latestReply, + textStyle: theme.threadLatestReplyMessageStyle, + ); + }, ), ), StreamTimestamp( diff --git a/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart b/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart index 5363cb4e1f..e13d607fa2 100644 --- a/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart +++ b/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart @@ -114,6 +114,7 @@ class StreamChatConfigurationData { Widget Function(BuildContext, User)? placeholderUserImage, List? reactionIcons, bool? enforceUniqueReactions, + bool draftMessagesEnabled = false, }) { return StreamChatConfigurationData._( loadingIndicator: loadingIndicator, @@ -121,6 +122,7 @@ class StreamChatConfigurationData { placeholderUserImage: placeholderUserImage, reactionIcons: reactionIcons ?? _defaultReactionIcons, enforceUniqueReactions: enforceUniqueReactions ?? true, + draftMessagesEnabled: draftMessagesEnabled, ); } @@ -130,6 +132,7 @@ class StreamChatConfigurationData { required this.placeholderUserImage, required this.reactionIcons, required this.enforceUniqueReactions, + required this.draftMessagesEnabled, }); /// Copies the configuration options from one [StreamChatConfigurationData] to @@ -140,6 +143,7 @@ class StreamChatConfigurationData { Widget Function(BuildContext, User)? placeholderUserImage, List? reactionIcons, bool? enforceUniqueReactions, + bool? draftMessagesEnabled, }) { return StreamChatConfigurationData( reactionIcons: reactionIcons ?? this.reactionIcons, @@ -148,9 +152,15 @@ class StreamChatConfigurationData { loadingIndicator: loadingIndicator ?? this.loadingIndicator, enforceUniqueReactions: enforceUniqueReactions ?? this.enforceUniqueReactions, + draftMessagesEnabled: draftMessagesEnabled ?? this.draftMessagesEnabled, ); } + /// If True, the user will be able to send draft messages. + /// + /// Defaults to False. + final bool draftMessagesEnabled; + /// The widget that will be shown to indicate loading. final Widget loadingIndicator; diff --git a/packages/stream_chat_flutter/lib/src/theme/draft_list_tile_theme.dart b/packages/stream_chat_flutter/lib/src/theme/draft_list_tile_theme.dart new file mode 100644 index 0000000000..ca8aada5d2 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/theme/draft_list_tile_theme.dart @@ -0,0 +1,153 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; + +/// {@template streamDraftListTileTheme} +/// Overrides the default style of [StreamDraftListTile] descendants. +/// +/// See also: +/// +/// * [StreamDraftListTileThemeData], which is used to configure this +/// theme. +/// {@endtemplate} +class StreamDraftListTileTheme extends InheritedTheme { + /// Creates a [StreamDraftListTileTheme]. + /// + /// The [data] parameter must not be null. + const StreamDraftListTileTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The configuration of this theme. + final StreamDraftListTileThemeData data; + + /// The closest instance of this class that encloses the given context. + /// + /// If there is no enclosing [StreamDraftListTileTheme] widget, then + /// [StreamChatThemeData.draftListTileTheme] is used. + static StreamDraftListTileThemeData of(BuildContext context) { + final draftListTileTheme = + context.dependOnInheritedWidgetOfExactType(); + return draftListTileTheme?.data ?? + StreamChatTheme.of(context).draftListTileTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) => + StreamDraftListTileTheme(data: data, child: child); + + @override + bool updateShouldNotify(StreamDraftListTileTheme oldWidget) => + data != oldWidget.data; +} + +/// {@template streamDraftListTileThemeData} +/// A style that overrides the default appearance of +/// [StreamDraftListTile] widgets when used with +/// [StreamDraftListTileTheme] or with the overall [StreamChatTheme]'s +/// [StreamChatThemeData.draftListTileTheme]. +/// {@endtemplate} +class StreamDraftListTileThemeData with Diagnosticable { + /// {@macro streamDraftListTileThemeData} + const StreamDraftListTileThemeData({ + this.padding, + this.backgroundColor, + this.draftChannelNameStyle, + this.draftMessageStyle, + this.draftTimestampStyle, + }); + + /// The padding around the [StreamDraftListTile] widget. + final EdgeInsetsGeometry? padding; + + /// The background color of the [StreamDraftListTile] widget. + final Color? backgroundColor; + + /// The style of the channel name in the [StreamDraftListTile] widget. + final TextStyle? draftChannelNameStyle; + + /// The style of the draft message in the [StreamDraftListTile] widget. + final TextStyle? draftMessageStyle; + + /// The style of the draft timestamp in the [StreamDraftListTile] widget. + final TextStyle? draftTimestampStyle; + + /// A copy of [StreamDraftListTileThemeData] with specified attributes + /// overridden. + StreamDraftListTileThemeData copyWith({ + EdgeInsetsGeometry? padding, + Color? backgroundColor, + TextStyle? draftChannelNameStyle, + TextStyle? draftMessageStyle, + TextStyle? draftTimestampStyle, + Color? draftIconColor, + }) => + StreamDraftListTileThemeData( + padding: padding ?? this.padding, + backgroundColor: backgroundColor ?? this.backgroundColor, + draftChannelNameStyle: + draftChannelNameStyle ?? this.draftChannelNameStyle, + draftMessageStyle: draftMessageStyle ?? this.draftMessageStyle, + draftTimestampStyle: draftTimestampStyle ?? this.draftTimestampStyle, + ); + + /// Merges this [StreamDraftListTileThemeData] with the [other]. + StreamDraftListTileThemeData merge( + StreamDraftListTileThemeData? other, + ) { + if (other == null) return this; + return copyWith( + padding: other.padding, + backgroundColor: other.backgroundColor, + draftChannelNameStyle: other.draftChannelNameStyle, + draftMessageStyle: other.draftMessageStyle, + draftTimestampStyle: other.draftTimestampStyle, + ); + } + + /// Linearly interpolate between two [StreamDraftListTileThemeData]. + StreamDraftListTileThemeData lerp( + StreamDraftListTileThemeData? a, + StreamDraftListTileThemeData? b, + double t, + ) => + StreamDraftListTileThemeData( + padding: EdgeInsetsGeometry.lerp(a?.padding, b?.padding, t), + backgroundColor: Color.lerp(a?.backgroundColor, b?.backgroundColor, t), + draftChannelNameStyle: TextStyle.lerp( + a?.draftChannelNameStyle, + b?.draftChannelNameStyle, + t, + ), + draftMessageStyle: TextStyle.lerp( + a?.draftMessageStyle, + b?.draftMessageStyle, + t, + ), + draftTimestampStyle: TextStyle.lerp( + a?.draftTimestampStyle, + b?.draftTimestampStyle, + t, + ), + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is StreamDraftListTileThemeData && + other.padding == padding && + other.backgroundColor == backgroundColor && + other.draftChannelNameStyle == draftChannelNameStyle && + other.draftMessageStyle == draftMessageStyle && + other.draftTimestampStyle == draftTimestampStyle; + + @override + int get hashCode => + padding.hashCode ^ + backgroundColor.hashCode ^ + draftChannelNameStyle.hashCode ^ + draftMessageStyle.hashCode ^ + draftTimestampStyle.hashCode; +} diff --git a/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart b/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart index 3c39c678f5..771f4ccb41 100644 --- a/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart +++ b/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart @@ -68,6 +68,7 @@ class StreamChatThemeData { StreamPollCommentsDialogThemeData? pollCommentsDialogTheme, StreamPollOptionVotesDialogThemeData? pollOptionVotesDialogTheme, StreamThreadListTileThemeData? threadListTileTheme, + StreamDraftListTileThemeData? draftListTileTheme, StreamAudioWaveformThemeData? audioWaveformTheme, StreamAudioWaveformSliderThemeData? audioWaveformSliderTheme, StreamVoiceRecordingAttachmentThemeData? voiceRecordingAttachmentTheme, @@ -104,6 +105,7 @@ class StreamChatThemeData { pollCommentsDialogTheme: pollCommentsDialogTheme, pollOptionVotesDialogTheme: pollOptionVotesDialogTheme, threadListTileTheme: threadListTileTheme, + draftListTileTheme: draftListTileTheme, audioWaveformTheme: audioWaveformTheme, audioWaveformSliderTheme: audioWaveformSliderTheme, voiceRecordingAttachmentTheme: voiceRecordingAttachmentTheme, @@ -142,6 +144,7 @@ class StreamChatThemeData { required this.pollCommentsDialogTheme, required this.pollOptionVotesDialogTheme, required this.threadListTileTheme, + required this.draftListTileTheme, required this.audioWaveformTheme, required this.audioWaveformSliderTheme, required this.voiceRecordingAttachmentTheme, @@ -535,6 +538,19 @@ class StreamChatThemeData { color: colorTheme.textLowEmphasis, ), ), + draftListTileTheme: StreamDraftListTileThemeData( + backgroundColor: colorTheme.barsBg, + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 8), + draftChannelNameStyle: textTheme.bodyBold.copyWith( + color: colorTheme.textHighEmphasis, + ), + draftMessageStyle: textTheme.footnote.copyWith( + color: colorTheme.textLowEmphasis, + ), + draftTimestampStyle: textTheme.footnote.copyWith( + color: colorTheme.textLowEmphasis, + ), + ), audioWaveformTheme: audioWaveformTheme, audioWaveformSliderTheme: audioWaveformSliderTheme, voiceRecordingAttachmentTheme: StreamVoiceRecordingAttachmentThemeData( @@ -654,6 +670,9 @@ class StreamChatThemeData { /// Theme configuration for the [StreamVoiceRecordingAttachment] widget. final StreamVoiceRecordingAttachmentThemeData voiceRecordingAttachmentTheme; + /// Theme configuration for the [StreamDraftListTile] widget. + final StreamDraftListTileThemeData draftListTileTheme; + /// Creates a copy of [StreamChatThemeData] with specified attributes /// overridden. StreamChatThemeData copyWith({ @@ -682,6 +701,7 @@ class StreamChatThemeData { StreamPollCommentsDialogThemeData? pollCommentsDialogTheme, StreamPollOptionVotesDialogThemeData? pollOptionVotesDialogTheme, StreamThreadListTileThemeData? threadListTileTheme, + StreamDraftListTileThemeData? draftListTileTheme, StreamAudioWaveformThemeData? audioWaveformTheme, StreamAudioWaveformSliderThemeData? audioWaveformSliderTheme, StreamVoiceRecordingAttachmentThemeData? voiceRecordingAttachmentTheme, @@ -713,6 +733,7 @@ class StreamChatThemeData { pollOptionVotesDialogTheme: pollOptionVotesDialogTheme ?? this.pollOptionVotesDialogTheme, threadListTileTheme: threadListTileTheme ?? this.threadListTileTheme, + draftListTileTheme: draftListTileTheme ?? this.draftListTileTheme, audioWaveformTheme: audioWaveformTheme ?? this.audioWaveformTheme, audioWaveformSliderTheme: audioWaveformSliderTheme ?? this.audioWaveformSliderTheme, @@ -750,6 +771,7 @@ class StreamChatThemeData { pollOptionVotesDialogTheme: pollOptionVotesDialogTheme.merge(other.pollOptionVotesDialogTheme), threadListTileTheme: threadListTileTheme.merge(other.threadListTileTheme), + draftListTileTheme: draftListTileTheme.merge(other.draftListTileTheme), audioWaveformTheme: audioWaveformTheme.merge(other.audioWaveformTheme), audioWaveformSliderTheme: audioWaveformSliderTheme.merge(other.audioWaveformSliderTheme), diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index b41bf90dd2..7a86bb5f95 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -99,6 +99,8 @@ export 'src/scroll_view/channel_scroll_view/stream_channel_grid_tile.dart'; export 'src/scroll_view/channel_scroll_view/stream_channel_grid_view.dart'; export 'src/scroll_view/channel_scroll_view/stream_channel_list_tile.dart'; export 'src/scroll_view/channel_scroll_view/stream_channel_list_view.dart'; +export 'src/scroll_view/draft_scroll_view/stream_draft_list_tile.dart'; +export 'src/scroll_view/draft_scroll_view/stream_draft_list_view.dart'; export 'src/scroll_view/member_scroll_view/stream_member_grid_view.dart'; export 'src/scroll_view/member_scroll_view/stream_member_list_view.dart'; export 'src/scroll_view/message_search_scroll_view/stream_message_search_grid_view.dart'; @@ -120,6 +122,7 @@ export 'src/scroll_view/user_scroll_view/stream_user_list_tile.dart'; export 'src/scroll_view/user_scroll_view/stream_user_list_view.dart'; export 'src/stream_chat.dart'; export 'src/stream_chat_configuration.dart'; +export 'src/theme/draft_list_tile_theme.dart'; export 'src/theme/stream_chat_theme.dart'; export 'src/theme/themes.dart'; export 'src/user/user_mention_tile.dart'; diff --git a/packages/stream_chat_flutter/test/src/channel/stream_draft_message_preview_text_test.dart b/packages/stream_chat_flutter/test/src/channel/stream_draft_message_preview_text_test.dart new file mode 100644 index 0000000000..eb124279e9 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/channel/stream_draft_message_preview_text_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/src/channel/stream_draft_message_preview_text.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +void main() { + // Helper to pump the draft message preview widget + Future pumpDraftMessagePreview( + WidgetTester tester, + DraftMessage draftMessage, { + TextStyle? textStyle, + }) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StreamChatTheme( + data: StreamChatThemeData.light(), + child: Center( + child: StreamDraftMessagePreviewText( + textStyle: textStyle, + draftMessage: draftMessage, + ), + ), + ), + ), + ), + ); + await tester.pump(); + } + + group('StreamDraftMessagePreviewText', () { + testWidgets('renders draft message', (tester) async { + final draftMessage = DraftMessage( + text: 'This is a draft message', + ); + + await pumpDraftMessagePreview(tester, draftMessage); + + expect(find.text('Draft: This is a draft message'), findsOneWidget); + }); + }); +} diff --git a/packages/stream_chat_flutter/test/src/scroll_view/draft_scroll_view/goldens/ci/stream_draft_list_tile_dark.png b/packages/stream_chat_flutter/test/src/scroll_view/draft_scroll_view/goldens/ci/stream_draft_list_tile_dark.png new file mode 100644 index 0000000000..eb47dc88e3 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/scroll_view/draft_scroll_view/goldens/ci/stream_draft_list_tile_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/scroll_view/draft_scroll_view/goldens/ci/stream_draft_list_tile_light.png b/packages/stream_chat_flutter/test/src/scroll_view/draft_scroll_view/goldens/ci/stream_draft_list_tile_light.png new file mode 100644 index 0000000000..7d71fa6e70 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/scroll_view/draft_scroll_view/goldens/ci/stream_draft_list_tile_light.png differ diff --git a/packages/stream_chat_flutter/test/src/scroll_view/draft_scroll_view/stream_draft_list_tile_test.dart b/packages/stream_chat_flutter/test/src/scroll_view/draft_scroll_view/stream_draft_list_tile_test.dart new file mode 100644 index 0000000000..5267db28c8 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/scroll_view/draft_scroll_view/stream_draft_list_tile_test.dart @@ -0,0 +1,68 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../../mocks.dart'; + +void main() { + final user = User(id: 'uid1', name: 'User 1'); + final createdAt = DateTime.parse('2022-07-20T16:00:00.000Z'); + final draft = Draft( + channelCid: 'messaging:123', + channel: ChannelModel( + cid: 'messaging:123', + extraData: const {'name': 'Group chat'}, + ), + createdAt: createdAt, + message: DraftMessage( + text: 'This is a draft message that I want to save for later', + ), + ); + + for (final brightness in Brightness.values) { + goldenTest( + '[${brightness.name}] -> StreamDraftListTile looks fine', + fileName: 'stream_draft_list_tile_${brightness.name}', + constraints: const BoxConstraints.tightFor(width: 600, height: 120), + builder: () => _wrapWithMaterialApp( + brightness: brightness, + StreamDraftListTile(draft: draft, currentUser: user), + ), + ); + } +} + +Widget _wrapWithMaterialApp( + Widget widget, { + Brightness? brightness, +}) { + final client = MockClient(); + final clientState = MockClientState(); + final currentUser = OwnUser(id: 'current-user-id', name: 'Current User'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(currentUser); + + return MaterialApp( + theme: ThemeData( + useMaterial3: true, + brightness: brightness ?? Brightness.light, + ), + home: StreamChat( + client: client, + streamChatConfigData: StreamChatConfigurationData(), + connectivityStream: Stream.value([ConnectivityResult.wifi]), + streamChatThemeData: StreamChatThemeData(brightness: brightness), + child: Builder( + builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: Center(child: widget), + ); + }, + ), + ), + ); +} diff --git a/packages/stream_chat_flutter/test/src/theme/draft_list_tile_theme_test.dart b/packages/stream_chat_flutter/test/src/theme/draft_list_tile_theme_test.dart new file mode 100644 index 0000000000..b19f9151d1 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/theme/draft_list_tile_theme_test.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +void main() { + testWidgets('StreamDraftListTileTheme merges with ancestor theme', + (tester) async { + const backgroundColor = Colors.blue; + const childBackgroundColor = Colors.red; + + late BuildContext capturedContext; + + await tester.pumpWidget( + MaterialApp( + home: StreamChatTheme( + data: StreamChatThemeData( + draftListTileTheme: const StreamDraftListTileThemeData( + backgroundColor: backgroundColor, + ), + ), + child: Builder( + builder: (context) { + return StreamDraftListTileTheme( + data: const StreamDraftListTileThemeData( + backgroundColor: childBackgroundColor, + ), + child: Builder( + builder: (context) { + capturedContext = context; + return const SizedBox(); + }, + ), + ); + }, + ), + ), + ), + ); + + // Verify that the theme data is correctly merged + final theme = StreamDraftListTileTheme.of(capturedContext); + expect(theme.backgroundColor, childBackgroundColor); + }); + + test('StreamDraftListTileThemeData equality', () { + const themeData1 = StreamDraftListTileThemeData( + backgroundColor: Colors.red, + padding: EdgeInsets.all(8), + draftChannelNameStyle: TextStyle(fontSize: 16), + draftMessageStyle: TextStyle(fontSize: 14), + draftTimestampStyle: TextStyle(fontSize: 12), + ); + + const themeData2 = StreamDraftListTileThemeData( + backgroundColor: Colors.red, + padding: EdgeInsets.all(8), + draftChannelNameStyle: TextStyle(fontSize: 16), + draftMessageStyle: TextStyle(fontSize: 14), + draftTimestampStyle: TextStyle(fontSize: 12), + ); + + const themeData3 = StreamDraftListTileThemeData( + backgroundColor: Colors.blue, // Different color + padding: EdgeInsets.all(8), + draftChannelNameStyle: TextStyle(fontSize: 16), + draftMessageStyle: TextStyle(fontSize: 14), + draftTimestampStyle: TextStyle(fontSize: 12), + ); + + // Same properties should be equal + expect(themeData1, themeData2); + // Different properties should not be equal + expect(themeData1, isNot(themeData3)); + + // Hash codes should match for equal objects + expect(themeData1.hashCode, themeData2.hashCode); + }); + + test('StreamDraftListTileThemeData copyWith', () { + const original = StreamDraftListTileThemeData( + backgroundColor: Colors.red, + padding: EdgeInsets.all(8), + draftChannelNameStyle: TextStyle(fontSize: 16), + draftMessageStyle: TextStyle(fontSize: 14), + draftTimestampStyle: TextStyle(fontSize: 12), + ); + + const newBackgroundColor = Colors.blue; + const newPadding = EdgeInsets.all(16); + + final copied = original.copyWith( + backgroundColor: newBackgroundColor, + padding: newPadding, + ); + + // Verify copied properties + expect(copied.backgroundColor, newBackgroundColor); + expect(copied.padding, newPadding); + // Unchanged properties should remain the same + expect(copied.draftChannelNameStyle, original.draftChannelNameStyle); + expect(copied.draftMessageStyle, original.draftMessageStyle); + expect(copied.draftTimestampStyle, original.draftTimestampStyle); + }); + + test('StreamDraftListTileThemeData merge', () { + const original = StreamDraftListTileThemeData( + backgroundColor: Colors.red, + padding: EdgeInsets.all(8), + draftChannelNameStyle: TextStyle(fontSize: 16), + draftMessageStyle: TextStyle(fontSize: 14), + draftTimestampStyle: TextStyle(fontSize: 12), + ); + + const other = StreamDraftListTileThemeData( + backgroundColor: Colors.blue, + padding: EdgeInsets.all(16), + // Other properties are null + ); + + final merged = original.merge(other); + + // Properties from 'other' should override 'original' + expect(merged.backgroundColor, other.backgroundColor); + expect(merged.padding, other.padding); + // Null properties in 'other' should not override 'original' + expect(merged.draftChannelNameStyle, original.draftChannelNameStyle); + expect(merged.draftMessageStyle, original.draftMessageStyle); + expect(merged.draftTimestampStyle, original.draftTimestampStyle); + + // Merging with null should return original + final mergedWithNull = original.merge(null); + expect(mergedWithNull, original); + }); + + test('StreamDraftListTileThemeData lerp', () { + const data1 = StreamDraftListTileThemeData( + backgroundColor: Colors.black, + padding: EdgeInsets.all(8), + ); + + const data2 = StreamDraftListTileThemeData( + backgroundColor: Colors.white, + padding: EdgeInsets.all(16), + ); + + // t = 0 should return data1 + final lerpedAt0 = data1.lerp(data1, data2, 0); + expect(lerpedAt0.backgroundColor, data1.backgroundColor); + expect(lerpedAt0.padding, data1.padding); + + // t = 1 should return data2 + final lerpedAt1 = data1.lerp(data1, data2, 1); + expect(lerpedAt1.backgroundColor, data2.backgroundColor); + expect(lerpedAt1.padding, data2.padding); + + // t = 0.5 should return something in between + final lerpedAt05 = data1.lerp(data1, data2, 0.5); + expect(lerpedAt05.backgroundColor, + Color.lerp(Colors.black, Colors.white, 0.5)); + expect( + lerpedAt05.padding, + EdgeInsetsGeometry.lerp( + const EdgeInsets.all(8), + const EdgeInsets.all(16), + 0.5, + )); + }); +} diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md index 51bb665d3c..6e6ddf2035 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -1,3 +1,9 @@ +## Upcoming + +✅ Added + +- Added `StreamDraftListController` to manage the list of draft messages. + ## 9.8.0 ✅ Added diff --git a/packages/stream_chat_flutter_core/lib/src/stream_draft_list_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_draft_list_controller.dart new file mode 100644 index 0000000000..8845ec93f1 --- /dev/null +++ b/packages/stream_chat_flutter_core/lib/src/stream_draft_list_controller.dart @@ -0,0 +1,284 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:collection/collection.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter_core/src/paged_value_notifier.dart'; +import 'package:stream_chat_flutter_core/src/stream_draft_list_event_handler.dart'; + +/// The default channel page limit to load. +const defaultDraftPagedLimit = 10; + +/// The default sort used for the draft list. +const defaultDraftListSort = [ + SortOption.desc(DraftSortKey.createdAt), +]; + +const _kDefaultBackendPaginationLimit = 30; + +/// {@template streamDraftListController} +/// A controller for managing and displaying a paginated list of drafts. +/// +/// The `StreamDraftListController` extends [PagedValueNotifier] to handle +/// paginated data for drafts. It provides functionality for querying drafts, +/// handling events, and managing filters and sorting. +/// +/// This controller is typically used in conjunction with UI components +/// to display and interact with a list of drafts. +/// {@endtemplate} +class StreamDraftListController extends PagedValueNotifier { + /// {@macro streamThreadListController} + StreamDraftListController({ + required this.client, + StreamDraftListEventHandler? eventHandler, + this.filter, + this.sort = defaultDraftListSort, + this.limit = defaultDraftPagedLimit, + }) : _activeFilter = filter, + _activeSort = sort, + _eventHandler = eventHandler ?? StreamDraftListEventHandler(), + super(const PagedValue.loading()); + + /// Creates a [StreamThreadListController] from the passed [value]. + StreamDraftListController.fromValue( + super.value, { + required this.client, + StreamDraftListEventHandler? eventHandler, + this.filter, + this.sort = defaultDraftListSort, + this.limit = defaultDraftPagedLimit, + }) : _activeFilter = filter, + _activeSort = sort, + _eventHandler = eventHandler ?? StreamDraftListEventHandler(); + + /// The Stream client used to perform the queries. + final StreamChatClient client; + + /// The channel event handlers to use for the draft list. + final StreamDraftListEventHandler _eventHandler; + + /// The query filters to use. + /// + /// You can query on any of the custom fields you've defined on the + /// [Draft]. + final Filter? filter; + Filter? _activeFilter; + + /// The sorting used for the drafts matching the filters. + /// + /// Sorting is based on field and direction, multiple sorting options + /// can be provided. + /// + /// Direction can be ascending or descending. + final SortOrder? sort; + SortOrder? _activeSort; + + /// The limit to apply to the poll vote list. The default is set to + /// [defaultPollVotePagedLimit]. + final int limit; + + /// Allows for the change of filters used for poll vote queries. + /// + /// Use this if you need to support runtime filter changes, + /// through custom filters UI. + set filter(Filter? value) => _activeFilter = value; + + /// Allows for the change of the query sort used for poll vote queries. + /// + /// Use this if you need to support runtime sort changes, + /// through custom sort UI. + set sort(SortOrder? value) => _activeSort = value; + + @override + set value(PagedValue newValue) { + super.value = switch (_activeSort) { + null => newValue, + final draftSort => newValue.maybeMap( + orElse: () => newValue, + (success) => success.copyWith( + items: success.items.sorted(draftSort.compare), + ), + ), + }; + } + + @override + Future doInitialLoad() async { + final limit = min( + this.limit * defaultInitialPagedLimitMultiplier, + _kDefaultBackendPaginationLimit, + ); + try { + final response = await client.queryDrafts( + sort: _activeSort, + filter: _activeFilter, + pagination: PaginationParams(limit: limit), + ); + + final results = response.drafts; + final nextKey = response.next; + value = PagedValue( + items: results, + nextPageKey: nextKey, + ); + // Start listening to events + _subscribeToDraftListEvents(); + } on StreamChatError catch (error) { + value = PagedValue.error(error); + } catch (error) { + final chatError = StreamChatError(error.toString()); + value = PagedValue.error(chatError); + } + } + + @override + Future loadMore(String nextPageKey) async { + final previousValue = value.asSuccess; + + try { + final response = await client.queryDrafts( + sort: _activeSort, + filter: _activeFilter, + pagination: PaginationParams(limit: limit, next: nextPageKey), + ); + + final results = response.drafts; + final previousItems = previousValue.items; + final newItems = previousItems + results; + final next = response.next; + final nextKey = next != null && next.isNotEmpty ? next : null; + value = PagedValue( + items: newItems, + nextPageKey: nextKey, + ); + } on StreamChatError catch (error) { + value = previousValue.copyWith(error: error); + } catch (error) { + final chatError = StreamChatError(error.toString()); + value = previousValue.copyWith(error: chatError); + } + } + + /// Event listener, which can be set in order to listen + /// [client] web-socket events. + /// + /// Return `true` if the event is handled. Return `false` to + /// allow the event to be handled internally. + bool Function(Event event)? eventListener; + + StreamSubscription? _draftEventSubscription; + + // Subscribes to the draft list events. + void _subscribeToDraftListEvents() { + if (_draftEventSubscription != null) { + _unsubscribeFromDraftListEvents(); + } + + _draftEventSubscription = client.on().listen((event) { + // Only handle the event if the value is in success state. + if (value.isNotSuccess) return; + + // Returns early if the event is already handled by the listener. + if (eventListener?.call(event) ?? false) return; + + final handlerFunc = switch (event.type) { + EventType.draftUpdated => _eventHandler.onDraftUpdated, + EventType.draftDeleted => _eventHandler.onDraftDeleted, + EventType.connectionRecovered => _eventHandler.onConnectionRecovered, + _ => null, + }; + + return handlerFunc?.call(event, this); + }); + } + + @override + Future refresh({bool resetValue = true}) { + if (resetValue) { + _activeFilter = filter; + _activeSort = sort; + } + return super.refresh(resetValue: resetValue); + } + + /// Replaces the previously loaded drafts with the passed [drafts]. + set drafts(List drafts) { + if (value.isSuccess) { + final currentValue = value.asSuccess; + value = currentValue.copyWith(items: drafts); + } else { + value = PagedValue(items: drafts); + } + } + + /// Updates the given [draft] in the list. + /// + /// Returns `true` if the thread is updated successfully. Otherwise, `false`. + bool updateDraft(Draft draft) { + final currentDrafts = [ + ...currentItems.merge( + [draft], + key: (draft) { + var predicate = draft.channelCid; + if (draft.parentId case final parentId?) { + predicate += parentId; + } + + return predicate; + }, + update: (original, updated) => updated, + ), + ]; + + drafts = currentDrafts; + return true; + } + + /// Deletes the draft with the given [channelCid] and optional [parentId] + /// from the list. + /// + /// Returns `true` if the draft is deleted successfully. Otherwise, `false`. + bool deleteDraft(Draft draft) { + final currentDrafts = [...currentItems]; + final removeIndex = currentDrafts.indexWhere( + (it) { + var predicate = it.channelCid == draft.channelCid; + if (draft.parentId case final parentId?) { + predicate &= it.parentId == parentId; + } + + return predicate; + }, + ); + + if (removeIndex < 0) return false; + currentDrafts.removeAt(removeIndex); + + drafts = currentDrafts; + return true; + } + + // Unsubscribes from all draft list events. + void _unsubscribeFromDraftListEvents() { + if (_draftEventSubscription != null) { + _draftEventSubscription!.cancel(); + _draftEventSubscription = null; + } + } + + /// Pauses all subscriptions added to this composite. + void pauseEventsSubscription([Future? resumeSignal]) { + _draftEventSubscription?.pause(resumeSignal); + } + + /// Resumes all subscriptions added to this composite. + void resumeEventsSubscription() { + _draftEventSubscription?.resume(); + } + + @override + void dispose() { + _unsubscribeFromDraftListEvents(); + super.dispose(); + } +} diff --git a/packages/stream_chat_flutter_core/lib/src/stream_draft_list_event_handler.dart b/packages/stream_chat_flutter_core/lib/src/stream_draft_list_event_handler.dart new file mode 100644 index 0000000000..90cdfe1a0a --- /dev/null +++ b/packages/stream_chat_flutter_core/lib/src/stream_draft_list_event_handler.dart @@ -0,0 +1,50 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter_core/src/stream_draft_list_controller.dart'; + +/// Contains handlers that are called from [StreamDraftListController] for +/// certain [Event]s. +/// +/// This class can be mixed in or extended to create custom overrides. +mixin class StreamDraftListEventHandler { + /// Function which gets called for the event [EventType.connectionRecovered]. + /// + /// This event is fired when the client web-socket connection recovers. + /// + /// By default, this refreshes the whole draft list. + void onConnectionRecovered( + Event event, + StreamDraftListController controller, + ) { + controller.refresh(); + } + + /// Function which gets called for the event [EventType.threadUpdated]. + /// + /// This event is fired when a thread is updated. + /// + /// By default, this does nothing. Override this method to handle this event. + void onDraftUpdated( + Event event, + StreamDraftListController controller, + ) { + final draft = event.draft; + if (draft == null) return; + + controller.updateDraft(draft); + } + + /// Function which gets called for the event [EventType.draftDeleted]. + /// + /// This event is fired when a draft is deleted. + /// + /// By default, this does nothing. Override this method to handle this event. + void onDraftDeleted( + Event event, + StreamDraftListController controller, + ) { + final draft = event.draft; + if (draft == null) return; + + controller.deleteDraft(draft); + } +} diff --git a/packages/stream_chat_flutter_core/lib/src/stream_message_input_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_message_input_controller.dart index 6ea2e2f765..478fdacce7 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_message_input_controller.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_message_input_controller.dart @@ -111,6 +111,9 @@ class StreamMessageInputController extends ValueNotifier { _textFieldController.text = text; } + /// Returns true if the slow mode is currently active. + bool get isSlowModeActive => _cooldownTimeOut > 0; + /// The current [cooldownTimeOut] of the slow mode. /// /// Defaults to 0, which means slow mode is not active. diff --git a/packages/stream_chat_flutter_core/lib/src/stream_thread_list_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_thread_list_controller.dart index d5d5902aab..974e780553 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_thread_list_controller.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_thread_list_controller.dart @@ -44,7 +44,7 @@ class StreamThreadListController extends PagedValueNotifier { /// The Stream client used to perform the queries. final StreamChatClient client; - /// The channel event handlers to use for the channels list. + /// The thread event handlers to use for the thread list. final StreamThreadListEventHandler _eventHandler; /// The limit to apply to the thread list. @@ -290,6 +290,8 @@ class StreamThreadListController extends PagedValueNotifier { EventType.reactionNew => _eventHandler.onReactionNew, EventType.reactionUpdated => _eventHandler.onReactionUpdated, EventType.reactionDeleted => _eventHandler.onReactionDeleted, + EventType.draftUpdated => _eventHandler.onDraftUpdated, + EventType.draftDeleted => _eventHandler.onDraftDeleted, _ => null, }; diff --git a/packages/stream_chat_flutter_core/lib/src/stream_thread_list_event_handler.dart b/packages/stream_chat_flutter_core/lib/src/stream_thread_list_event_handler.dart index d01279665c..038cd54685 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_thread_list_event_handler.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_thread_list_event_handler.dart @@ -218,6 +218,48 @@ mixin class StreamThreadListEventHandler { return _markThreadAsUnread(thread, user, createdAt, controller); } + /// Function which gets called for the event [EventType.draftUpdated]. + /// + /// This event is fired when a draft is either created or updated. + /// + /// By default, this updates the draft in the thread. + void onDraftUpdated( + Event event, + StreamThreadListController controller, + ) { + final draft = event.draft; + if (draft == null) return; + + final parentMessageId = draft.parentId; + final thread = controller.getThread(parentMessageId: parentMessageId); + if (thread == null) return; + + final updatedThread = thread.copyWith(draft: draft); + + controller.updateThread(updatedThread); + } + + /// Function which gets called for the event [EventType.draftDeleted]. + /// + /// This event is fired when a draft is deleted. + /// + /// By default, this deletes the draft in the thread. + void onDraftDeleted( + Event event, + StreamThreadListController controller, + ) { + final draft = event.draft; + if (draft == null) return; + + final parentMessageId = draft.parentId; + final thread = controller.getThread(parentMessageId: parentMessageId); + if (thread == null) return; + + final updatedThread = thread.copyWith(draft: null); + + controller.updateThread(updatedThread); + } + void _markThreadAsRead( Thread threadInfo, User user, diff --git a/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart b/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart index cbdd0601c0..ebfa497f0f 100644 --- a/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart +++ b/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart @@ -14,11 +14,14 @@ export 'src/stream_channel.dart'; export 'src/stream_channel_list_controller.dart'; export 'src/stream_channel_list_event_handler.dart'; export 'src/stream_chat_core.dart'; +export 'src/stream_draft_list_controller.dart'; +export 'src/stream_draft_list_event_handler.dart'; export 'src/stream_member_list_controller.dart'; export 'src/stream_message_input_controller.dart'; export 'src/stream_message_search_list_controller.dart'; export 'src/stream_poll_controller.dart'; export 'src/stream_poll_vote_list_controller.dart'; export 'src/stream_thread_list_controller.dart'; +export 'src/stream_thread_list_event_handler.dart'; export 'src/stream_user_list_controller.dart'; export 'src/typedef.dart'; diff --git a/packages/stream_chat_flutter_core/test/stream_draft_list_controller_test.dart b/packages/stream_chat_flutter_core/test/stream_draft_list_controller_test.dart new file mode 100644 index 0000000000..ff07096332 --- /dev/null +++ b/packages/stream_chat_flutter_core/test/stream_draft_list_controller_test.dart @@ -0,0 +1,697 @@ +// ignore_for_file: avoid_redundant_argument_values + +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/stream_chat.dart' hide Success; +import 'package:stream_chat_flutter_core/src/paged_value_notifier.dart'; +import 'package:stream_chat_flutter_core/src/stream_draft_list_controller.dart'; + +import 'mocks.dart'; + +Draft generateDraft({ + String? channelCid, + String? text, + String? parentId, + DateTime? createdAt, +}) { + return Draft( + channelCid: channelCid ?? 'messaging:123', + createdAt: createdAt ?? DateTime.now(), + message: DraftMessage( + text: text ?? 'Test draft message', + ), + parentId: parentId, + ); +} + +List generateDrafts({ + int count = 2, + List? texts, + int? startId, + bool withThreads = false, +}) { + final now = DateTime.now(); + final baseId = startId ?? 123; + + return List.generate(count, (index) { + final text = texts != null && index < texts.length + ? texts[index] + : 'Draft ${index + 1}'; + + return generateDraft( + channelCid: 'messaging:${baseId + index}', + text: text, + createdAt: now.subtract(Duration(minutes: index)), + parentId: withThreads && index.isOdd ? 'parent${index ~/ 2}' : null, + ); + }); +} + +void main() { + final client = MockClient(); + + setUp(() { + when(client.on).thenAnswer((_) => const Stream.empty()); + }); + + tearDown(() { + reset(client); + }); + + group('Initialization', () { + test('should start in loading state when created with client', () { + final controller = StreamDraftListController(client: client); + expect(controller.value, isA()); + }); + + test('should preserve provided value when created with fromValue', () { + final drafts = generateDrafts(); + final value = PagedValue(items: drafts); + final controller = StreamDraftListController.fromValue( + value, + client: client, + ); + + expect(controller.value, same(value)); + expect(controller.value.asSuccess.items, equals(drafts)); + }); + }); + + group('Initial loading', () { + test('successfully loads drafts from API', () async { + final drafts = generateDrafts(); + final response = QueryDraftsResponse() + ..drafts = drafts + ..next = ''; + + when(() => client.queryDrafts( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + )).thenAnswer((_) async => response); + + final controller = StreamDraftListController(client: client); + + await controller.doInitialLoad(); + await pumpEventQueue(); + + verify(() => client.queryDrafts( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + )).called(1); + + expect(controller.value, isA>()); + expect(controller.value.asSuccess.items, equals(drafts)); + }); + + test('handles API exceptions by transitioning to error state', () async { + final exception = Exception('API unavailable'); + when(() => client.queryDrafts( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + )).thenThrow(exception); + + final controller = StreamDraftListController(client: client); + + await controller.doInitialLoad(); + await pumpEventQueue(); + + expect(controller.value, isA()); + expect( + (controller.value as Error).error.message, + contains('API unavailable'), + ); + }); + }); + + group('Pagination', () { + test('loadMore appends new drafts to existing items', () async { + const nextKey = 'next_page_token'; + final existingDrafts = generateDrafts(); + final additionalDrafts = generateDrafts( + count: 1, + startId: 789, + texts: ['Draft 3'], + ); + + final response = QueryDraftsResponse() + ..drafts = additionalDrafts + ..next = ''; + + when(() => client.queryDrafts( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + )).thenAnswer((_) async => response); + + final controller = StreamDraftListController.fromValue( + PagedValue( + items: existingDrafts, + nextPageKey: nextKey, + ), + client: client, + ); + + await controller.loadMore(nextKey); + await pumpEventQueue(); + + // We need to verify that all the drafts are there, but not necessarily in + // the same order since the controller might sort them differently + final mergedDrafts = [...existingDrafts, ...additionalDrafts]; + + // Verify all drafts are present by checking the number and content + expect( + controller.value.asSuccess.items.length, + equals(mergedDrafts.length), + ); + + for (final draft in mergedDrafts) { + expect( + controller.value.asSuccess.items.any((d) => + d.channelCid == draft.channelCid && + d.message.text == draft.message.text), + isTrue, + ); + } + + expect(controller.value.asSuccess.nextPageKey, isNull); + }); + + test('loadMore preserves existing items when API throws exception', + () async { + const nextKey = 'next_page_token'; + final existingDrafts = generateDrafts(); + final exception = Exception('Network error'); + + when(() => client.queryDrafts( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + )).thenThrow(exception); + + final controller = StreamDraftListController.fromValue( + PagedValue( + items: existingDrafts, + nextPageKey: nextKey, + ), + client: client, + ); + + await controller.loadMore(nextKey); + await pumpEventQueue(); + + expect(controller.value.isSuccess, isTrue); + expect(controller.value.asSuccess.items, equals(existingDrafts)); + expect(controller.value.asSuccess.error, isNotNull); + expect( + controller.value.asSuccess.error!.message, + contains('Network error'), + ); + }); + }); + + group('Draft CRUD operations', () { + test('updateDraft replaces existing draft with same predicate', () { + final drafts = generateDrafts(texts: ['Draft 1', 'Draft 2']); + final controller = StreamDraftListController.fromValue( + PagedValue(items: drafts), + client: client, + ); + + final updatedDraft = drafts[0].copyWith( + message: DraftMessage( + text: 'Updated Draft 1', + ), + ); + + final result = controller.updateDraft(updatedDraft); + + expect(result, isTrue); + expect( + controller.value.asSuccess.items.first.message.text, + equals('Updated Draft 1'), + ); + expect(controller.value.asSuccess.items.length, equals(drafts.length)); + }); + + test('updateDraft adds draft when no matching draft exists', () { + final drafts = generateDrafts(); + final controller = StreamDraftListController.fromValue( + PagedValue(items: drafts), + client: client, + ); + + final newDraft = generateDraft( + channelCid: 'messaging:789', + text: 'New Draft', + ); + + final result = controller.updateDraft(newDraft); + + expect(result, isTrue); + + expect( + controller.value.asSuccess.items.length, + equals(drafts.length + 1), + ); + + expect( + controller.value.asSuccess.items.any( + (d) => d.message.text == 'New Draft', + ), + isTrue, + ); + }); + + test('deleteDraft removes draft and returns true when draft exists', () { + final drafts = generateDrafts(); + final controller = StreamDraftListController.fromValue( + PagedValue(items: drafts), + client: client, + ); + + final result = controller.deleteDraft(drafts[0]); + + expect(result, isTrue); + expect( + controller.value.asSuccess.items.length, + equals(drafts.length - 1), + ); + expect(controller.value.asSuccess.items.contains(drafts[0]), isFalse); + }); + + test('deleteDraft returns false when draft does not exist', () { + final drafts = generateDrafts(); + final controller = StreamDraftListController.fromValue( + PagedValue(items: drafts), + client: client, + ); + + final nonExistentDraft = generateDraft( + channelCid: 'messaging:999', + text: 'Non-existent Draft', + ); + + final result = controller.deleteDraft(nonExistentDraft); + + expect(result, isFalse); + expect(controller.value.asSuccess.items.length, equals(drafts.length)); + }); + + test('deleteDraft correctly handles drafts with parentId', () { + final threadDraft1 = generateDraft( + channelCid: 'messaging:thread', + text: 'Thread Draft 1', + parentId: 'parent1', + ); + + final threadDraft2 = generateDraft( + channelCid: 'messaging:thread', + text: 'Thread Draft 2', + parentId: 'parent2', + ); + + final regularDrafts = generateDrafts(); + final allDrafts = [...regularDrafts, threadDraft1, threadDraft2]; + + final controller = StreamDraftListController.fromValue( + PagedValue(items: allDrafts), + client: client, + ); + + final result = controller.deleteDraft(threadDraft1); + + expect(result, isTrue); + expect( + controller.value.asSuccess.items.length, + equals(allDrafts.length - 1), + ); + + final remainingDraftTexts = [ + ...controller.value.asSuccess.items.map((d) => d.message.text) + ]; + + expect(remainingDraftTexts, contains('Thread Draft 2')); + expect(remainingDraftTexts, isNot(contains('Thread Draft 1'))); + }); + }); + + group('Order preservation', () { + test('controller preserves explicit draft order', () { + final draftA = generateDraft( + channelCid: 'messaging:A', + text: 'Draft A', + ); + + final draftB = generateDraft( + channelCid: 'messaging:B', + text: 'Draft B', + ); + + final abOrder = [draftA, draftB]; + final controllerAB = StreamDraftListController.fromValue( + PagedValue(items: abOrder), + client: client, + ); + + final baOrder = [draftB, draftA]; + final controllerBA = StreamDraftListController.fromValue( + PagedValue(items: baOrder), + client: client, + ); + + expect( + controllerAB.value.asSuccess.items.first.message.text, + equals('Draft A'), + ); + expect( + controllerBA.value.asSuccess.items.first.message.text, + equals('Draft B'), + ); + }); + }); + + group('Event handling', () { + late StreamController eventController; + + setUp(() { + eventController = StreamController.broadcast(); + when(client.on).thenAnswer((_) => eventController.stream); + }); + + tearDown(() { + eventController.close(); + }); + + test('draft_updated event triggers draft update', () async { + final drafts = generateDrafts(); + final queryResponse = QueryDraftsResponse() + ..drafts = drafts + ..next = ''; + + when(() => client.queryDrafts( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + )).thenAnswer((_) async => queryResponse); + + final controller = StreamDraftListController.fromValue( + PagedValue(items: drafts), + client: client, + ); + + await controller.doInitialLoad(); + await pumpEventQueue(); + + final updatedDraft = drafts[0].copyWith( + message: DraftMessage(text: 'Updated via event'), + ); + + final event = Event( + type: EventType.draftUpdated, + draft: updatedDraft, + ); + + eventController.add(event); + await pumpEventQueue(); + + final hasUpdatedDraft = controller.value.asSuccess.items.any( + (draft) => draft.message.text == 'Updated via event', + ); + + expect(hasUpdatedDraft, isTrue); + }); + + test('draft_deleted event triggers draft removal', () async { + final drafts = generateDrafts(); + final queryResponse = QueryDraftsResponse() + ..drafts = drafts + ..next = ''; + + when(() => client.queryDrafts( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + )).thenAnswer((_) async => queryResponse); + + final controller = StreamDraftListController.fromValue( + PagedValue(items: drafts), + client: client, + ); + + await controller.doInitialLoad(); + await pumpEventQueue(); + + final initialItemCount = controller.value.asSuccess.items.length; + + final event = Event( + type: EventType.draftDeleted, + draft: drafts[0], + ); + + eventController.add(event); + await pumpEventQueue(); + + expect( + controller.value.asSuccess.items.length, + equals(initialItemCount - 1), + ); + + expect( + controller.value.asSuccess.items.any( + (d) => d.channelCid == drafts[0].channelCid, + ), + isFalse, + ); + }); + + test('connection_recovered event triggers refresh', () async { + final drafts = generateDrafts(); + var queryCallCount = 0; + + when(() => client.queryDrafts( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + )).thenAnswer((_) async { + queryCallCount++; + return QueryDraftsResponse() + ..drafts = drafts + ..next = ''; + }); + + final controller = StreamDraftListController.fromValue( + PagedValue(items: drafts), + client: client, + ); + + await controller.doInitialLoad(); + await pumpEventQueue(); + + // Reset the counter after initial load + queryCallCount = 0; + + // When connection recovered is triggered, verify another query is made + final event = Event(type: EventType.connectionRecovered); + eventController.add(event); + + // Give it enough time for the async operations to complete + await pumpEventQueue(times: 10); + + // The controller should re-query during refresh + expect(queryCallCount, equals(1)); + }); + + test('custom event listener can prevent default handling', () async { + final drafts = generateDrafts(); + final queryResponse = QueryDraftsResponse() + ..drafts = drafts + ..next = ''; + + when(() => client.queryDrafts( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + )).thenAnswer((_) async => queryResponse); + + final controller = StreamDraftListController.fromValue( + PagedValue(items: drafts), + client: client, + ); + + await controller.doInitialLoad(); + await pumpEventQueue(); + + final initialItems = List.of(controller.value.asSuccess.items); + + var listenerCalled = false; + controller.eventListener = (event) { + listenerCalled = true; + return true; + }; + + final updatedDraft = drafts[0].copyWith( + message: DraftMessage( + text: 'Should not update', + ), + ); + + final event = Event( + type: EventType.draftUpdated, + draft: updatedDraft, + ); + + eventController.add(event); + await pumpEventQueue(); + + expect(listenerCalled, isTrue); + expect(controller.value.asSuccess.items, equals(initialItems)); + expect( + controller.value.asSuccess.items.any( + (d) => d.message.text == 'Should not update', + ), + isFalse, + ); + }); + }); + + group('Subscription lifecycle', () { + test('pause and resume correctly affect event subscription', () async { + final drafts = generateDrafts(); + final eventController = StreamController.broadcast(); + when(client.on).thenAnswer((_) => eventController.stream); + + final response = QueryDraftsResponse() + ..drafts = drafts + ..next = ''; + + when(() => client.queryDrafts( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + )).thenAnswer((_) async => response); + + final controller = StreamDraftListController.fromValue( + PagedValue(items: drafts), + client: client, + ); + + await controller.doInitialLoad(); + await pumpEventQueue(); + + controller + ..pauseEventsSubscription() + ..resumeEventsSubscription(); + + eventController.close(); + }); + }); + + group('Filtering and sorting', () { + test('refresh resets filter and sort to initial values', () async { + final drafts = generateDrafts(); + final initialFilter = Filter.equal('type', 'messaging'); + const initialSort = [SortOption.desc(DraftSortKey.createdAt)]; + + final apiCalls = >[]; + + when(() => client.queryDrafts( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + )).thenAnswer((invocation) async { + apiCalls.add({ + 'filter': invocation.namedArguments[const Symbol('filter')], + 'sort': invocation.namedArguments[const Symbol('sort')], + }); + return QueryDraftsResponse() + ..drafts = drafts + ..next = ''; + }); + + final controller = StreamDraftListController( + client: client, + filter: initialFilter, + sort: initialSort, + ); + + await controller.doInitialLoad(); + await pumpEventQueue(); + + controller + ..filter = Filter.equal('type', 'team') + ..sort = const [SortOption.asc(DraftSortKey.createdAt)]; + + await controller.refresh(); + await pumpEventQueue(); + + expect(apiCalls.length, equals(2)); + + final refreshCall = apiCalls.last; + expect(refreshCall['filter'], equals(initialFilter)); + expect(refreshCall['sort'], equals(initialSort)); + }); + + test( + 'refresh with resetValue=false preserves current filter and sort', + () async { + final drafts = generateDrafts(); + final initialFilter = Filter.equal('type', 'messaging'); + const initialSort = [SortOption.desc(DraftSortKey.createdAt)]; + final newFilter = Filter.equal('type', 'team'); + const newSort = [SortOption.asc(DraftSortKey.createdAt)]; + + final apiCalls = >[]; + + when(() => client.queryDrafts( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + )).thenAnswer((invocation) { + apiCalls.add({ + 'filter': invocation.namedArguments[const Symbol('filter')], + 'sort': invocation.namedArguments[const Symbol('sort')], + }); + return Future.value(QueryDraftsResponse() + ..drafts = drafts + ..next = ''); + }); + + final controller = StreamDraftListController( + client: client, + filter: initialFilter, + sort: initialSort, + ); + + await controller.doInitialLoad(); + await pumpEventQueue(); + + controller + ..filter = newFilter + ..sort = newSort; + + await controller.refresh(resetValue: false); + await pumpEventQueue(); + + expect(apiCalls.length, equals(2)); + + final refreshCall = apiCalls.last; + expect(refreshCall['filter'], equals(newFilter)); + expect(refreshCall['sort'], equals(newSort)); + }, + ); + }); + + group('Disposal', () { + test('dispose cancels subscriptions without errors', () { + final controller = StreamDraftListController(client: client) + ..doInitialLoad(); + + expect(controller.dispose, returnsNormally); + }); + }); +} diff --git a/packages/stream_chat_localizations/CHANGELOG.md b/packages/stream_chat_localizations/CHANGELOG.md index 099a1918dc..4e0b4ef5e9 100644 --- a/packages/stream_chat_localizations/CHANGELOG.md +++ b/packages/stream_chat_localizations/CHANGELOG.md @@ -1,3 +1,7 @@ +## Upcoming + +- Added translations for new `draftLabel` label. + ## 9.8.0 - Updated `stream_chat_flutter` dependency to [`9.8.0`](https://pub.dev/packages/stream_chat/changelog). 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 de9fae83fc..52277edd45 100644 --- a/packages/stream_chat_localizations/example/lib/add_new_lang.dart +++ b/packages/stream_chat_localizations/example/lib/add_new_lang.dart @@ -682,6 +682,9 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { @override String pollSomeoneCreatedText(String username) => '$username created'; + + @override + String get draftLabel => 'Draft'; } 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 e702e7f516..4a8e2cbb75 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 @@ -664,4 +664,7 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { @override String pollSomeoneCreatedText(String username) => '$username ha creat'; + + @override + String get draftLabel => 'Esborrany'; } 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 662ad24153..b5edebe21d 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 @@ -657,4 +657,7 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { @override String pollSomeoneCreatedText(String username) => '$username hat erstellt'; + + @override + String get draftLabel => 'Entwurf'; } 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 7aefdb323f..b5c9448da4 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 @@ -659,4 +659,7 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { @override String pollSomeoneCreatedText(String username) => '$username created'; + + @override + String get draftLabel => 'Draft'; } 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 8c4b87d812..fac507117f 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 @@ -666,4 +666,7 @@ No es posible añadir más de $limit archivos adjuntos @override String pollSomeoneCreatedText(String username) => '$username ha creado'; + + @override + String get draftLabel => 'Borrador'; } 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 ce992aa1ba..161da059e9 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 @@ -669,4 +669,7 @@ Limite de pièces jointes dépassée : il n'est pas possible d'ajouter plus de $ @override String pollSomeoneCreatedText(String username) => '$username a créé'; + + @override + String get draftLabel => 'Brouillon'; } 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 9e17bcf812..73ebbfb039 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 @@ -660,4 +660,7 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { @override String pollSomeoneCreatedText(String username) => '$username ने बनाया'; + + @override + String get draftLabel => 'ड्राफ्ट'; } 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 711ae5db17..ea04e09bb1 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 @@ -669,4 +669,7 @@ Attenzione: il limite massimo di $limit file è stato superato. @override String pollSomeoneCreatedText(String username) => '$username ha creato'; + + @override + String get draftLabel => 'Bozza'; } 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 21d3624c30..2ab9991b34 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 @@ -635,8 +635,11 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { String pollSomeoneVotedText(String username) => '$usernameが投票しました'; @override - String get pollYouCreatedText => '作成しました'; + String get pollYouCreatedText => 'あなたが作成しました'; @override String pollSomeoneCreatedText(String username) => '$usernameが作成しました'; + + @override + String get draftLabel => '下書き'; } 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 3befc0d6a9..31acc8490a 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 @@ -640,4 +640,7 @@ class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations { @override String pollSomeoneCreatedText(String username) => '$username님이 생성했습니다'; + + @override + String get draftLabel => '임시글'; } 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 2d9607503b..a151a6b7ae 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 @@ -650,4 +650,7 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { @override String pollSomeoneCreatedText(String username) => '$username opprettet'; + + @override + String get draftLabel => 'Utkast'; } 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 bddfc5713a..47e36cc866 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 @@ -663,4 +663,7 @@ Não é possível adicionar mais de $limit arquivos de uma vez @override String pollSomeoneCreatedText(String username) => '$username criou'; + + @override + String get draftLabel => 'Rascunho'; } diff --git a/packages/stream_chat_localizations/test/translations_test.dart b/packages/stream_chat_localizations/test/translations_test.dart index 732f219430..12a0658147 100644 --- a/packages/stream_chat_localizations/test/translations_test.dart +++ b/packages/stream_chat_localizations/test/translations_test.dart @@ -19,6 +19,7 @@ void main() { expect(localizations.userLastOnlineText, isNotNull); expect(localizations.userOnlineText, isNotNull); expect(localizations.userOnlineText, isNotNull); + expect(localizations.draftLabel, isNotNull); // no users expect(localizations.userTypingText([]), isNotNull); // single user diff --git a/packages/stream_chat_persistence/lib/src/dao/draft_message_dao.dart b/packages/stream_chat_persistence/lib/src/dao/draft_message_dao.dart index 90cc826de7..1c69c31658 100644 --- a/packages/stream_chat_persistence/lib/src/dao/draft_message_dao.dart +++ b/packages/stream_chat_persistence/lib/src/dao/draft_message_dao.dart @@ -1,3 +1,5 @@ +// ignore_for_file: join_return_with_assignment + import 'package:drift/drift.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat_persistence/src/db/drift_chat_database.dart'; @@ -43,33 +45,20 @@ class DraftMessageDao extends DatabaseAccessor ); } - /// Returns the draft message by matching [DraftMessages.id] with [id] - Future getDraftMessageById(String id) async { - final query = select(draftMessages)..where((tbl) => tbl.id.equals(id)); - - final result = await query.getSingleOrNull(); - if (result == null) return null; - - return _draftFromEntity(result); - } - /// Returns the draft message by matching [DraftMessages.channelCid]. /// /// Note: This will skip the thread draft messages. - Future getDraftMessageByCid(String cid) async { + Future getDraftMessageByCid( + String cid, { + String? parentId, + }) async { final query = select(draftMessages) - ..where((tbl) => tbl.channelCid.equals(cid) & tbl.parentId.isNull()); + ..where((tbl) { + var filter = tbl.channelCid.equals(cid); + filter &= tbl.parentId.equalsNullable(parentId); - final result = await query.getSingleOrNull(); - if (result == null) return null; - - return _draftFromEntity(result); - } - - /// Returns the draft message by matching [DraftMessages.parentId]. - Future getDraftMessageByParentId(String parentId) async { - final query = select(draftMessages) - ..where((tbl) => tbl.parentId.equals(parentId)); + return filter; + }); final result = await query.getSingleOrNull(); if (result == null) return null; @@ -86,24 +75,31 @@ class DraftMessageDao extends DatabaseAccessor // Find and delete existing drafts with the same channelCid // and parentId (if any). - final deleteQuery = delete(draftMessages) - ..where((tbl) { - var filter = tbl.channelCid.equals(entity.channelCid); - if (entity.parentId case final parentId?) { - filter &= tbl.parentId.equals(parentId); - } + await deleteDraftMessageByCid( + entity.channelCid, + parentId: entity.parentId, + ); - return filter; - }); - - await deleteQuery.go(); + // Insert the new draft message. await into(draftMessages).insertOnConflictUpdate(entity); } }); } - /// Deletes all the draft messages whose [DraftMessages.id] is present in - /// [messageIds]. - Future deleteDraftMessagesByIds(List messageIds) => - (delete(draftMessages)..where((tbl) => tbl.id.isIn(messageIds))).go(); + /// Deletes the draft message by matching [DraftMessages.channelCid] and + /// [DraftMessages.parentId]. + Future deleteDraftMessageByCid( + String cid, { + String? parentId, + }) { + final query = delete(draftMessages) + ..where((tbl) { + var filter = tbl.channelCid.equals(cid); + filter &= tbl.parentId.equalsNullable(parentId); + + return filter; + }); + + return query.go(); + } } diff --git a/packages/stream_chat_persistence/lib/src/dao/message_dao.dart b/packages/stream_chat_persistence/lib/src/dao/message_dao.dart index 13d58e4b4d..3d11267a6d 100644 --- a/packages/stream_chat_persistence/lib/src/dao/message_dao.dart +++ b/packages/stream_chat_persistence/lib/src/dao/message_dao.dart @@ -59,8 +59,11 @@ class MessageDao extends DatabaseAccessor _ => null, }; - final draft = await switch ((fetchDraft, msgEntity.draftMessageId)) { - (true, final id?) => _db.draftMessageDao.getDraftMessageById(id), + final draft = await switch (fetchDraft) { + true => _db.draftMessageDao.getDraftMessageByCid( + msgEntity.channelCid, + parentId: msgEntity.parentId, + ), _ => null, }; diff --git a/packages/stream_chat_persistence/lib/src/entity/draft_messages.dart b/packages/stream_chat_persistence/lib/src/entity/draft_messages.dart index f959e1fbda..0105aec3a1 100644 --- a/packages/stream_chat_persistence/lib/src/entity/draft_messages.dart +++ b/packages/stream_chat_persistence/lib/src/entity/draft_messages.dart @@ -54,5 +54,5 @@ class DraftMessages extends Table { TextColumn get extraData => text().nullable().map(MapConverter())(); @override - Set>? get primaryKey => {id}; + Set get primaryKey => {id}; } diff --git a/packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart b/packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart index 7f41421556..2ab392b601 100644 --- a/packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart +++ b/packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart @@ -171,13 +171,6 @@ class StreamChatPersistenceClient extends ChatPersistenceClient { return db!.pinnedMessageDao.deleteMessageByCids(cids); } - @override - Future deleteDraftMessagesByIds(List messageIds) { - assert(_debugIsConnected, ''); - _logger.info('deleteDraftMessagesByIds'); - return db!.draftMessageDao.deleteDraftMessagesByIds(messageIds); - } - @override Future> getMembersByCid(String cid) { assert(_debugIsConnected, ''); @@ -219,17 +212,16 @@ class StreamChatPersistenceClient extends ChatPersistenceClient { } @override - Future getDraftMessageByCid(String cid) { + Future getDraftMessageByCid( + String cid, { + String? parentId, + }) { assert(_debugIsConnected, ''); _logger.info('getDraftMessageByCid'); - return db!.draftMessageDao.getDraftMessageByCid(cid); - } - - @override - Future getDraftMessageByParentId(String parentId) { - assert(_debugIsConnected, ''); - _logger.info('getDraftMessageByParentId'); - return db!.draftMessageDao.getDraftMessageByParentId(parentId); + return db!.draftMessageDao.getDraftMessageByCid( + cid, + parentId: parentId, + ); } @override @@ -430,6 +422,19 @@ class StreamChatPersistenceClient extends ChatPersistenceClient { return db!.memberDao.deleteMemberByCids(cids); } + @override + Future deleteDraftMessageByCid( + String cid, { + String? parentId, + }) { + assert(_debugIsConnected, ''); + _logger.info('deleteDraftMessageByCid'); + return db!.draftMessageDao.deleteDraftMessageByCid( + cid, + parentId: parentId, + ); + } + @override Future updateChannelThreads( String cid, diff --git a/packages/stream_chat_persistence/test/src/dao/draft_message_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/draft_message_dao_test.dart index dff3cf0dd0..5ac947b871 100644 --- a/packages/stream_chat_persistence/test/src/dao/draft_message_dao_test.dart +++ b/packages/stream_chat_persistence/test/src/dao/draft_message_dao_test.dart @@ -113,31 +113,6 @@ void main() { return drafts; } - group('getDraftMessageById', () { - test('should return null for a non-existent draft message id', () async { - final draft = await draftMessageDao.getDraftMessageById( - 'non-existent-id', - ); - expect(draft, isNull); - }); - - test('should return the draft message for a valid id', () async { - const cid = 'test:getDraftById'; - final testDrafts = - await _prepareTestData(cid, count: 1); // Just create one draft - final testDraftId = testDrafts.first.message.id; - - final draft = await draftMessageDao.getDraftMessageById(testDraftId); - - expect(draft, isNotNull); - expect(draft!.message.id, testDraftId); - expect(draft.channelCid, cid); - expect(draft.message.text, 'Draft message #0'); - expect(draft.message.attachments, isNotEmpty); - expect(draft.message.mentionedUsers, isNotEmpty); - }); - }); - group('getDraftMessageByCid', () { test('should return null for a non-existent channel cid', () async { final draft = await draftMessageDao.getDraftMessageByCid( @@ -174,19 +149,26 @@ void main() { group('getDraftMessageByParentId', () { test('should return null for a non-existent parent id', () async { - final draft = await draftMessageDao.getDraftMessageByParentId( - 'non-existent-parent-id', + final draft = await draftMessageDao.getDraftMessageByCid( + 'non-existent-cid', + parentId: 'non-existent-parent-id', ); expect(draft, isNull); }); test('should return the draft message for a valid parent id', () async { const cid = 'test:getDraftByParentId'; - final testDrafts = - await _prepareTestData(cid, withParentMessage: true, count: 1); + final testDrafts = await _prepareTestData( + cid, + withParentMessage: true, + count: 1, + ); final parentId = testDrafts.first.parentId; - final draft = await draftMessageDao.getDraftMessageByParentId(parentId!); + final draft = await draftMessageDao.getDraftMessageByCid( + cid, + parentId: parentId, + ); expect(draft, isNotNull); expect(draft!.parentId, parentId); @@ -210,9 +192,7 @@ void main() { await database.channelDao.updateChannels([ChannelModel(cid: cid)]); await draftMessageDao.updateDraftMessages([draft]); - final fetchedDraft = await draftMessageDao.getDraftMessageById( - draft.message.id, - ); + final fetchedDraft = await draftMessageDao.getDraftMessageByCid(cid); expect(fetchedDraft, isNotNull); expect(fetchedDraft!.message.id, draft.message.id); expect(fetchedDraft.message.text, draft.message.text); @@ -233,9 +213,7 @@ void main() { await draftMessageDao.updateDraftMessages([updatedDraft]); - final fetchedDraft = await draftMessageDao.getDraftMessageById( - testDrafts.first.message.id, - ); + final fetchedDraft = await draftMessageDao.getDraftMessageByCid(cid); expect(fetchedDraft, isNotNull); expect(fetchedDraft!.message.text, 'Updated Draft message'); }); @@ -251,7 +229,6 @@ void main() { channelCid: cid, createdAt: DateTime.now(), message: DraftMessage( - id: 'firstDraftId', text: 'First channel draft', ), ); @@ -264,12 +241,11 @@ void main() { expect(firstFetchedDraft, isNotNull); expect(firstFetchedDraft!.message.text, 'First channel draft'); - // Create second channel draft with same channelCid but different ID + // Create second channel draft with same channelCid but different text final secondDraft = Draft( channelCid: cid, createdAt: DateTime.now(), message: DraftMessage( - id: 'secondDraftId', // Different ID text: 'Second channel draft', ), ); @@ -281,19 +257,18 @@ void main() { final secondFetchedDraft = await draftMessageDao.getDraftMessageByCid(cid); expect(secondFetchedDraft, isNotNull); - expect(secondFetchedDraft!.message.id, 'secondDraftId'); - expect(secondFetchedDraft.message.text, 'Second channel draft'); + expect(secondFetchedDraft!.message.text, 'Second channel draft'); // Verify the first draft no longer exists - final firstDraftAfterUpdate = await draftMessageDao.getDraftMessageById( - firstDraft.message.id, - ); - expect(firstDraftAfterUpdate, isNull); + final firstDraftAfterUpdate = + await draftMessageDao.getDraftMessageByCid(firstDraft.channelCid); + expect( + firstDraftAfterUpdate!.message.text, isNot('First channel draft')); // Verify there's only one draft message for this channel final channelDraft = await draftMessageDao.getDraftMessageByCid(cid); expect(channelDraft, isNotNull); - expect(channelDraft!.message.id, 'secondDraftId'); + expect(channelDraft!.message.text, 'Second channel draft'); }, ); @@ -305,7 +280,6 @@ void main() { // Create parent message first final user = User(id: 'testUserId'); final parentMessage = Message( - id: 'threadParent', user: user, createdAt: DateTime.now(), text: 'Parent message', @@ -321,7 +295,6 @@ void main() { createdAt: DateTime.now(), parentId: parentMessage.id, message: DraftMessage( - id: 'firstThreadDraftId', text: 'First thread draft', parentId: parentMessage.id, ), @@ -330,9 +303,8 @@ void main() { await draftMessageDao.updateDraftMessages([firstDraft]); // Verify first thread draft exists - final firstFetchedDraft = await draftMessageDao.getDraftMessageById( - firstDraft.message.id, - ); + final firstFetchedDraft = await draftMessageDao + .getDraftMessageByCid(cid, parentId: firstDraft.parentId); expect(firstFetchedDraft, isNotNull); expect(firstFetchedDraft!.message.text, 'First thread draft'); @@ -342,7 +314,6 @@ void main() { createdAt: DateTime.now(), parentId: parentMessage.id, message: DraftMessage( - id: 'secondThreadDraftId', // Different ID text: 'Second thread draft', parentId: parentMessage.id, ), @@ -352,25 +323,22 @@ void main() { await draftMessageDao.updateDraftMessages([secondDraft]); // Verify only the second draft exists - final secondFetchedDraft = await draftMessageDao.getDraftMessageById( - secondDraft.message.id, - ); + final secondFetchedDraft = await draftMessageDao + .getDraftMessageByCid(cid, parentId: secondDraft.parentId); expect(secondFetchedDraft, isNotNull); - expect(secondFetchedDraft!.message.id, 'secondThreadDraftId'); - expect(secondFetchedDraft.message.text, 'Second thread draft'); + expect(secondFetchedDraft!.message.text, 'Second thread draft'); // Verify the first draft no longer exists - final firstDraftAfterUpdate = await draftMessageDao.getDraftMessageById( - firstDraft.message.id, - ); - expect(firstDraftAfterUpdate, isNull); + final firstDraftAfterUpdate = await draftMessageDao + .getDraftMessageByCid(cid, parentId: firstDraft.parentId); + expect( + firstDraftAfterUpdate!.message.text, isNot('First thread draft')); // Verify there's only one draft message for this thread - final threadDraft = await draftMessageDao.getDraftMessageByParentId( - parentMessage.id, - ); + final threadDraft = await draftMessageDao.getDraftMessageByCid(cid, + parentId: parentMessage.id); expect(threadDraft, isNotNull); - expect(threadDraft!.message.id, 'secondThreadDraftId'); + expect(threadDraft!.message.text, 'Second thread draft'); }, ); }); @@ -381,19 +349,19 @@ void main() { () async { const cid = 'test:channelRefCascade'; // Just create one draft - final testDrafts = await _prepareTestData(cid, count: 1); + await _prepareTestData(cid, count: 1); // Verify draft exists - final draftBeforeChannelDelete = await draftMessageDao - .getDraftMessageById(testDrafts.first.message.id); + final draftBeforeChannelDelete = + await draftMessageDao.getDraftMessageByCid(cid); expect(draftBeforeChannelDelete, isNotNull); // Delete the channel await database.channelDao.deleteChannelByCids([cid]); // Verify draft has been deleted (cascade) - final draftAfterChannelDelete = await draftMessageDao - .getDraftMessageById(testDrafts.first.message.id); + final draftAfterChannelDelete = + await draftMessageDao.getDraftMessageByCid(cid); expect(draftAfterChannelDelete, isNull); }, ); @@ -452,13 +420,13 @@ void main() { // Verify drafts exist before channel deletion final channelDraftBeforeDelete = - await draftMessageDao.getDraftMessageById(channelDraft.message.id); + await draftMessageDao.getDraftMessageByCid(cid); expect(channelDraftBeforeDelete, isNotNull); expect(channelDraftBeforeDelete!.parentId, isNull); for (var i = 0; i < threadDrafts.length; i++) { - final threadDraft = await draftMessageDao - .getDraftMessageById(threadDrafts[i].message.id); + final threadDraft = await draftMessageDao.getDraftMessageByCid(cid, + parentId: threadDrafts[i].parentId); expect(threadDraft, isNotNull); expect(threadDraft!.parentId, messages[i].id); } @@ -468,12 +436,12 @@ void main() { // Verify all drafts have been deleted (cascade) final channelDraftAfterDelete = - await draftMessageDao.getDraftMessageById(channelDraft.message.id); + await draftMessageDao.getDraftMessageByCid(cid); expect(channelDraftAfterDelete, isNull); for (final threadDraft in threadDrafts) { - final draft = - await draftMessageDao.getDraftMessageById(threadDraft.message.id); + final draft = await draftMessageDao.getDraftMessageByCid(cid, + parentId: threadDraft.parentId); expect(draft, isNull); } }, @@ -489,7 +457,7 @@ void main() { // Verify draft with parent exists final draftBeforeMessageDelete = - await draftMessageDao.getDraftMessageByParentId(parentId); + await draftMessageDao.getDraftMessageByCid(cid, parentId: parentId); expect(draftBeforeMessageDelete, isNotNull); // Delete the parent message @@ -497,90 +465,9 @@ void main() { // Verify draft has been deleted (cascade) final draftAfterMessageDelete = - await draftMessageDao.getDraftMessageByParentId(parentId); + await draftMessageDao.getDraftMessageByCid(cid, parentId: parentId); expect(draftAfterMessageDelete, isNull); }, ); }); - - group('deleteDraftMessagesByIds', () { - test( - 'should delete multiple draft messages by their IDs', - () async { - // Create drafts with unique channelCids to avoid conflicts - const baseCid = 'test:deleteDraftsByIds'; - final drafts = List.generate( - 3, - (index) => Draft( - channelCid: '$baseCid$index', - createdAt: DateTime.now(), - message: DraftMessage( - id: 'draftToDelete$index', - text: 'Draft message $index', - ), - ), - ); - - // Create channels for each draft - await database.channelDao.updateChannels([ - for (final draft in drafts) ChannelModel(cid: draft.channelCid), - ]); - - // Insert all drafts - await draftMessageDao.updateDraftMessages(drafts); - - // Get IDs to delete (first and third draft) - final idsToDelete = [ - drafts.first.message.id, - drafts.last.message.id, - ]; - final idToKeep = drafts[1].message.id; - - // Verify all drafts exist before deletion - for (final draft in drafts) { - final fetchedDraft = await draftMessageDao.getDraftMessageById( - draft.message.id, - ); - expect(fetchedDraft, isNotNull); - } - - // Delete two out of three drafts - await draftMessageDao.deleteDraftMessagesByIds(idsToDelete); - - // Verify deleted drafts don't exist anymore - for (final id in idsToDelete) { - final fetchedDraft = await draftMessageDao.getDraftMessageById(id); - expect(fetchedDraft, isNull); - } - - // Verify the remaining draft still exists - final remainingDraft = - await draftMessageDao.getDraftMessageById(idToKeep); - expect(remainingDraft, isNotNull); - expect(remainingDraft!.message.id, idToKeep); - }, - ); - - test( - 'should not fail when trying to delete non-existent draft IDs', - () async { - // Should not throw any exception - await expectLater( - draftMessageDao.deleteDraftMessagesByIds(['non-existent-id']), - completes, - ); - }, - ); - - test( - 'should handle empty list of IDs gracefully', - () async { - // Should not throw any exception - await expectLater( - draftMessageDao.deleteDraftMessagesByIds([]), - completes, - ); - }, - ); - }); } diff --git a/packages/stream_chat_persistence/test/stream_chat_persistence_client_test.dart b/packages/stream_chat_persistence/test/stream_chat_persistence_client_test.dart index 24ed7eabf4..0a8cfc6783 100644 --- a/packages/stream_chat_persistence/test/stream_chat_persistence_client_test.dart +++ b/packages/stream_chat_persistence/test/stream_chat_persistence_client_test.dart @@ -631,11 +631,12 @@ void main() { verify(() => mockDatabase.memberDao.deleteMemberByCids(cids)).called(1); }); - // Draft message tests test('getDraftMessageByCid', () async { const cid = 'testCid'; + const parentId = 'testParentId'; final draft = Draft( channelCid: cid, + parentId: parentId, createdAt: DateTime.now(), message: DraftMessage( id: 'testDraftId', @@ -655,33 +656,6 @@ void main() { .called(1); }); - test('getDraftMessageByParentId', () async { - const parentId = 'testParentId'; - final draft = Draft( - channelCid: 'testCid', - createdAt: DateTime.now(), - parentId: parentId, - message: DraftMessage( - id: 'testDraftId', - text: 'Test thread draft message', - parentId: parentId, - ), - ); - - when(() => - mockDatabase.draftMessageDao.getDraftMessageByParentId(parentId)) - .thenAnswer((_) async => draft); - - final fetchedDraft = await client.getDraftMessageByParentId(parentId); - expect(fetchedDraft, isNotNull); - expect(fetchedDraft!.parentId, parentId); - expect(fetchedDraft.message.id, draft.message.id); - expect(fetchedDraft.message.text, draft.message.text); - verify(() => - mockDatabase.draftMessageDao.getDraftMessageByParentId(parentId)) - .called(1); - }); - test('updateDraftMessages', () async { final drafts = List.generate( 3, @@ -703,19 +677,17 @@ void main() { .called(1); }); - test('deleteDraftMessagesByIds', () async { - final messageIds = ['testDraftId1', 'testDraftId2', 'testDraftId3']; + test('deleteDraftMessageByCid', () async { + const cid = 'testCid'; + const parentId = 'testParentId'; - when(() => - mockDatabase.draftMessageDao.deleteDraftMessagesByIds(messageIds)) - .thenAnswer((_) async {}); + when(() => mockDatabase.draftMessageDao.deleteDraftMessageByCid(cid, + parentId: parentId)).thenAnswer((_) async {}); - await client.deleteDraftMessagesByIds(messageIds); - verify(() => - mockDatabase.draftMessageDao.deleteDraftMessagesByIds(messageIds)) - .called(1); + await client.deleteDraftMessageByCid(cid, parentId: parentId); + verify(() => mockDatabase.draftMessageDao + .deleteDraftMessageByCid(cid, parentId: parentId)).called(1); }); - // End of draft message tests tearDown(() async { await client.disconnect(flush: true); diff --git a/sample_app/lib/app.dart b/sample_app/lib/app.dart index dd07ff7a23..5e72b30825 100644 --- a/sample_app/lib/app.dart +++ b/sample_app/lib/app.dart @@ -506,6 +506,9 @@ class _StreamChatSampleAppState extends State ], builder: (context, child) => StreamChat( client: _initNotifier.initData!.client, + streamChatConfigData: StreamChatConfigurationData( + draftMessagesEnabled: true, + ), child: child, ), routerConfig: _setupRouter(), diff --git a/sample_app/lib/pages/channel_list_page.dart b/sample_app/lib/pages/channel_list_page.dart index 358e204bdb..428ffdcef0 100644 --- a/sample_app/lib/pages/channel_list_page.dart +++ b/sample_app/lib/pages/channel_list_page.dart @@ -9,6 +9,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import 'package:sample_app/app.dart'; +import 'package:sample_app/pages/draft_list_page.dart'; import 'package:sample_app/pages/thread_list_page.dart'; import 'package:sample_app/pages/user_mentions_page.dart'; import 'package:sample_app/routes/routes.dart'; @@ -82,6 +83,15 @@ class _ChannelListPageState extends State { ), label: 'Threads', ), + BottomNavigationBarItem( + icon: Icon( + Icons.edit_note_rounded, + color: _isSelected(3) + ? StreamChatTheme.of(context).colorTheme.textHighEmphasis + : Colors.grey, + ), + label: 'Drafts', + ), ]; } @@ -125,6 +135,7 @@ class _ChannelListPageState extends State { ChannelList(), UserMentionsPage(), ThreadListPage(), + DraftListPage(), ], ), ); diff --git a/sample_app/lib/pages/draft_list_page.dart b/sample_app/lib/pages/draft_list_page.dart new file mode 100644 index 0000000000..ffdda5fec7 --- /dev/null +++ b/sample_app/lib/pages/draft_list_page.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; +import 'package:sample_app/pages/channel_page.dart'; +import 'package:sample_app/pages/thread_page.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +class DraftListPage extends StatefulWidget { + const DraftListPage({super.key}); + + @override + State createState() => _DraftListPageState(); +} + +class _DraftListPageState extends State { + late final controller = StreamDraftListController( + client: StreamChat.of(context).client, + ); + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return RefreshIndicator( + onRefresh: controller.refresh, + child: StreamDraftListView( + controller: controller, + itemBuilder: (context, drafts, index, defaultWidget) { + final draft = drafts[index]; + + return Slidable( + groupTag: 'draft-actions', + endActionPane: ActionPane( + extentRatio: 0.20, + motion: const BehindMotion(), + children: [ + CustomSlidableAction( + backgroundColor: Colors.red, + child: const StreamSvgIcon( + size: 24, + icon: StreamSvgIcons.delete, + color: Colors.white, + ), + onPressed: (context) { + final client = StreamChat.of(context).client; + final [type, id] = draft.channelCid.split(':'); + final parentId = draft.parentId; + + client.deleteDraft(id, type, parentId: parentId).ignore(); + }, + ), + ], + ), + child: defaultWidget, + ); + }, + onDraftTap: (draft) { + final client = StreamChat.of(context).client; + + final [channelType, channelId] = draft.channelCid.split(':'); + final channel = client.channel(channelType, id: channelId); + + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) { + return StreamChannel( + channel: channel, + initialMessageId: draft.parentId, + child: switch (draft.parentMessage) { + final parent? => ThreadPage( + parent: parent.copyWith(draft: draft), + ), + _ => const ChannelPage(), + }, + ); + }, + ), + ); + }, + ), + ); + } +} diff --git a/sample_app/lib/pages/thread_list_page.dart b/sample_app/lib/pages/thread_list_page.dart index a2e10ce1b9..4e8c371ca4 100644 --- a/sample_app/lib/pages/thread_list_page.dart +++ b/sample_app/lib/pages/thread_list_page.dart @@ -49,6 +49,7 @@ class _ThreadListPageState extends State { builder: (context) { return StreamChannel( channel: channel, + initialMessageId: thread.draft?.parentId, child: ThreadPage(parent: thread.parentMessage!), ); },