From 4182ad35011526870d933da64bfbe729f9e0e940 Mon Sep 17 00:00:00 2001 From: Salvatore Giordano Date: Thu, 17 Jun 2021 13:04:53 +0200 Subject: [PATCH 1/3] add thread parent indicators --- packages/stream_chat/lib/src/api/channel.dart | 25 ++--- .../lib/src/channel_info.dart | 5 + .../lib/src/channel_preview.dart | 95 ++++++++++--------- .../lib/src/message_input.dart | 5 +- .../lib/src/thread_header.dart | 53 +++++++---- .../lib/src/typing_indicator.dart | 23 ++--- 6 files changed, 118 insertions(+), 88 deletions(-) diff --git a/packages/stream_chat/lib/src/api/channel.dart b/packages/stream_chat/lib/src/api/channel.dart index aeda98e7..286a7e07 100644 --- a/packages/stream_chat/lib/src/api/channel.dart +++ b/packages/stream_chat/lib/src/api/channel.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'dart:math'; import 'package:collection/collection.dart' - show IterableExtension, ListEquality; + show IterableExtension, ListEquality, MapEquality; import 'package:dio/dio.dart'; import 'package:logging/logging.dart'; import 'package:rxdart/rxdart.dart'; @@ -1848,17 +1848,17 @@ class ChannelClientState { } /// Channel related typing users last value - List get typingEvents => _typingEventsController.value; + Map get typingEvents => _typingEventsController.value; /// Channel related typing users stream - Stream> get typingEventsStream => - _typingEventsController.stream.distinct(const ListEquality().equals); + Stream> get typingEventsStream => + _typingEventsController.stream; - final BehaviorSubject> _typingEventsController = - BehaviorSubject.seeded([]); + final BehaviorSubject> _typingEventsController = + BehaviorSubject.seeded({}); final Channel _channel; - final Map _typings = {}; + final Map _typings = {}; void _listenTypingEvents() { if (_channelState.channel?.config.typingEvents == false) { @@ -1872,8 +1872,8 @@ class ChannelClientState { if (event.user != null) { final user = event.user!; if (user.id != _channel.client.state.user?.id) { - _typings[user] = DateTime.now(); - _typingEventsController.add(_typings.keys.toList()); + _typings[user] = event; + _typingEventsController.add(_typings); } } }, @@ -1886,7 +1886,7 @@ class ChannelClientState { final user = event.user!; if (user.id != _channel.client.state.user?.id) { _typings.remove(event.user); - _typingEventsController.add(_typings.keys.toList()); + _typingEventsController.add(_typings); } } }, @@ -1964,13 +1964,14 @@ class ChannelClientState { void _clean() { final now = DateTime.now(); - _typings.forEach((user, lastTypingEvent) { - if (now.difference(lastTypingEvent).inSeconds > 7) { + _typings.forEach((user, event) { + if (now.difference(event.createdAt!).inSeconds > 7) { _channel.client.handleEvent( Event( type: EventType.typingStop, user: user, cid: _channel.cid, + parentId: event.parentId, ), ); } diff --git a/packages/stream_chat_flutter/lib/src/channel_info.dart b/packages/stream_chat_flutter/lib/src/channel_info.dart index 32dbeb67..a17260ec 100644 --- a/packages/stream_chat_flutter/lib/src/channel_info.dart +++ b/packages/stream_chat_flutter/lib/src/channel_info.dart @@ -11,6 +11,7 @@ class ChannelInfo extends StatelessWidget { required this.channel, this.textStyle, this.showTypingIndicator = true, + this.parentId, }) : super(key: key); /// The channel about which the info is to be displayed @@ -22,6 +23,9 @@ class ChannelInfo extends StatelessWidget { /// If true the typing indicator will be rendered if a user is typing final bool showTypingIndicator; + /// Id of the parent message in case of a thread + final String? parentId; + @override Widget build(BuildContext context) { final client = StreamChat.of(context).client; @@ -88,6 +92,7 @@ class ChannelInfo extends StatelessWidget { } return TypingIndicator( + parentId: parentId, alignment: Alignment.center, alternativeWidget: alternativeWidget, style: textStyle, diff --git a/packages/stream_chat_flutter/lib/src/channel_preview.dart b/packages/stream_chat_flutter/lib/src/channel_preview.dart index d9170eb8..231b5f94 100644 --- a/packages/stream_chat_flutter/lib/src/channel_preview.dart +++ b/packages/stream_chat_flutter/lib/src/channel_preview.dart @@ -221,59 +221,62 @@ class ChannelPreview extends StatelessWidget { ); } - Widget _buildLastMessage(BuildContext context) => - BetterStreamBuilder?>( - stream: channel.state!.messagesStream, - initialData: channel.state!.messages, - builder: (context, data) { - final lastMessage = - data?.lastWhereOrNull((m) => m.shadowed != true && !m.isDeleted); - if (lastMessage == null) { - return const SizedBox(); - } + Widget _buildLastMessage(BuildContext context) => Align( + alignment: Alignment.centerLeft, + child: BetterStreamBuilder?>( + stream: channel.state!.messagesStream, + initialData: channel.state!.messages, + builder: (context, data) { + final lastMessage = data + ?.lastWhereOrNull((m) => m.shadowed != true && !m.isDeleted); + if (lastMessage == null) { + return const SizedBox(); + } - var text = lastMessage.text; - final parts = [ - ...lastMessage.attachments.map((e) { - if (e.type == 'image') { - return '📷'; - } else if (e.type == 'video') { - return '🎬'; - } else if (e.type == 'giphy') { - return '[GIF]'; - } - return e == lastMessage.attachments.last - ? (e.title ?? 'File') - : '${e.title ?? 'File'} , '; - }), - lastMessage.text ?? '', - ]; + var text = lastMessage.text; + final parts = [ + ...lastMessage.attachments.map((e) { + if (e.type == 'image') { + return '📷'; + } else if (e.type == 'video') { + return '🎬'; + } else if (e.type == 'giphy') { + return '[GIF]'; + } + return e == lastMessage.attachments.last + ? (e.title ?? 'File') + : '${e.title ?? 'File'} , '; + }), + lastMessage.text ?? '', + ]; - text = parts.join(' '); + text = parts.join(' '); - final chatThemeData = StreamChatTheme.of(context); - return Text.rich( - _getDisplayText( - text, - lastMessage.mentionedUsers, - lastMessage.attachments, - chatThemeData.channelPreviewTheme.subtitle?.copyWith( + final chatThemeData = StreamChatTheme.of(context); + return Text.rich( + _getDisplayText( + text, + lastMessage.mentionedUsers, + lastMessage.attachments, + chatThemeData.channelPreviewTheme.subtitle?.copyWith( + color: chatThemeData.channelPreviewTheme.subtitle?.color, + fontStyle: (lastMessage.isSystem || lastMessage.isDeleted) + ? FontStyle.italic + : FontStyle.normal), + chatThemeData.channelPreviewTheme.subtitle?.copyWith( color: chatThemeData.channelPreviewTheme.subtitle?.color, fontStyle: (lastMessage.isSystem || lastMessage.isDeleted) ? FontStyle.italic - : FontStyle.normal), - chatThemeData.channelPreviewTheme.subtitle?.copyWith( - color: chatThemeData.channelPreviewTheme.subtitle?.color, - fontStyle: (lastMessage.isSystem || lastMessage.isDeleted) - ? FontStyle.italic - : FontStyle.normal, - fontWeight: FontWeight.bold, + : FontStyle.normal, + fontWeight: FontWeight.bold, + ), ), - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ); - }, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.start, + ); + }, + ), ); TextSpan _getDisplayText( diff --git a/packages/stream_chat_flutter/lib/src/message_input.dart b/packages/stream_chat_flutter/lib/src/message_input.dart index 7e62f1c3..4f61c9fa 100644 --- a/packages/stream_chat_flutter/lib/src/message_input.dart +++ b/packages/stream_chat_flutter/lib/src/message_input.dart @@ -700,7 +700,10 @@ class MessageInputState extends State { if (!mounted) { return; } - StreamChannel.of(context).channel.keyStroke().catchError((e) {}); + StreamChannel.of(context) + .channel + .keyStroke(widget.parentMessage?.id) + .catchError((e) {}); setState(() { _messageIsPresent = s.trim().isNotEmpty; diff --git a/packages/stream_chat_flutter/lib/src/thread_header.dart b/packages/stream_chat_flutter/lib/src/thread_header.dart index d16aa530..bfde9a4c 100644 --- a/packages/stream_chat_flutter/lib/src/thread_header.dart +++ b/packages/stream_chat_flutter/lib/src/thread_header.dart @@ -68,6 +68,7 @@ class ThreadHeader extends StatelessWidget implements PreferredSizeWidget { this.leading, this.actions, this.onTitleTap, + this.showTypingIndicator = true, }) : preferredSize = const Size.fromHeight(kToolbarHeight), super(key: key); @@ -96,9 +97,32 @@ class ThreadHeader extends StatelessWidget implements PreferredSizeWidget { /// AppBar actions final List? actions; + /// If true the typing indicator will be rendered + /// if a user is typing in this thread + final bool showTypingIndicator; + @override Widget build(BuildContext context) { final chatThemeData = StreamChatTheme.of(context); + + final defaultSubtitle = subtitle ?? + Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'with ', + style: chatThemeData.channelTheme.channelHeaderTheme.subtitle, + ), + Flexible( + child: ChannelName( + textStyle: + chatThemeData.channelTheme.channelHeaderTheme.subtitle, + ), + ), + ], + ); + return AppBar( automaticallyImplyLeading: false, textTheme: Theme.of(context).textTheme, @@ -119,6 +143,7 @@ class ThreadHeader extends StatelessWidget implements PreferredSizeWidget { onTap: onTitleTap, child: SizedBox( height: preferredSize.height, + width: 250, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -128,24 +153,16 @@ class ThreadHeader extends StatelessWidget implements PreferredSizeWidget { style: chatThemeData.channelTheme.channelHeaderTheme.title, ), const SizedBox(height: 2), - subtitle ?? - Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'with ', - style: chatThemeData - .channelTheme.channelHeaderTheme.subtitle, - ), - Flexible( - child: ChannelName( - textStyle: chatThemeData - .channelTheme.channelHeaderTheme.subtitle, - ), - ), - ], - ), + if (showTypingIndicator) + TypingIndicator( + alignment: Alignment.center, + channel: StreamChannel.of(context).channel, + style: chatThemeData.channelTheme.channelHeaderTheme.subtitle, + parentId: parent.id, + alternativeWidget: defaultSubtitle, + ) + else + defaultSubtitle, ], ), ), diff --git a/packages/stream_chat_flutter/lib/src/typing_indicator.dart b/packages/stream_chat_flutter/lib/src/typing_indicator.dart index 100cdb53..4c0ad19f 100644 --- a/packages/stream_chat_flutter/lib/src/typing_indicator.dart +++ b/packages/stream_chat_flutter/lib/src/typing_indicator.dart @@ -12,6 +12,7 @@ class TypingIndicator extends StatelessWidget { this.style, this.alignment = Alignment.centerLeft, this.padding = const EdgeInsets.all(0), + this.parentId, }) : super(key: key); /// Style of the text widget @@ -29,21 +30,21 @@ class TypingIndicator extends StatelessWidget { /// Alignment of the typing indicator final Alignment alignment; + /// Id of the parent message in case of a thread + final String? parentId; + @override Widget build(BuildContext context) { final channelState = channel?.state ?? StreamChannel.of(context).channel.state!; - final altWidget = Align( - key: const Key('alternative'), - alignment: alignment, - child: Container( - child: alternativeWidget ?? const Offstage(), - ), - ); - return BetterStreamBuilder>( - initialData: channelState.typingEvents, - stream: channelState.typingEventsStream, + final altWidget = alternativeWidget ?? const Offstage(); + + return BetterStreamBuilder>( + initialData: channelState.typingEvents.keys, + stream: channelState.typingEventsStream.map((typings) => typings.entries + .where((element) => element.value.parentId == parentId) + .map((e) => e.key)), builder: (context, data) => AnimatedSwitcher( duration: const Duration(milliseconds: 300), child: data.isNotEmpty == true @@ -63,7 +64,7 @@ class TypingIndicator extends StatelessWidget { ), Text( // ignore: lines_longer_than_80_chars - ' ${data[0].name}${data.length == 1 ? '' : ' and ${data.length - 1} more'} ${data.length == 1 ? 'is' : 'are'} typing', + ' ${data.elementAt(0).name}${data.length == 1 ? '' : ' and ${data.length - 1} more'} ${data.length == 1 ? 'is' : 'are'} typing', maxLines: 1, style: style, ), From 5cd9a3be5359b3e65a04c37beac613792ce23d63 Mon Sep 17 00:00:00 2001 From: Salvatore Giordano Date: Thu, 17 Jun 2021 13:15:01 +0200 Subject: [PATCH 2/3] fix analysis --- packages/stream_chat/lib/src/api/channel.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stream_chat/lib/src/api/channel.dart b/packages/stream_chat/lib/src/api/channel.dart index 286a7e07..b2e33084 100644 --- a/packages/stream_chat/lib/src/api/channel.dart +++ b/packages/stream_chat/lib/src/api/channel.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'dart:math'; import 'package:collection/collection.dart' - show IterableExtension, ListEquality, MapEquality; + show IterableExtension, ListEquality; import 'package:dio/dio.dart'; import 'package:logging/logging.dart'; import 'package:rxdart/rxdart.dart'; From c37faddd2ee28db16f3195565b955258be226cf7 Mon Sep 17 00:00:00 2001 From: Salvatore Giordano Date: Thu, 17 Jun 2021 14:03:35 +0200 Subject: [PATCH 3/3] fix tests --- .../test/src/channel_preview_test.dart | 4 ---- .../test/src/full_screen_media_test.dart | 16 ++++++++-------- packages/stream_chat_flutter/test/src/mocks.dart | 4 ++-- .../test/src/typing_indicator_test.dart | 15 ++++++++------- 4 files changed, 18 insertions(+), 21 deletions(-) diff --git a/packages/stream_chat_flutter/test/src/channel_preview_test.dart b/packages/stream_chat_flutter/test/src/channel_preview_test.dart index b913489e..baa82614 100644 --- a/packages/stream_chat_flutter/test/src/channel_preview_test.dart +++ b/packages/stream_chat_flutter/test/src/channel_preview_test.dart @@ -60,10 +60,6 @@ void main() { ) ])); - when(() => channelState.typingEvents).thenReturn([]); - when(() => channelState.typingEventsStream) - .thenAnswer((_) => Stream.value([])); - await tester.pumpWidget(MaterialApp( home: StreamChat( client: client, diff --git a/packages/stream_chat_flutter/test/src/full_screen_media_test.dart b/packages/stream_chat_flutter/test/src/full_screen_media_test.dart index 81c342eb..d418c303 100644 --- a/packages/stream_chat_flutter/test/src/full_screen_media_test.dart +++ b/packages/stream_chat_flutter/test/src/full_screen_media_test.dart @@ -53,15 +53,15 @@ void main() { user: User(id: 'other-user'), ) ])); - - when(() => channelState.typingEvents).thenAnswer((i) => [ - User(id: 'other-user', extraData: {'name': 'demo'}) - ]); + when(() => channelState.typingEvents).thenAnswer((i) => { + User(id: 'other-user', extraData: {'name': 'demo'}): + const Event(type: EventType.typingStart), + }); when(() => channelState.typingEventsStream) - .thenAnswer((i) => Stream.value([ - User(id: 'other-user', extraData: {'name': 'demo'}), - User(id: 'other-user', extraData: {'name': 'demo'}), - ])); + .thenAnswer((i) => Stream.value({ + User(id: 'other-user', extraData: {'name': 'demo'}): + const Event(type: EventType.typingStart), + })); await tester.pumpWidget(MaterialApp( home: StreamChat( diff --git a/packages/stream_chat_flutter/test/src/mocks.dart b/packages/stream_chat_flutter/test/src/mocks.dart index d2b04e11..28e00e4b 100644 --- a/packages/stream_chat_flutter/test/src/mocks.dart +++ b/packages/stream_chat_flutter/test/src/mocks.dart @@ -23,8 +23,8 @@ class MockChannel extends Mock implements Channel { class MockChannelState extends Mock implements ChannelClientState { MockChannelState() { - when(() => typingEvents).thenReturn([]); - when(() => typingEventsStream).thenAnswer((_) => Stream.value([])); + when(() => typingEvents).thenReturn({}); + when(() => typingEventsStream).thenAnswer((_) => Stream.value({})); } } diff --git a/packages/stream_chat_flutter/test/src/typing_indicator_test.dart b/packages/stream_chat_flutter/test/src/typing_indicator_test.dart index cc9af663..75431f44 100644 --- a/packages/stream_chat_flutter/test/src/typing_indicator_test.dart +++ b/packages/stream_chat_flutter/test/src/typing_indicator_test.dart @@ -53,14 +53,15 @@ void main() { ) ])); - when(() => channelState.typingEvents).thenAnswer((i) => [ - User(id: 'other-user', extraData: {'name': 'demo'}) - ]); + when(() => channelState.typingEvents).thenAnswer((i) => { + User(id: 'other-user', extraData: {'name': 'demo'}): + const Event(type: EventType.typingStart), + }); when(() => channelState.typingEventsStream) - .thenAnswer((i) => Stream.value([ - User(id: 'other-user', extraData: {'name': 'demo'}), - User(id: 'other-user', extraData: {'name': 'demo'}), - ])); + .thenAnswer((i) => Stream.value({ + User(id: 'other-user', extraData: {'name': 'demo'}): + const Event(type: EventType.typingStart), + })); await tester.pumpWidget(MaterialApp( home: StreamChat(