Optimize chat (#154)

Optimized the chat so that resuming PiP does not cause stutters.

This new implementation makes it so that only the latest (100) messages are
rendered at any given time, rather than every single one.

Once autoscroll is disabled and we scroll up in chat, the most recent
messages will be added to a buffer to prevent shifting.

Essentially, I've made it so that the default chat shows only the (100)
most recent messages. When you want to scroll back, the chat will
silently switch to the version with the last (10000) messages, giving
the best of both worlds in performance and scrollback history.
This commit is contained in:
Tommy Chow 2022-07-26 23:23:53 -04:00
parent 402d75cc21
commit 932412b75f
7 changed files with 81 additions and 43 deletions

View file

@ -140,7 +140,7 @@ Map<String, dynamic> _$EmoteToJson(Emote instance) => <String, dynamic>{
'height': instance.height,
'zeroWidth': instance.zeroWidth,
'url': instance.url,
'type': _$EmoteTypeEnumMap[instance.type],
'type': _$EmoteTypeEnumMap[instance.type]!,
'ownerId': instance.ownerId,
};

View file

@ -35,7 +35,7 @@ class IRCMessage {
this.mention,
});
/// Returns a list of messages where the gievn CLEARCHAT message is applied.
/// Returns a list of messages where the given CLEARCHAT message is applied.
static List<IRCMessage> clearChat({required List<IRCMessage> messages, required IRCMessage ircMessage}) {
// If there is no message, it means that entire chat was cleared.
if (ircMessage.message == null) {
@ -367,9 +367,7 @@ class IRCMessage {
Column(
children: [
Text(
nextWordIsEmoji
? '${words[index]} - Emoji'
: '${emoteStack.last.name} - ${emoteType[emoteStack.last.type.index]}',
nextWordIsEmoji ? '${words[index]} - Emoji' : '${emoteStack.last.name} - ${emoteType[emoteStack.last.type.index]}',
style: const TextStyle(color: Colors.black),
),
Text(

View file

@ -36,13 +36,13 @@ class Chat extends StatelessWidget {
child: Observer(
builder: (context) {
return ListView.builder(
reverse: true,
padding: EdgeInsets.zero,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
itemCount: chatStore.messages.length,
controller: chatStore.scrollController,
itemCount: chatStore.renderMessages.length,
itemBuilder: (context, index) => ChatMessage(
ircMessage: chatStore.messages[index],
ircMessage: chatStore.renderMessages.reversed.toList()[index],
chatStore: chatStore,
),
);
@ -83,7 +83,7 @@ class Chat extends StatelessWidget {
duration: const Duration(milliseconds: 200),
child: EmoteMenu(chatStore: chatStore),
),
onEnd: () => chatStore.scrollController.jumpTo(chatStore.scrollController.position.maxScrollExtent),
onEnd: () => chatStore.scrollController.jumpTo(0),
),
],
);

View file

@ -18,8 +18,9 @@ part 'chat_store.g.dart';
class ChatStore = ChatStoreBase with _$ChatStore;
abstract class ChatStoreBase with Store {
static const _messageUpperLimit = 7500;
static const _messageLimit = 5000;
static const _messageUpperLimit = 15000;
static const _messageLimit = 10000;
static const _renderMessageLimit = 100;
/// The provided auth store to determine login status, get the token, and use the headers for requests.
final AuthStore auth;
@ -63,10 +64,29 @@ abstract class ChatStoreBase with Store {
/// Requested message to be sent by the user. Will only be sent on receipt of a USERNOTICE command.
IRCMessage? toSend;
/// The list of chat messages to add once autoscroll is resumed.
/// This is used as an optimization to prevent the list from being updated/shifted while the user is scrolling.
final _messageBuffer = <IRCMessage>[];
/// The list of chat messages to render and display.
@readonly
var _messages = ObservableList<IRCMessage>();
@computed
List<IRCMessage> get renderMessages {
// If autoscroll is disabled, render ALL messages in chat.
// The second condition is to prevent an out of index error with sublist.
//
// Else, when autoscroll is enabled, only show the first [_renderMessageLimit] messages.
// This will improve performance by only rendering a limited amount of messages
// instead of the entire history at all times.
if (!_autoScroll || _messages.length < _renderMessageLimit) {
return _messages;
} else {
return _messages.sublist(_messages.length - _renderMessageLimit);
}
}
/// If the chat should automatically scroll/jump to the latest message.
@readonly
var _autoScroll = true;
@ -105,6 +125,18 @@ abstract class ChatStoreBase with Store {
(_) => _channel?.sink.close(1001),
));
// Create a reaction that will add buffered messages to the chat
// when autoscroll is changed.
reactions.add(reaction(
(_) => _autoScroll,
(_) {
if (_messageBuffer.isNotEmpty) {
_messages.addAll(_messageBuffer);
_messageBuffer.clear();
}
},
));
assetsStore.init();
chatDetailsStore.updateChatters();
@ -119,12 +151,12 @@ abstract class ChatStoreBase with Store {
// Tell the scrollController to determine when auto-scroll should be enabled or disabled.
scrollController.addListener(() {
// If the user scrolls up, auto-scroll will stop, allowing them to freely scroll back to previous messages.
// Else if the user scrolls back to the bottom edge (latest message), auto-scroll will resume.
if (scrollController.position.pixels < scrollController.position.maxScrollExtent) {
if (_autoScroll == true) _autoScroll = false;
} else if (scrollController.position.atEdge || scrollController.position.pixels > scrollController.position.maxScrollExtent) {
if (_autoScroll == false) _autoScroll = true;
// If the scroll position is at the latest message (maximum possible), enable autoscroll.
// Else if the position is before the latest message (not at the edge), disable autoscroll.
if (scrollController.position.pixels <= 0) {
_autoScroll = true;
} else if (scrollController.position.pixels > 0) {
_autoScroll = false;
}
});
@ -172,7 +204,14 @@ abstract class ChatStoreBase with Store {
switch (parsedIRCMessage.command) {
case Command.privateMessage:
_messages.add(parsedIRCMessage);
case Command.notice:
case Command.userNotice:
if (_autoScroll) {
_messages.add(parsedIRCMessage);
} else {
// If audoscroll is disabled, buffer the message to prevent the chat from shifting.
_messageBuffer.add(parsedIRCMessage);
}
break;
case Command.clearChat:
_messages = IRCMessage.clearChat(messages: _messages, ircMessage: parsedIRCMessage).asObservable();
@ -180,10 +219,6 @@ abstract class ChatStoreBase with Store {
case Command.clearMessage:
_messages = IRCMessage.clearMessage(messages: _messages, ircMessage: parsedIRCMessage).asObservable();
break;
case Command.notice:
case Command.userNotice:
_messages.add(parsedIRCMessage);
break;
case Command.roomState:
chatDetailsStore.roomState = chatDetailsStore.roomState.fromIRCMessage(parsedIRCMessage);
continue;
@ -212,16 +247,11 @@ abstract class ChatStoreBase with Store {
continue;
}
if (_autoScroll) {
if (_messages.length >= _messageLimit) _messages.removeAt(0);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (scrollController.hasClients) scrollController.jumpTo(scrollController.position.maxScrollExtent);
});
}
// If the message limit is reached, remove the oldest message.
if (_messages.length >= _messageLimit) _messages.removeAt(0);
// Remove messages when an upper limit is reached.
// This will prevent an infinite amount of messages when autoscroll is off.
// This will prevent an infinite amount of messages building up when autoscroll is off.
if (_messages.length >= _messageUpperLimit) _messages.removeRange(0, 500);
// Hard upper-limit of 5000 messages to prevent infinite messages being added when scrolling.
@ -258,15 +288,10 @@ abstract class ChatStoreBase with Store {
/// Re-enables [_autoScroll] and jumps to the latest message.
@action
void resumeScroll() {
if (_messages.length >= _messageLimit) _messages.removeRange(0, _messages.length - _messageLimit);
_autoScroll = true;
// Jump to the latest message (bottom of the list/chat).
scrollController.jumpTo(scrollController.position.maxScrollExtent);
// Schedule a postFrameCallback in the event a new message is added at the same time.
WidgetsBinding.instance.addPostFrameCallback((_) => scrollController.jumpTo(scrollController.position.maxScrollExtent));
scrollController.jumpTo(0);
}
@action
@ -353,9 +378,7 @@ abstract class ChatStoreBase with Store {
}
// Scroll to the latest message after sending.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (scrollController.hasClients) scrollController.jumpTo(scrollController.position.maxScrollExtent);
});
if (scrollController.hasClients) scrollController.jumpTo(0);
}
/// Adds the given [emote] to the chat textfield.

View file

@ -9,6 +9,14 @@ part of 'chat_store.dart';
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers
mixin _$ChatStore on ChatStoreBase, Store {
Computed<List<IRCMessage>>? _$renderMessagesComputed;
@override
List<IRCMessage> get renderMessages => (_$renderMessagesComputed ??=
Computed<List<IRCMessage>>(() => super.renderMessages,
name: 'ChatStoreBase.renderMessages'))
.value;
late final _$_messagesAtom =
Atom(name: 'ChatStoreBase._messages', context: context);
@ -135,6 +143,14 @@ mixin _$ChatStore on ChatStoreBase, Store {
});
}
late final _$getAssetsAsyncAction =
AsyncAction('ChatStoreBase.getAssets', context: context);
@override
Future<void> getAssets() {
return _$getAssetsAsyncAction.run(() => super.getAssets());
}
late final _$ChatStoreBaseActionController =
ActionController(name: 'ChatStoreBase', context: context);
@ -196,7 +212,8 @@ mixin _$ChatStore on ChatStoreBase, Store {
@override
String toString() {
return '''
expandChat: ${expandChat}
expandChat: ${expandChat},
renderMessages: ${renderMessages}
''';
}
}

View file

@ -169,7 +169,7 @@ class _VideoChatState extends State<VideoChat> {
builder: (context, orientation) {
// Scroll to bottom when summoning keyboard or rotating.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_chatStore.scrollController.hasClients) _chatStore.scrollController.jumpTo(_chatStore.scrollController.position.maxScrollExtent);
if (_chatStore.scrollController.hasClients) _chatStore.scrollController.jumpTo(0);
});
if (orientation == Orientation.landscape) {

View file

@ -48,7 +48,7 @@ SettingsStore _$SettingsStoreFromJson(Map<String, dynamic> json) =>
Map<String, dynamic> _$SettingsStoreToJson(SettingsStore instance) =>
<String, dynamic>{
'themeType': _$ThemeTypeEnumMap[instance.themeType],
'themeType': _$ThemeTypeEnumMap[instance.themeType]!,
'showThumbnails': instance.showThumbnails,
'largeStreamCard': instance.largeStreamCard,
'showThumbnailUptime': instance.showThumbnailUptime,
@ -68,7 +68,7 @@ Map<String, dynamic> _$SettingsStoreToJson(SettingsStore instance) =>
'useReadableColors': instance.useReadableColors,
'showDeletedMessages': instance.showDeletedMessages,
'showChatMessageDividers': instance.showChatMessageDividers,
'timestampType': _$TimestampTypeEnumMap[instance.timestampType],
'timestampType': _$TimestampTypeEnumMap[instance.timestampType]!,
'badgeScale': instance.badgeScale,
'emoteScale': instance.emoteScale,
'messageScale': instance.messageScale,