feat(meta): Paginate message requests

This commit is contained in:
PapaTutuWawa 2023-02-18 21:02:55 +01:00
parent 28591a6787
commit de2e2f3987
11 changed files with 355 additions and 166 deletions

View File

@ -265,6 +265,16 @@ files:
stickerPack:
type: StickerPack
deserialise: true
# Returned by [GetPagedMessagesCommand]
- name: PagedMessagesResultEvent
extends: BackgroundEvent
implements:
- JsonImplementation
attributes:
messages:
type: List<Message>
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"

View File

@ -261,7 +261,9 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
case conversationRoute: return PageTransition<dynamic>(
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;

View File

@ -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<List<Message>> 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<Message>.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<Conversation> updateConversation(String jid, {

View File

@ -82,6 +82,7 @@ void setupBackgroundEventHandler() {
EventTypeMatcher<FetchStickerPackCommand>(performFetchStickerPack),
EventTypeMatcher<InstallStickerPackCommand>(performStickerPackInstall),
EventTypeMatcher<GetBlocklistCommand>(performGetBlocklist),
EventTypeMatcher<GetPagedMessagesCommand>(performGetPagedMessages),
]);
GetIt.I.registerSingleton<EventHandler>(handler);
@ -1012,3 +1013,21 @@ Future<void> performGetBlocklist(GetBlocklistCommand command, { dynamic extra })
id: id,
);
}
Future<void> performGetPagedMessages(GetPagedMessagesCommand command, { dynamic extra }) async {
final id = extra as String;
final result = await GetIt.I.get<MessageService>().getPaginatedMessagesForJid(
command.conversationJid,
command.oldestMessageTimestamp,
);
sendEvent(
PagedMessagesResultEvent(
messages: result,
// TODO
hasOlderMessages: true,
),
id: id,
);
}

View File

@ -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<String, List<Message>> _messageCache;
final Logger _log;
// TODO(PapaTutuWawa): Maybe remove the cache
final HashMap<String, List<Message>> _messageCache = HashMap();
/// Logger
final Logger _log = Logger('MessageService');
/// Returns the messages for [jid], either from cache or from the database.
Future<List<Message>> getMessagesForJid(String jid) async {
@ -33,6 +35,13 @@ class MessageService {
return messages;
}
Future<List<Message>> getPaginatedMessagesForJid(String jid, int? oldestTimestamp) async {
return GetIt.I.get<DatabaseService>().getPaginatedMessagesForJid(
jid,
oldestTimestamp,
);
}
/// Wrapper around [DatabaseService]'s addMessageFromData that updates the cache.
Future<Message> addMessageFromData(
String body,

View File

@ -1 +1,4 @@
const int timestampNever = -1;
/// The amount of messages that are fetched by a paginated message request
const int paginatedMessageFetchAmount = 30;

View File

@ -44,7 +44,6 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
on<JidBlockedEvent>(_onJidBlocked);
on<JidAddedEvent>(_onJidAdded);
on<CurrentConversationResetEvent>(_onCurrentConversationReset);
on<MessageAddedEvent>(_onMessageAdded);
on<MessageUpdatedEvent>(_onMessageUpdated);
on<ConversationUpdatedEvent>(_onConversationUpdated);
on<AppStateChanged>(_onAppStateChanged);
@ -160,25 +159,23 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
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<NavigationBloc>().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<ConversationEvent, ConversationState> {
conversation: null,
messageText: '',
quotedMessage: null,
messages: [],
),
);
@ -312,16 +308,6 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
);
}
Future<void> _onMessageAdded(MessageAddedEvent event, Emitter<ConversationState> emit) async {
if (!_isMessageForConversation(event.message)) return;
emit(
state.copyWith(
messages: List.from(<Message>[ ...state.messages, event.message ]),
),
);
}
Future<void> _onMessageUpdated(MessageUpdatedEvent event, Emitter<ConversationState> emit) async {
if (!_isMessageForConversation(event.message)) return;
@ -338,19 +324,6 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
}
}
*/
// We don't have to re-sort the list here as timestamps never change
emit(
state.copyWith(
messages: List.from(
state.messages.map<dynamic>((Message m) {
if (m.id == event.message.id) return event.message;
return m;
}),
),
),
);
}
Future<void> _onConversationUpdated(ConversationUpdatedEvent event, Emitter<ConversationState> emit) async {
@ -570,86 +543,88 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
}
Future<void> _onReactionAdded(ReactionAddedEvent event, Emitter<ConversationState> emit) async {
// TODO: Fix
// Check if such a reaction already exists
final message = state.messages[event.index];
final msgs = List<Message>.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<Message>.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<Reaction>.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<Reaction>.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<void> _onReactionRemoved(ReactionRemovedEvent event, Emitter<ConversationState> emit) async {
final message = state.messages[event.index];
final msgs = List<Message>.from(state.messages);
final reactionIndex = message.reactions.indexWhere(
(Reaction r) => r.emoji == event.emoji,
);
// TODO
// final message = state.messages[event.index];
// final msgs = List<Message>.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<Reaction>.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<Reaction>.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<void> _onStickerSent(StickerSentEvent event, Emitter<ConversationState> emit) async {

View File

@ -15,7 +15,6 @@ class ConversationState with _$ConversationState {
@Default('') String messageText,
@Default(defaultSendButtonState) SendButtonState sendButtonState,
@Default(null) Message? quotedMessage,
@Default(<Message>[]) List<Message> messages,
@Default(null) Conversation? conversation,
@Default('') String backgroundPath,
@Default(false) bool pickerVisible,

View File

@ -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<Message> _messageCache = List<Message>.empty(growable: true);
/// Flag indicating whether we are currently fetching messages.
bool isFetchingMessages = false;
final StreamController<bool> _isFetchingStreamController = StreamController();
Stream<bool> 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<List<Message>> _messageStreamController = StreamController();
Stream<List<Message>> 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<void>.delayed(const Duration(milliseconds: 300))
.then((_) => animateToBottom());
} else {
// TODO(PapaTutuWawa): Load the newest page and scroll to it
}
}
Future<void> 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();
}
}

View File

@ -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<bool> speedDialValueNotifier;
final BidirectionalConversationController conversationController;
@override
ConversationBottomRowState createState() => ConversationBottomRowState();
@ -298,6 +301,7 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
context.read<ConversationBloc>().add(
MessageSentEvent(),
);
widget.conversationController.animateToBottom();
widget.controller.text = '';
return;
case SendButtonState.multi:

View File

@ -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<dynamic> get route => MaterialPageRoute<dynamic>(
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<ConversationPage> 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<ConversationPage> with TickerProviderS
late FocusNode _textfieldFocus;
final ValueNotifier<bool> _isSpeedDialOpen = ValueNotifier(false);
late final BidirectionalConversationController _conversationController;
@override
void initState() {
super.initState();
@ -58,7 +59,13 @@ class ConversationPageState extends State<ConversationPage> 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<ConversationPage> 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<ConversationPage> 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<ConversationPage> 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<ConversationPage> with TickerProviderS
},
),
BlocBuilder<ConversationBloc, ConversationState>(
// 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<Message, DateTime>(
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<List<Message>>(
initialData: [],
stream: _conversationController.messageStream,
builder: (context, snapshot) {
if (snapshot.hasData) {
return SingleChildScrollView(
reverse: true,
controller: _conversationController.scrollController,
child: GroupedListView<Message, DateTime>(
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<ConversationBloc>().state,
message,
index,
maxWidth,
),
),
);
}
return CircularProgressIndicator();
},
),
),
@ -543,6 +555,7 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
_controller,
_tabController,
_textfieldFocus,
_conversationController,
_isSpeedDialOpen,
),
),
@ -570,7 +583,7 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
heroTag: 'fabScrollDown',
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
onPressed: () {
_scrollController.jumpTo(0);
_conversationController.animateToBottom();
},
child: const Icon(
Icons.arrow_downward,