Merge pull request #479 from GetStream/feat/threadTypingIndicators

feat: thread typing indicators
This commit is contained in:
Salvatore Giordano
2021-06-18 10:18:29 +02:00
committed by GitHub
10 changed files with 135 additions and 108 deletions
+12 -11
View File
@@ -1848,17 +1848,17 @@ class ChannelClientState {
} }
/// Channel related typing users last value /// Channel related typing users last value
List<User> get typingEvents => _typingEventsController.value; Map<User, Event> get typingEvents => _typingEventsController.value;
/// Channel related typing users stream /// Channel related typing users stream
Stream<List<User>> get typingEventsStream => Stream<Map<User, Event>> get typingEventsStream =>
_typingEventsController.stream.distinct(const ListEquality().equals); _typingEventsController.stream;
final BehaviorSubject<List<User>> _typingEventsController = final BehaviorSubject<Map<User, Event>> _typingEventsController =
BehaviorSubject.seeded([]); BehaviorSubject.seeded({});
final Channel _channel; final Channel _channel;
final Map<User, DateTime> _typings = {}; final Map<User, Event> _typings = {};
void _listenTypingEvents() { void _listenTypingEvents() {
if (_channelState.channel?.config.typingEvents == false) { if (_channelState.channel?.config.typingEvents == false) {
@@ -1872,8 +1872,8 @@ class ChannelClientState {
if (event.user != null) { if (event.user != null) {
final user = event.user!; final user = event.user!;
if (user.id != _channel.client.state.user?.id) { if (user.id != _channel.client.state.user?.id) {
_typings[user] = DateTime.now(); _typings[user] = event;
_typingEventsController.add(_typings.keys.toList()); _typingEventsController.add(_typings);
} }
} }
}, },
@@ -1886,7 +1886,7 @@ class ChannelClientState {
final user = event.user!; final user = event.user!;
if (user.id != _channel.client.state.user?.id) { if (user.id != _channel.client.state.user?.id) {
_typings.remove(event.user); _typings.remove(event.user);
_typingEventsController.add(_typings.keys.toList()); _typingEventsController.add(_typings);
} }
} }
}, },
@@ -1964,13 +1964,14 @@ class ChannelClientState {
void _clean() { void _clean() {
final now = DateTime.now(); final now = DateTime.now();
_typings.forEach((user, lastTypingEvent) { _typings.forEach((user, event) {
if (now.difference(lastTypingEvent).inSeconds > 7) { if (now.difference(event.createdAt!).inSeconds > 7) {
_channel.client.handleEvent( _channel.client.handleEvent(
Event( Event(
type: EventType.typingStop, type: EventType.typingStop,
user: user, user: user,
cid: _channel.cid, cid: _channel.cid,
parentId: event.parentId,
), ),
); );
} }
@@ -11,6 +11,7 @@ class ChannelInfo extends StatelessWidget {
required this.channel, required this.channel,
this.textStyle, this.textStyle,
this.showTypingIndicator = true, this.showTypingIndicator = true,
this.parentId,
}) : super(key: key); }) : super(key: key);
/// The channel about which the info is to be displayed /// 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 /// If true the typing indicator will be rendered if a user is typing
final bool showTypingIndicator; final bool showTypingIndicator;
/// Id of the parent message in case of a thread
final String? parentId;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final client = StreamChat.of(context).client; final client = StreamChat.of(context).client;
@@ -88,6 +92,7 @@ class ChannelInfo extends StatelessWidget {
} }
return TypingIndicator( return TypingIndicator(
parentId: parentId,
alignment: Alignment.center, alignment: Alignment.center,
alternativeWidget: alternativeWidget, alternativeWidget: alternativeWidget,
style: textStyle, style: textStyle,
@@ -221,59 +221,62 @@ class ChannelPreview extends StatelessWidget {
); );
} }
Widget _buildLastMessage(BuildContext context) => Widget _buildLastMessage(BuildContext context) => Align(
BetterStreamBuilder<List<Message>?>( alignment: Alignment.centerLeft,
stream: channel.state!.messagesStream, child: BetterStreamBuilder<List<Message>?>(
initialData: channel.state!.messages, stream: channel.state!.messagesStream,
builder: (context, data) { initialData: channel.state!.messages,
final lastMessage = builder: (context, data) {
data?.lastWhereOrNull((m) => m.shadowed != true && !m.isDeleted); final lastMessage = data
if (lastMessage == null) { ?.lastWhereOrNull((m) => m.shadowed != true && !m.isDeleted);
return const SizedBox(); if (lastMessage == null) {
} return const SizedBox();
}
var text = lastMessage.text; var text = lastMessage.text;
final parts = <String>[ final parts = <String>[
...lastMessage.attachments.map((e) { ...lastMessage.attachments.map((e) {
if (e.type == 'image') { if (e.type == 'image') {
return '📷'; return '📷';
} else if (e.type == 'video') { } else if (e.type == 'video') {
return '🎬'; return '🎬';
} else if (e.type == 'giphy') { } else if (e.type == 'giphy') {
return '[GIF]'; return '[GIF]';
} }
return e == lastMessage.attachments.last return e == lastMessage.attachments.last
? (e.title ?? 'File') ? (e.title ?? 'File')
: '${e.title ?? 'File'} , '; : '${e.title ?? 'File'} , ';
}), }),
lastMessage.text ?? '', lastMessage.text ?? '',
]; ];
text = parts.join(' '); text = parts.join(' ');
final chatThemeData = StreamChatTheme.of(context); final chatThemeData = StreamChatTheme.of(context);
return Text.rich( return Text.rich(
_getDisplayText( _getDisplayText(
text, text,
lastMessage.mentionedUsers, lastMessage.mentionedUsers,
lastMessage.attachments, lastMessage.attachments,
chatThemeData.channelPreviewTheme.subtitle?.copyWith( 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, color: chatThemeData.channelPreviewTheme.subtitle?.color,
fontStyle: (lastMessage.isSystem || lastMessage.isDeleted) fontStyle: (lastMessage.isSystem || lastMessage.isDeleted)
? FontStyle.italic ? FontStyle.italic
: FontStyle.normal), : FontStyle.normal,
chatThemeData.channelPreviewTheme.subtitle?.copyWith( fontWeight: FontWeight.bold,
color: chatThemeData.channelPreviewTheme.subtitle?.color, ),
fontStyle: (lastMessage.isSystem || lastMessage.isDeleted)
? FontStyle.italic
: FontStyle.normal,
fontWeight: FontWeight.bold,
), ),
), maxLines: 1,
maxLines: 1, overflow: TextOverflow.ellipsis,
overflow: TextOverflow.ellipsis, textAlign: TextAlign.start,
); );
}, },
),
); );
TextSpan _getDisplayText( TextSpan _getDisplayText(
@@ -700,7 +700,10 @@ class MessageInputState extends State<MessageInput> {
if (!mounted) { if (!mounted) {
return; return;
} }
StreamChannel.of(context).channel.keyStroke().catchError((e) {}); StreamChannel.of(context)
.channel
.keyStroke(widget.parentMessage?.id)
.catchError((e) {});
setState(() { setState(() {
_messageIsPresent = s.trim().isNotEmpty; _messageIsPresent = s.trim().isNotEmpty;
@@ -68,6 +68,7 @@ class ThreadHeader extends StatelessWidget implements PreferredSizeWidget {
this.leading, this.leading,
this.actions, this.actions,
this.onTitleTap, this.onTitleTap,
this.showTypingIndicator = true,
}) : preferredSize = const Size.fromHeight(kToolbarHeight), }) : preferredSize = const Size.fromHeight(kToolbarHeight),
super(key: key); super(key: key);
@@ -96,9 +97,32 @@ class ThreadHeader extends StatelessWidget implements PreferredSizeWidget {
/// AppBar actions /// AppBar actions
final List<Widget>? actions; final List<Widget>? actions;
/// If true the typing indicator will be rendered
/// if a user is typing in this thread
final bool showTypingIndicator;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final chatThemeData = StreamChatTheme.of(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( return AppBar(
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
textTheme: Theme.of(context).textTheme, textTheme: Theme.of(context).textTheme,
@@ -119,6 +143,7 @@ class ThreadHeader extends StatelessWidget implements PreferredSizeWidget {
onTap: onTitleTap, onTap: onTitleTap,
child: SizedBox( child: SizedBox(
height: preferredSize.height, height: preferredSize.height,
width: 250,
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
@@ -128,24 +153,16 @@ class ThreadHeader extends StatelessWidget implements PreferredSizeWidget {
style: chatThemeData.channelTheme.channelHeaderTheme.title, style: chatThemeData.channelTheme.channelHeaderTheme.title,
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
subtitle ?? if (showTypingIndicator)
Row( TypingIndicator(
mainAxisSize: MainAxisSize.min, alignment: Alignment.center,
mainAxisAlignment: MainAxisAlignment.center, channel: StreamChannel.of(context).channel,
children: [ style: chatThemeData.channelTheme.channelHeaderTheme.subtitle,
Text( parentId: parent.id,
'with ', alternativeWidget: defaultSubtitle,
style: chatThemeData )
.channelTheme.channelHeaderTheme.subtitle, else
), defaultSubtitle,
Flexible(
child: ChannelName(
textStyle: chatThemeData
.channelTheme.channelHeaderTheme.subtitle,
),
),
],
),
], ],
), ),
), ),
@@ -12,6 +12,7 @@ class TypingIndicator extends StatelessWidget {
this.style, this.style,
this.alignment = Alignment.centerLeft, this.alignment = Alignment.centerLeft,
this.padding = const EdgeInsets.all(0), this.padding = const EdgeInsets.all(0),
this.parentId,
}) : super(key: key); }) : super(key: key);
/// Style of the text widget /// Style of the text widget
@@ -29,21 +30,21 @@ class TypingIndicator extends StatelessWidget {
/// Alignment of the typing indicator /// Alignment of the typing indicator
final Alignment alignment; final Alignment alignment;
/// Id of the parent message in case of a thread
final String? parentId;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final channelState = final channelState =
channel?.state ?? StreamChannel.of(context).channel.state!; channel?.state ?? StreamChannel.of(context).channel.state!;
final altWidget = Align( final altWidget = alternativeWidget ?? const Offstage();
key: const Key('alternative'),
alignment: alignment, return BetterStreamBuilder<Iterable<User>>(
child: Container( initialData: channelState.typingEvents.keys,
child: alternativeWidget ?? const Offstage(), stream: channelState.typingEventsStream.map((typings) => typings.entries
), .where((element) => element.value.parentId == parentId)
); .map((e) => e.key)),
return BetterStreamBuilder<List<User>>(
initialData: channelState.typingEvents,
stream: channelState.typingEventsStream,
builder: (context, data) => AnimatedSwitcher( builder: (context, data) => AnimatedSwitcher(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
child: data.isNotEmpty == true child: data.isNotEmpty == true
@@ -63,7 +64,7 @@ class TypingIndicator extends StatelessWidget {
), ),
Text( Text(
// ignore: lines_longer_than_80_chars // 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, maxLines: 1,
style: style, style: style,
), ),
@@ -60,10 +60,6 @@ void main() {
) )
])); ]));
when(() => channelState.typingEvents).thenReturn([]);
when(() => channelState.typingEventsStream)
.thenAnswer((_) => Stream.value([]));
await tester.pumpWidget(MaterialApp( await tester.pumpWidget(MaterialApp(
home: StreamChat( home: StreamChat(
client: client, client: client,
@@ -53,15 +53,15 @@ void main() {
user: User(id: 'other-user'), user: User(id: 'other-user'),
) )
])); ]));
when(() => channelState.typingEvents).thenAnswer((i) => {
when(() => channelState.typingEvents).thenAnswer((i) => [ User(id: 'other-user', extraData: {'name': 'demo'}):
User(id: 'other-user', extraData: {'name': 'demo'}) const Event(type: EventType.typingStart),
]); });
when(() => channelState.typingEventsStream) when(() => channelState.typingEventsStream)
.thenAnswer((i) => Stream.value([ .thenAnswer((i) => Stream.value({
User(id: 'other-user', extraData: {'name': 'demo'}), User(id: 'other-user', extraData: {'name': 'demo'}):
User(id: 'other-user', extraData: {'name': 'demo'}), const Event(type: EventType.typingStart),
])); }));
await tester.pumpWidget(MaterialApp( await tester.pumpWidget(MaterialApp(
home: StreamChat( home: StreamChat(
@@ -23,8 +23,8 @@ class MockChannel extends Mock implements Channel {
class MockChannelState extends Mock implements ChannelClientState { class MockChannelState extends Mock implements ChannelClientState {
MockChannelState() { MockChannelState() {
when(() => typingEvents).thenReturn([]); when(() => typingEvents).thenReturn({});
when(() => typingEventsStream).thenAnswer((_) => Stream.value([])); when(() => typingEventsStream).thenAnswer((_) => Stream.value({}));
} }
} }
@@ -53,14 +53,15 @@ void main() {
) )
])); ]));
when(() => channelState.typingEvents).thenAnswer((i) => [ when(() => channelState.typingEvents).thenAnswer((i) => {
User(id: 'other-user', extraData: {'name': 'demo'}) User(id: 'other-user', extraData: {'name': 'demo'}):
]); const Event(type: EventType.typingStart),
});
when(() => channelState.typingEventsStream) when(() => channelState.typingEventsStream)
.thenAnswer((i) => Stream.value([ .thenAnswer((i) => Stream.value({
User(id: 'other-user', extraData: {'name': 'demo'}), User(id: 'other-user', extraData: {'name': 'demo'}):
User(id: 'other-user', extraData: {'name': 'demo'}), const Event(type: EventType.typingStart),
])); }));
await tester.pumpWidget(MaterialApp( await tester.pumpWidget(MaterialApp(
home: StreamChat( home: StreamChat(