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
|
/// 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(
|
||||||
|
|||||||
Reference in New Issue
Block a user