Merge pull request #479 from GetStream/feat/threadTypingIndicators
feat: thread typing indicators
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user