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
List<User> get typingEvents => _typingEventsController.value;
Map<User, Event> get typingEvents => _typingEventsController.value;
/// Channel related typing users stream
Stream<List<User>> get typingEventsStream =>
_typingEventsController.stream.distinct(const ListEquality().equals);
Stream<Map<User, Event>> get typingEventsStream =>
_typingEventsController.stream;
final BehaviorSubject<List<User>> _typingEventsController =
BehaviorSubject.seeded([]);
final BehaviorSubject<Map<User, Event>> _typingEventsController =
BehaviorSubject.seeded({});
final Channel _channel;
final Map<User, DateTime> _typings = {};
final Map<User, Event> _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,
),
);
}
@@ -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,
@@ -221,59 +221,62 @@ class ChannelPreview extends StatelessWidget {
);
}
Widget _buildLastMessage(BuildContext context) =>
BetterStreamBuilder<List<Message>?>(
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<List<Message>?>(
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 = <String>[
...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 = <String>[
...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(
@@ -700,7 +700,10 @@ class MessageInputState extends State<MessageInput> {
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;
@@ -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<Widget>? 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,
],
),
),
@@ -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<List<User>>(
initialData: channelState.typingEvents,
stream: channelState.typingEventsStream,
final altWidget = alternativeWidget ?? const Offstage();
return BetterStreamBuilder<Iterable<User>>(
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,
),
@@ -60,10 +60,6 @@ void main() {
)
]));
when(() => channelState.typingEvents).thenReturn([]);
when(() => channelState.typingEventsStream)
.thenAnswer((_) => Stream.value([]));
await tester.pumpWidget(MaterialApp(
home: StreamChat(
client: client,
@@ -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(
@@ -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({}));
}
}
@@ -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(