From de2e2f39873564a9d3dd11d2890b0aed081c93e5 Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Sat, 18 Feb 2023 21:02:55 +0100 Subject: [PATCH] feat(meta): Paginate message requests --- lib/data_classes.yaml | 17 ++ lib/main.dart | 4 +- lib/service/database/database.dart | 34 ++++ lib/service/events.dart | 19 ++ lib/service/message.dart | 15 +- lib/shared/constants.dart | 3 + lib/ui/bloc/conversation_bloc.dart | 187 ++++++++---------- lib/ui/bloc/conversation_state.dart | 1 - .../controller/conversation_controller.dart | 114 +++++++++++ lib/ui/pages/conversation/bottom.dart | 4 + lib/ui/pages/conversation/conversation.dart | 123 ++++++------ 11 files changed, 355 insertions(+), 166 deletions(-) create mode 100644 lib/ui/controller/conversation_controller.dart diff --git a/lib/data_classes.yaml b/lib/data_classes.yaml index 63dd54c2..22068970 100644 --- a/lib/data_classes.yaml +++ b/lib/data_classes.yaml @@ -265,6 +265,16 @@ files: stickerPack: type: StickerPack deserialise: true + # Returned by [GetPagedMessagesCommand] + - name: PagedMessagesResultEvent + extends: BackgroundEvent + implements: + - JsonImplementation + attributes: + messages: + type: List + deserialise: true + hasOlderMessages: bool generate_builder: true builder_name: "Event" builder_baseclass: "BackgroundEvent" @@ -545,6 +555,13 @@ files: implements: - JsonImplementation attributes: + - name: GetPagedMessagesCommand + extends: BackgroundCommand + implements: + - JsonImplementation + attributes: + conversationJid: String + oldestMessageTimestamp: int? generate_builder: true # get${builder_Name}FromJson builder_name: "Command" diff --git a/lib/main.dart b/lib/main.dart index 1380a44a..cb942d03 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -261,7 +261,9 @@ class MyAppState extends State with WidgetsBindingObserver { case conversationRoute: return PageTransition( type: PageTransitionType.rightToLeft, settings: settings, - child: const ConversationPage(), + child: ConversationPage( + conversationJid: settings.arguments as String, + ), ); case sharedMediaRoute: return SharedMediaPage.route; case blocklistRoute: return BlocklistPage.route; diff --git a/lib/service/database/database.dart b/lib/service/database/database.dart index eb3871a3..283a91f3 100644 --- a/lib/service/database/database.dart +++ b/lib/service/database/database.dart @@ -41,6 +41,7 @@ import 'package:moxxyv2/service/not_specified.dart'; import 'package:moxxyv2/service/omemo/omemo.dart'; import 'package:moxxyv2/service/omemo/types.dart'; import 'package:moxxyv2/service/roster.dart'; +import 'package:moxxyv2/shared/constants.dart'; import 'package:moxxyv2/shared/models/conversation.dart'; import 'package:moxxyv2/shared/models/media.dart'; import 'package:moxxyv2/shared/models/message.dart'; @@ -286,6 +287,39 @@ class DatabaseService { return messages; } + + Future> getPaginatedMessagesForJid(String jid, int? oldestTimestamp) async { + final query = oldestTimestamp != null ? + 'conversationJid = ? AND timestamp < ?' : + 'conversationJid = ?'; + final args = oldestTimestamp != null ? + [jid, oldestTimestamp!] : + [jid]; + final rawMessages = await _db.query( + 'Messages', + where: query, + whereArgs: args, + orderBy: 'timestamp DESC', + limit: paginatedMessageFetchAmount, + ); + + final messages = List.empty(growable: true); + for (final m in rawMessages) { + Message? quotes; + if (m['quote_id'] != null) { + final rawQuote = (await _db.query( + 'Messages', + where: 'conversationJid = ? AND id = ?', + whereArgs: [jid, m['quote_id']! as int], + )).first; + quotes = Message.fromDatabaseJson(rawQuote, null); + } + + messages.add(Message.fromDatabaseJson(m, quotes)); + } + + return messages; + } /// Updates the conversation with JID [jid] inside the database. Future updateConversation(String jid, { diff --git a/lib/service/events.dart b/lib/service/events.dart index 926ca13e..31a9504c 100644 --- a/lib/service/events.dart +++ b/lib/service/events.dart @@ -82,6 +82,7 @@ void setupBackgroundEventHandler() { EventTypeMatcher(performFetchStickerPack), EventTypeMatcher(performStickerPackInstall), EventTypeMatcher(performGetBlocklist), + EventTypeMatcher(performGetPagedMessages), ]); GetIt.I.registerSingleton(handler); @@ -1012,3 +1013,21 @@ Future performGetBlocklist(GetBlocklistCommand command, { dynamic extra }) id: id, ); } + +Future performGetPagedMessages(GetPagedMessagesCommand command, { dynamic extra }) async { + final id = extra as String; + + final result = await GetIt.I.get().getPaginatedMessagesForJid( + command.conversationJid, + command.oldestMessageTimestamp, + ); + + sendEvent( + PagedMessagesResultEvent( + messages: result, + // TODO + hasOlderMessages: true, + ), + id: id, + ); +} diff --git a/lib/service/message.dart b/lib/service/message.dart index eed55f07..3c7f3d85 100644 --- a/lib/service/message.dart +++ b/lib/service/message.dart @@ -14,9 +14,11 @@ import 'package:moxxyv2/shared/models/media.dart'; import 'package:moxxyv2/shared/models/message.dart'; class MessageService { - MessageService() : _messageCache = HashMap(), _log = Logger('MessageService'); - final HashMap> _messageCache; - final Logger _log; + // TODO(PapaTutuWawa): Maybe remove the cache + final HashMap> _messageCache = HashMap(); + + /// Logger + final Logger _log = Logger('MessageService'); /// Returns the messages for [jid], either from cache or from the database. Future> getMessagesForJid(String jid) async { @@ -33,6 +35,13 @@ class MessageService { return messages; } + Future> getPaginatedMessagesForJid(String jid, int? oldestTimestamp) async { + return GetIt.I.get().getPaginatedMessagesForJid( + jid, + oldestTimestamp, + ); + } + /// Wrapper around [DatabaseService]'s addMessageFromData that updates the cache. Future addMessageFromData( String body, diff --git a/lib/shared/constants.dart b/lib/shared/constants.dart index ca24a74e..13d0c5ca 100644 --- a/lib/shared/constants.dart +++ b/lib/shared/constants.dart @@ -1 +1,4 @@ const int timestampNever = -1; + +/// The amount of messages that are fetched by a paginated message request +const int paginatedMessageFetchAmount = 30; diff --git a/lib/ui/bloc/conversation_bloc.dart b/lib/ui/bloc/conversation_bloc.dart index 24cc208f..6bf9932c 100644 --- a/lib/ui/bloc/conversation_bloc.dart +++ b/lib/ui/bloc/conversation_bloc.dart @@ -44,7 +44,6 @@ class ConversationBloc extends Bloc { on(_onJidBlocked); on(_onJidAdded); on(_onCurrentConversationReset); - on(_onMessageAdded); on(_onMessageUpdated); on(_onConversationUpdated); on(_onAppStateChanged); @@ -160,25 +159,23 @@ class ConversationBloc extends Bloc { final navEvent = event.removeUntilConversations ? ( PushedNamedAndRemoveUntilEvent( - const NavigationDestination(conversationRoute), + NavigationDestination( + conversationRoute, + arguments: event.jid, + ), ModalRoute.withName(conversationsRoute), ) ) : ( PushedNamedEvent( - const NavigationDestination(conversationRoute), + NavigationDestination( + conversationRoute, + arguments: event.jid, + ), ) ); GetIt.I.get().add(navEvent); - // ignore: cast_nullable_to_non_nullable - final result = await MoxplatformPlugin.handler.getDataSender().sendData( - GetMessagesForJidCommand( - jid: event.jid, - ), - ) as events.MessagesResultEvent; - emit(state.copyWith(messages: result.messages)); - await MoxplatformPlugin.handler.getDataSender().sendData( SetOpenConversationCommand(jid: event.jid), awaitable: false, @@ -302,7 +299,6 @@ class ConversationBloc extends Bloc { conversation: null, messageText: '', quotedMessage: null, - messages: [], ), ); @@ -312,16 +308,6 @@ class ConversationBloc extends Bloc { ); } - Future _onMessageAdded(MessageAddedEvent event, Emitter emit) async { - if (!_isMessageForConversation(event.message)) return; - - emit( - state.copyWith( - messages: List.from([ ...state.messages, event.message ]), - ), - ); - } - Future _onMessageUpdated(MessageUpdatedEvent event, Emitter emit) async { if (!_isMessageForConversation(event.message)) return; @@ -338,19 +324,6 @@ class ConversationBloc extends Bloc { } } */ - - // We don't have to re-sort the list here as timestamps never change - emit( - state.copyWith( - messages: List.from( - state.messages.map((Message m) { - if (m.id == event.message.id) return event.message; - - return m; - }), - ), - ), - ); } Future _onConversationUpdated(ConversationUpdatedEvent event, Emitter emit) async { @@ -570,86 +543,88 @@ class ConversationBloc extends Bloc { } Future _onReactionAdded(ReactionAddedEvent event, Emitter emit) async { + // TODO: Fix // Check if such a reaction already exists - final message = state.messages[event.index]; - final msgs = List.from(state.messages); - final reactionIndex = message.reactions.indexWhere( - (Reaction r) => r.emoji == event.emoji, - ); - if (reactionIndex != -1) { - // Ignore the request when the reaction would be invalid - final reaction = message.reactions[reactionIndex]; - if (reaction.reactedBySelf) return; + // final message = state.messages[event.index]; + // final msgs = List.from(state.messages); + // final reactionIndex = message.reactions.indexWhere( + // (Reaction r) => r.emoji == event.emoji, + // ); + // if (reactionIndex != -1) { + // // Ignore the request when the reaction would be invalid + // final reaction = message.reactions[reactionIndex]; + // if (reaction.reactedBySelf) return; - final reactions = List.from(message.reactions); - reactions[reactionIndex] = reaction.copyWith( - reactedBySelf: true, - ); - msgs[event.index] = message.copyWith( - reactions: reactions, - ); - } else { - // The reaction is new - msgs[event.index] = message.copyWith( - reactions: [ - ...message.reactions, - Reaction( - [], - event.emoji, - true, - ), - ], - ); - } + // final reactions = List.from(message.reactions); + // reactions[reactionIndex] = reaction.copyWith( + // reactedBySelf: true, + // ); + // msgs[event.index] = message.copyWith( + // reactions: reactions, + // ); + // } else { + // // The reaction is new + // msgs[event.index] = message.copyWith( + // reactions: [ + // ...message.reactions, + // Reaction( + // [], + // event.emoji, + // true, + // ), + // ], + // ); + // } - emit( - state.copyWith( - messages: msgs, - ), - ); + // emit( + // state.copyWith( + // messages: msgs, + // ), + // ); - await MoxplatformPlugin.handler.getDataSender().sendData( - AddReactionToMessageCommand( - messageId: message.id, - emoji: event.emoji, - conversationJid: message.conversationJid, - ), - awaitable: false, - ); + // await MoxplatformPlugin.handler.getDataSender().sendData( + // AddReactionToMessageCommand( + // messageId: message.id, + // emoji: event.emoji, + // conversationJid: message.conversationJid, + // ), + // awaitable: false, + // ); } Future _onReactionRemoved(ReactionRemovedEvent event, Emitter emit) async { - final message = state.messages[event.index]; - final msgs = List.from(state.messages); - final reactionIndex = message.reactions.indexWhere( - (Reaction r) => r.emoji == event.emoji, - ); + // TODO + // final message = state.messages[event.index]; + // final msgs = List.from(state.messages); + // final reactionIndex = message.reactions.indexWhere( + // (Reaction r) => r.emoji == event.emoji, + // ); - // We assume that reactionIndex >= 0 - assert(reactionIndex >= 0, 'The reaction must be found'); - final reactions = List.from(message.reactions); - if (message.reactions[reactionIndex].senders.isEmpty) { - reactions.removeAt(reactionIndex); - } else { - reactions[reactionIndex] = reactions[reactionIndex].copyWith( - reactedBySelf: false, - ); - } - msgs[event.index] = message.copyWith(reactions: reactions); - emit( - state.copyWith( - messages: msgs, - ), - ); + // // We assume that reactionIndex >= 0 + // assert(reactionIndex >= 0, 'The reaction must be found'); + // final reactions = List.from(message.reactions); + // if (message.reactions[reactionIndex].senders.isEmpty) { + // reactions.removeAt(reactionIndex); + // } else { + // reactions[reactionIndex] = reactions[reactionIndex].copyWith( + // reactedBySelf: false, + // ); + // } + // msgs[event.index] = message.copyWith(reactions: reactions); + // emit( + // state.copyWith( + // messages: msgs, + // ), + // ); - await MoxplatformPlugin.handler.getDataSender().sendData( - RemoveReactionFromMessageCommand( - messageId: message.id, - emoji: event.emoji, - conversationJid: message.conversationJid, - ), - awaitable: false, - ); + // await MoxplatformPlugin.handler.getDataSender().sendData( + // RemoveReactionFromMessageCommand( + // messageId: message.id, + // emoji: event.emoji, + // conversationJid: message.conversationJid, + // ), + // awaitable: false, + // ); } Future _onStickerSent(StickerSentEvent event, Emitter emit) async { diff --git a/lib/ui/bloc/conversation_state.dart b/lib/ui/bloc/conversation_state.dart index 96474568..1d048034 100644 --- a/lib/ui/bloc/conversation_state.dart +++ b/lib/ui/bloc/conversation_state.dart @@ -15,7 +15,6 @@ class ConversationState with _$ConversationState { @Default('') String messageText, @Default(defaultSendButtonState) SendButtonState sendButtonState, @Default(null) Message? quotedMessage, - @Default([]) List messages, @Default(null) Conversation? conversation, @Default('') String backgroundPath, @Default(false) bool pickerVisible, diff --git a/lib/ui/controller/conversation_controller.dart b/lib/ui/controller/conversation_controller.dart new file mode 100644 index 00000000..3316afe6 --- /dev/null +++ b/lib/ui/controller/conversation_controller.dart @@ -0,0 +1,114 @@ +import 'dart:async'; +import 'package:flutter/animation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:moxplatform/moxplatform.dart'; +import 'package:moxxyv2/shared/events.dart'; +import 'package:moxxyv2/shared/commands.dart'; +import 'package:moxxyv2/shared/constants.dart'; +import 'package:moxxyv2/shared/models/message.dart'; + +class BidirectionalConversationController { + BidirectionalConversationController(this.conversationJid) { + _controller.addListener(_handleScroll); + } + + /// The list of messages we know about + final List _messageCache = List.empty(growable: true); + + /// Flag indicating whether we are currently fetching messages. + bool isFetchingMessages = false; + final StreamController _isFetchingStreamController = StreamController(); + Stream get isFetchingStream => _isFetchingStreamController.stream; + + /// Flag indicating whether we have newer messages we could request from the database + bool hasNewerMessages = false; + + /// Flag indicating whether we have older messages we could request from the database + bool hasOlderMessages = true; + + /// Flag indicating whether messages have been fetched once + bool hasFetchedOnce = false; + + /// Scroll controller for managing things like loading newer and older messages + final ScrollController _controller = ScrollController(); + ScrollController get scrollController => _controller; + + /// Stream for message updates + final StreamController> _messageStreamController = StreamController(); + Stream> get messageStream => _messageStreamController.stream; + + /// The JID of the current chat + final String conversationJid; + + void _setIsFetching(bool state) { + isFetchingMessages = state; + _isFetchingStreamController.add(state); + } + + void _handleScroll() { + if (!_controller.hasClients) return; + + // Fetch older messages when we reach the top edge of the list + if (_controller.offset >= _controller.position.maxScrollExtent - 20 && !isFetchingMessages) { + unawaited(fetchOlderMessages()); + } + } + + void animateToBottom() { + _controller.animateTo( + _controller.position.minScrollExtent, + curve: Curves.easeIn, + duration: const Duration(milliseconds: 300), + ); + } + + void onMessageSent(Message message) { + if (hasNewerMessages) { + _messageCache.add(message); + + _messageStreamController.add(_messageCache); + + Future.delayed(const Duration(milliseconds: 300)) + .then((_) => animateToBottom()); + } else { + // TODO(PapaTutuWawa): Load the newest page and scroll to it + } + } + + Future fetchOlderMessages() async { + if (isFetchingMessages || + _messageCache.isEmpty && hasFetchedOnce) return; + if (!hasOlderMessages) return; + + _setIsFetching(true); + + // ignore: cast_nullable_to_non_nullable + final result = await MoxplatformPlugin.handler.getDataSender().sendData( + GetPagedMessagesCommand( + conversationJid: conversationJid, + oldestMessageTimestamp: !hasFetchedOnce ? + null : + _messageCache.first.timestamp, + ), + ) as PagedMessagesResultEvent; + + _setIsFetching(false); + hasFetchedOnce = true; + hasOlderMessages = result.hasOlderMessages; + + if (result.messages.length == 0) { + hasOlderMessages = false; + return; + } else if (result.messages.length < paginatedMessageFetchAmount) { + // This means we reached the end of messages we can fetch + hasOlderMessages = false; + } + + _messageCache.insertAll(0, result.messages.reversed); + _messageStreamController.add(_messageCache); + } + + void dispose() { + _controller.dispose(); + } +} diff --git a/lib/ui/pages/conversation/bottom.dart b/lib/ui/pages/conversation/bottom.dart index b98d72b6..e2c115b6 100644 --- a/lib/ui/pages/conversation/bottom.dart +++ b/lib/ui/pages/conversation/bottom.dart @@ -10,6 +10,7 @@ import 'package:moxxyv2/i18n/strings.g.dart'; import 'package:moxxyv2/shared/helpers.dart'; import 'package:moxxyv2/ui/bloc/conversation_bloc.dart'; import 'package:moxxyv2/ui/constants.dart'; +import 'package:moxxyv2/ui/controller/conversation_controller.dart'; import 'package:moxxyv2/ui/helpers.dart'; import 'package:moxxyv2/ui/pages/conversation/blink.dart'; import 'package:moxxyv2/ui/pages/conversation/timer.dart'; @@ -93,6 +94,7 @@ class ConversationBottomRow extends StatefulWidget { this.controller, this.tabController, this.focusNode, + this.conversationController, this.speedDialValueNotifier, { super.key, } @@ -101,6 +103,7 @@ class ConversationBottomRow extends StatefulWidget { final TabController tabController; final FocusNode focusNode; final ValueNotifier speedDialValueNotifier; + final BidirectionalConversationController conversationController; @override ConversationBottomRowState createState() => ConversationBottomRowState(); @@ -298,6 +301,7 @@ class ConversationBottomRowState extends State { context.read().add( MessageSentEvent(), ); + widget.conversationController.animateToBottom(); widget.controller.text = ''; return; case SendButtonState.multi: diff --git a/lib/ui/pages/conversation/conversation.dart b/lib/ui/pages/conversation/conversation.dart index d9984684..5b286031 100644 --- a/lib/ui/pages/conversation/conversation.dart +++ b/lib/ui/pages/conversation/conversation.dart @@ -13,6 +13,7 @@ import 'package:moxxyv2/shared/models/message.dart'; import 'package:moxxyv2/shared/warning_types.dart'; import 'package:moxxyv2/ui/bloc/conversation_bloc.dart'; import 'package:moxxyv2/ui/constants.dart'; +import 'package:moxxyv2/ui/controller/conversation_controller.dart'; import 'package:moxxyv2/ui/helpers.dart'; import 'package:moxxyv2/ui/pages/conversation/blink.dart'; import 'package:moxxyv2/ui/pages/conversation/bottom.dart'; @@ -25,14 +26,13 @@ import 'package:moxxyv2/ui/widgets/chat/chatbubble.dart'; import 'package:moxxyv2/ui/widgets/overview_menu.dart'; class ConversationPage extends StatefulWidget { - const ConversationPage({ super.key }); + const ConversationPage({ + required this.conversationJid, + super.key, + }); - static MaterialPageRoute get route => MaterialPageRoute( - builder: (context) => const ConversationPage(), - settings: const RouteSettings( - name: conversationRoute, - ), - ); + /// The JID of the current conversation + final String conversationJid; @override ConversationPageState createState() => ConversationPageState(); @@ -40,7 +40,6 @@ class ConversationPage extends StatefulWidget { class ConversationPageState extends State with TickerProviderStateMixin { final TextEditingController _controller = TextEditingController(); - final ScrollController _scrollController = ScrollController(); late final AnimationController _animationController; late final AnimationController _overviewAnimationController; late final TabController _tabController; @@ -50,6 +49,8 @@ class ConversationPageState extends State with TickerProviderS late FocusNode _textfieldFocus; final ValueNotifier _isSpeedDialOpen = ValueNotifier(false); + late final BidirectionalConversationController _conversationController; + @override void initState() { super.initState(); @@ -58,7 +59,13 @@ class ConversationPageState extends State with TickerProviderS vsync: this, ); _textfieldFocus = FocusNode(); - _scrollController.addListener(_onScroll); + + // TODO + _conversationController = BidirectionalConversationController( + widget.conversationJid, + ); + _conversationController.scrollController.addListener(_onScroll); + _conversationController.fetchOlderMessages(); _overviewAnimationController = AnimationController( duration: const Duration(milliseconds: 200), @@ -80,9 +87,7 @@ class ConversationPageState extends State with TickerProviderS void dispose() { _tabController.dispose(); _controller.dispose(); - _scrollController - ..removeListener(_onScroll) - ..dispose(); + _conversationController.dispose(); _animationController.dispose(); _overviewAnimationController.dispose(); _textfieldFocus.dispose(); @@ -133,10 +138,13 @@ class ConversationPageState extends State with TickerProviderS final start = index - 1 < 0 ? true : - isSent(state.messages[index - 1], state.jid) != isSent(item, state.jid); - final end = index + 1 >= state.messages.length ? - true : - isSent(state.messages[index + 1], state.jid) != isSent(item, state.jid); + false; +// isSent(state.messages[index - 1], state.jid) != isSent(item, state.jid); + // final end = index + 1 >= state.messages.length ? + // true : + // false; + final end = true; +// isSent(state.messages[index + 1], state.jid) != isSent(item, state.jid); final between = !start && !end; final sentBySelf = isSent(message, state.jid); @@ -401,9 +409,9 @@ class ConversationPageState extends State with TickerProviderS /// Taken from https://bloclibrary.dev/#/flutterinfinitelisttutorial bool _isScrolledToBottom() { - if (!_scrollController.hasClients) return false; + if (!_conversationController.scrollController.hasClients) return false; - return _scrollController.offset <= 10; + return _conversationController.scrollController.offset <= 10; } void _onScroll() { @@ -495,43 +503,47 @@ class ConversationPageState extends State with TickerProviderS }, ), - BlocBuilder( - // NOTE: We don't need to update when the jid changes as it should - // be static over the entire lifetime of the BLoC. - buildWhen: (prev, next) => prev.messages != next.messages || prev.conversation?.encrypted != next.conversation?.encrypted, - builder: (context, state) => Expanded( - // Inspired by https://github.com/SimformSolutionsPvtLtd/flutter_chatview/blob/main/lib/src/widgets/chat_groupedlist_widget.dart - child: SingleChildScrollView( - reverse: true, - controller: _scrollController, - child: GroupedListView( - elements: state.messages, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - groupBy: (message) { - final dt = DateTime.fromMillisecondsSinceEpoch(message.timestamp); - return DateTime( - dt.year, - dt.month, - dt.day, - ); - }, - groupSeparatorBuilder: (DateTime dt) => Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - DateBubble( - formatDateBubble(dt, DateTime.now()), + Expanded( + child: StreamBuilder>( + initialData: [], + stream: _conversationController.messageStream, + builder: (context, snapshot) { + if (snapshot.hasData) { + return SingleChildScrollView( + reverse: true, + controller: _conversationController.scrollController, + child: GroupedListView( + elements: snapshot.data!, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + groupBy: (message) { + final dt = DateTime.fromMillisecondsSinceEpoch(message.timestamp); + return DateTime( + dt.year, + dt.month, + dt.day, + ); + }, + groupSeparatorBuilder: (DateTime dt) => Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + DateBubble( + formatDateBubble(dt, DateTime.now()), + ), + ], ), - ], - ), - indexedItemBuilder: (context, message, index) => _renderBubble( - state, - message, - index, - maxWidth, - ), - ), - ), + indexedItemBuilder: (context, message, index) => _renderBubble( + context.read().state, + message, + index, + maxWidth, + ), + ), + ); + } + + return CircularProgressIndicator(); + }, ), ), @@ -543,6 +555,7 @@ class ConversationPageState extends State with TickerProviderS _controller, _tabController, _textfieldFocus, + _conversationController, _isSpeedDialOpen, ), ), @@ -570,7 +583,7 @@ class ConversationPageState extends State with TickerProviderS heroTag: 'fabScrollDown', backgroundColor: Theme.of(context).scaffoldBackgroundColor, onPressed: () { - _scrollController.jumpTo(0); + _conversationController.animateToBottom(); }, child: const Icon( Icons.arrow_downward,