feat(meta): Paginate message requests
This commit is contained in:
parent
28591a6787
commit
de2e2f3987
@ -265,6 +265,16 @@ files:
|
|||||||
stickerPack:
|
stickerPack:
|
||||||
type: StickerPack
|
type: StickerPack
|
||||||
deserialise: true
|
deserialise: true
|
||||||
|
# Returned by [GetPagedMessagesCommand]
|
||||||
|
- name: PagedMessagesResultEvent
|
||||||
|
extends: BackgroundEvent
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
messages:
|
||||||
|
type: List<Message>
|
||||||
|
deserialise: true
|
||||||
|
hasOlderMessages: bool
|
||||||
generate_builder: true
|
generate_builder: true
|
||||||
builder_name: "Event"
|
builder_name: "Event"
|
||||||
builder_baseclass: "BackgroundEvent"
|
builder_baseclass: "BackgroundEvent"
|
||||||
@ -545,6 +555,13 @@ files:
|
|||||||
implements:
|
implements:
|
||||||
- JsonImplementation
|
- JsonImplementation
|
||||||
attributes:
|
attributes:
|
||||||
|
- name: GetPagedMessagesCommand
|
||||||
|
extends: BackgroundCommand
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
conversationJid: String
|
||||||
|
oldestMessageTimestamp: int?
|
||||||
generate_builder: true
|
generate_builder: true
|
||||||
# get${builder_Name}FromJson
|
# get${builder_Name}FromJson
|
||||||
builder_name: "Command"
|
builder_name: "Command"
|
||||||
|
@ -261,7 +261,9 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
|||||||
case conversationRoute: return PageTransition<dynamic>(
|
case conversationRoute: return PageTransition<dynamic>(
|
||||||
type: PageTransitionType.rightToLeft,
|
type: PageTransitionType.rightToLeft,
|
||||||
settings: settings,
|
settings: settings,
|
||||||
child: const ConversationPage(),
|
child: ConversationPage(
|
||||||
|
conversationJid: settings.arguments as String,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
case sharedMediaRoute: return SharedMediaPage.route;
|
case sharedMediaRoute: return SharedMediaPage.route;
|
||||||
case blocklistRoute: return BlocklistPage.route;
|
case blocklistRoute: return BlocklistPage.route;
|
||||||
|
@ -41,6 +41,7 @@ import 'package:moxxyv2/service/not_specified.dart';
|
|||||||
import 'package:moxxyv2/service/omemo/omemo.dart';
|
import 'package:moxxyv2/service/omemo/omemo.dart';
|
||||||
import 'package:moxxyv2/service/omemo/types.dart';
|
import 'package:moxxyv2/service/omemo/types.dart';
|
||||||
import 'package:moxxyv2/service/roster.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/conversation.dart';
|
||||||
import 'package:moxxyv2/shared/models/media.dart';
|
import 'package:moxxyv2/shared/models/media.dart';
|
||||||
import 'package:moxxyv2/shared/models/message.dart';
|
import 'package:moxxyv2/shared/models/message.dart';
|
||||||
@ -286,6 +287,39 @@ class DatabaseService {
|
|||||||
|
|
||||||
return messages;
|
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.
|
/// Updates the conversation with JID [jid] inside the database.
|
||||||
Future<Conversation> updateConversation(String jid, {
|
Future<Conversation> updateConversation(String jid, {
|
||||||
|
@ -82,6 +82,7 @@ void setupBackgroundEventHandler() {
|
|||||||
EventTypeMatcher<FetchStickerPackCommand>(performFetchStickerPack),
|
EventTypeMatcher<FetchStickerPackCommand>(performFetchStickerPack),
|
||||||
EventTypeMatcher<InstallStickerPackCommand>(performStickerPackInstall),
|
EventTypeMatcher<InstallStickerPackCommand>(performStickerPackInstall),
|
||||||
EventTypeMatcher<GetBlocklistCommand>(performGetBlocklist),
|
EventTypeMatcher<GetBlocklistCommand>(performGetBlocklist),
|
||||||
|
EventTypeMatcher<GetPagedMessagesCommand>(performGetPagedMessages),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
GetIt.I.registerSingleton<EventHandler>(handler);
|
GetIt.I.registerSingleton<EventHandler>(handler);
|
||||||
@ -1012,3 +1013,21 @@ Future<void> performGetBlocklist(GetBlocklistCommand command, { dynamic extra })
|
|||||||
id: id,
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -14,9 +14,11 @@ import 'package:moxxyv2/shared/models/media.dart';
|
|||||||
import 'package:moxxyv2/shared/models/message.dart';
|
import 'package:moxxyv2/shared/models/message.dart';
|
||||||
|
|
||||||
class MessageService {
|
class MessageService {
|
||||||
MessageService() : _messageCache = HashMap(), _log = Logger('MessageService');
|
// TODO(PapaTutuWawa): Maybe remove the cache
|
||||||
final HashMap<String, List<Message>> _messageCache;
|
final HashMap<String, List<Message>> _messageCache = HashMap();
|
||||||
final Logger _log;
|
|
||||||
|
/// Logger
|
||||||
|
final Logger _log = Logger('MessageService');
|
||||||
|
|
||||||
/// Returns the messages for [jid], either from cache or from the database.
|
/// Returns the messages for [jid], either from cache or from the database.
|
||||||
Future<List<Message>> getMessagesForJid(String jid) async {
|
Future<List<Message>> getMessagesForJid(String jid) async {
|
||||||
@ -33,6 +35,13 @@ class MessageService {
|
|||||||
return messages;
|
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.
|
/// Wrapper around [DatabaseService]'s addMessageFromData that updates the cache.
|
||||||
Future<Message> addMessageFromData(
|
Future<Message> addMessageFromData(
|
||||||
String body,
|
String body,
|
||||||
|
@ -1 +1,4 @@
|
|||||||
const int timestampNever = -1;
|
const int timestampNever = -1;
|
||||||
|
|
||||||
|
/// The amount of messages that are fetched by a paginated message request
|
||||||
|
const int paginatedMessageFetchAmount = 30;
|
||||||
|
@ -44,7 +44,6 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
|||||||
on<JidBlockedEvent>(_onJidBlocked);
|
on<JidBlockedEvent>(_onJidBlocked);
|
||||||
on<JidAddedEvent>(_onJidAdded);
|
on<JidAddedEvent>(_onJidAdded);
|
||||||
on<CurrentConversationResetEvent>(_onCurrentConversationReset);
|
on<CurrentConversationResetEvent>(_onCurrentConversationReset);
|
||||||
on<MessageAddedEvent>(_onMessageAdded);
|
|
||||||
on<MessageUpdatedEvent>(_onMessageUpdated);
|
on<MessageUpdatedEvent>(_onMessageUpdated);
|
||||||
on<ConversationUpdatedEvent>(_onConversationUpdated);
|
on<ConversationUpdatedEvent>(_onConversationUpdated);
|
||||||
on<AppStateChanged>(_onAppStateChanged);
|
on<AppStateChanged>(_onAppStateChanged);
|
||||||
@ -160,25 +159,23 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
|||||||
|
|
||||||
final navEvent = event.removeUntilConversations ? (
|
final navEvent = event.removeUntilConversations ? (
|
||||||
PushedNamedAndRemoveUntilEvent(
|
PushedNamedAndRemoveUntilEvent(
|
||||||
const NavigationDestination(conversationRoute),
|
NavigationDestination(
|
||||||
|
conversationRoute,
|
||||||
|
arguments: event.jid,
|
||||||
|
),
|
||||||
ModalRoute.withName(conversationsRoute),
|
ModalRoute.withName(conversationsRoute),
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
PushedNamedEvent(
|
PushedNamedEvent(
|
||||||
const NavigationDestination(conversationRoute),
|
NavigationDestination(
|
||||||
|
conversationRoute,
|
||||||
|
arguments: event.jid,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
GetIt.I.get<NavigationBloc>().add(navEvent);
|
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(
|
await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||||
SetOpenConversationCommand(jid: event.jid),
|
SetOpenConversationCommand(jid: event.jid),
|
||||||
awaitable: false,
|
awaitable: false,
|
||||||
@ -302,7 +299,6 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
|||||||
conversation: null,
|
conversation: null,
|
||||||
messageText: '',
|
messageText: '',
|
||||||
quotedMessage: null,
|
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 {
|
Future<void> _onMessageUpdated(MessageUpdatedEvent event, Emitter<ConversationState> emit) async {
|
||||||
if (!_isMessageForConversation(event.message)) return;
|
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 {
|
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 {
|
Future<void> _onReactionAdded(ReactionAddedEvent event, Emitter<ConversationState> emit) async {
|
||||||
|
// TODO: Fix
|
||||||
// Check if such a reaction already exists
|
// Check if such a reaction already exists
|
||||||
final message = state.messages[event.index];
|
// final message = state.messages[event.index];
|
||||||
final msgs = List<Message>.from(state.messages);
|
// final msgs = List<Message>.from(state.messages);
|
||||||
final reactionIndex = message.reactions.indexWhere(
|
// final reactionIndex = message.reactions.indexWhere(
|
||||||
(Reaction r) => r.emoji == event.emoji,
|
// (Reaction r) => r.emoji == event.emoji,
|
||||||
);
|
// );
|
||||||
if (reactionIndex != -1) {
|
// if (reactionIndex != -1) {
|
||||||
// Ignore the request when the reaction would be invalid
|
// // Ignore the request when the reaction would be invalid
|
||||||
final reaction = message.reactions[reactionIndex];
|
// final reaction = message.reactions[reactionIndex];
|
||||||
if (reaction.reactedBySelf) return;
|
// if (reaction.reactedBySelf) return;
|
||||||
|
|
||||||
final reactions = List<Reaction>.from(message.reactions);
|
// final reactions = List<Reaction>.from(message.reactions);
|
||||||
reactions[reactionIndex] = reaction.copyWith(
|
// reactions[reactionIndex] = reaction.copyWith(
|
||||||
reactedBySelf: true,
|
// reactedBySelf: true,
|
||||||
);
|
// );
|
||||||
msgs[event.index] = message.copyWith(
|
// msgs[event.index] = message.copyWith(
|
||||||
reactions: reactions,
|
// reactions: reactions,
|
||||||
);
|
// );
|
||||||
} else {
|
// } else {
|
||||||
// The reaction is new
|
// // The reaction is new
|
||||||
msgs[event.index] = message.copyWith(
|
// msgs[event.index] = message.copyWith(
|
||||||
reactions: [
|
// reactions: [
|
||||||
...message.reactions,
|
// ...message.reactions,
|
||||||
Reaction(
|
// Reaction(
|
||||||
[],
|
// [],
|
||||||
event.emoji,
|
// event.emoji,
|
||||||
true,
|
// true,
|
||||||
),
|
// ),
|
||||||
],
|
// ],
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
|
|
||||||
emit(
|
// emit(
|
||||||
state.copyWith(
|
// state.copyWith(
|
||||||
messages: msgs,
|
// messages: msgs,
|
||||||
),
|
// ),
|
||||||
);
|
// );
|
||||||
|
|
||||||
await MoxplatformPlugin.handler.getDataSender().sendData(
|
// await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||||
AddReactionToMessageCommand(
|
// AddReactionToMessageCommand(
|
||||||
messageId: message.id,
|
// messageId: message.id,
|
||||||
emoji: event.emoji,
|
// emoji: event.emoji,
|
||||||
conversationJid: message.conversationJid,
|
// conversationJid: message.conversationJid,
|
||||||
),
|
// ),
|
||||||
awaitable: false,
|
// awaitable: false,
|
||||||
);
|
// );
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onReactionRemoved(ReactionRemovedEvent event, Emitter<ConversationState> emit) async {
|
Future<void> _onReactionRemoved(ReactionRemovedEvent event, Emitter<ConversationState> emit) async {
|
||||||
final message = state.messages[event.index];
|
// TODO
|
||||||
final msgs = List<Message>.from(state.messages);
|
// final message = state.messages[event.index];
|
||||||
final reactionIndex = message.reactions.indexWhere(
|
// final msgs = List<Message>.from(state.messages);
|
||||||
(Reaction r) => r.emoji == event.emoji,
|
// final reactionIndex = message.reactions.indexWhere(
|
||||||
);
|
// (Reaction r) => r.emoji == event.emoji,
|
||||||
|
// );
|
||||||
|
|
||||||
// We assume that reactionIndex >= 0
|
// // We assume that reactionIndex >= 0
|
||||||
assert(reactionIndex >= 0, 'The reaction must be found');
|
// assert(reactionIndex >= 0, 'The reaction must be found');
|
||||||
final reactions = List<Reaction>.from(message.reactions);
|
// final reactions = List<Reaction>.from(message.reactions);
|
||||||
if (message.reactions[reactionIndex].senders.isEmpty) {
|
// if (message.reactions[reactionIndex].senders.isEmpty) {
|
||||||
reactions.removeAt(reactionIndex);
|
// reactions.removeAt(reactionIndex);
|
||||||
} else {
|
// } else {
|
||||||
reactions[reactionIndex] = reactions[reactionIndex].copyWith(
|
// reactions[reactionIndex] = reactions[reactionIndex].copyWith(
|
||||||
reactedBySelf: false,
|
// reactedBySelf: false,
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
msgs[event.index] = message.copyWith(reactions: reactions);
|
// msgs[event.index] = message.copyWith(reactions: reactions);
|
||||||
emit(
|
// emit(
|
||||||
state.copyWith(
|
// state.copyWith(
|
||||||
messages: msgs,
|
// messages: msgs,
|
||||||
),
|
// ),
|
||||||
);
|
// );
|
||||||
|
|
||||||
await MoxplatformPlugin.handler.getDataSender().sendData(
|
// await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||||
RemoveReactionFromMessageCommand(
|
// RemoveReactionFromMessageCommand(
|
||||||
messageId: message.id,
|
// messageId: message.id,
|
||||||
emoji: event.emoji,
|
// emoji: event.emoji,
|
||||||
conversationJid: message.conversationJid,
|
// conversationJid: message.conversationJid,
|
||||||
),
|
// ),
|
||||||
awaitable: false,
|
// awaitable: false,
|
||||||
);
|
// );
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onStickerSent(StickerSentEvent event, Emitter<ConversationState> emit) async {
|
Future<void> _onStickerSent(StickerSentEvent event, Emitter<ConversationState> emit) async {
|
||||||
|
@ -15,7 +15,6 @@ class ConversationState with _$ConversationState {
|
|||||||
@Default('') String messageText,
|
@Default('') String messageText,
|
||||||
@Default(defaultSendButtonState) SendButtonState sendButtonState,
|
@Default(defaultSendButtonState) SendButtonState sendButtonState,
|
||||||
@Default(null) Message? quotedMessage,
|
@Default(null) Message? quotedMessage,
|
||||||
@Default(<Message>[]) List<Message> messages,
|
|
||||||
@Default(null) Conversation? conversation,
|
@Default(null) Conversation? conversation,
|
||||||
@Default('') String backgroundPath,
|
@Default('') String backgroundPath,
|
||||||
@Default(false) bool pickerVisible,
|
@Default(false) bool pickerVisible,
|
||||||
|
114
lib/ui/controller/conversation_controller.dart
Normal file
114
lib/ui/controller/conversation_controller.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
@ -10,6 +10,7 @@ import 'package:moxxyv2/i18n/strings.g.dart';
|
|||||||
import 'package:moxxyv2/shared/helpers.dart';
|
import 'package:moxxyv2/shared/helpers.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/constants.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/helpers.dart';
|
||||||
import 'package:moxxyv2/ui/pages/conversation/blink.dart';
|
import 'package:moxxyv2/ui/pages/conversation/blink.dart';
|
||||||
import 'package:moxxyv2/ui/pages/conversation/timer.dart';
|
import 'package:moxxyv2/ui/pages/conversation/timer.dart';
|
||||||
@ -93,6 +94,7 @@ class ConversationBottomRow extends StatefulWidget {
|
|||||||
this.controller,
|
this.controller,
|
||||||
this.tabController,
|
this.tabController,
|
||||||
this.focusNode,
|
this.focusNode,
|
||||||
|
this.conversationController,
|
||||||
this.speedDialValueNotifier, {
|
this.speedDialValueNotifier, {
|
||||||
super.key,
|
super.key,
|
||||||
}
|
}
|
||||||
@ -101,6 +103,7 @@ class ConversationBottomRow extends StatefulWidget {
|
|||||||
final TabController tabController;
|
final TabController tabController;
|
||||||
final FocusNode focusNode;
|
final FocusNode focusNode;
|
||||||
final ValueNotifier<bool> speedDialValueNotifier;
|
final ValueNotifier<bool> speedDialValueNotifier;
|
||||||
|
final BidirectionalConversationController conversationController;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConversationBottomRowState createState() => ConversationBottomRowState();
|
ConversationBottomRowState createState() => ConversationBottomRowState();
|
||||||
@ -298,6 +301,7 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
|
|||||||
context.read<ConversationBloc>().add(
|
context.read<ConversationBloc>().add(
|
||||||
MessageSentEvent(),
|
MessageSentEvent(),
|
||||||
);
|
);
|
||||||
|
widget.conversationController.animateToBottom();
|
||||||
widget.controller.text = '';
|
widget.controller.text = '';
|
||||||
return;
|
return;
|
||||||
case SendButtonState.multi:
|
case SendButtonState.multi:
|
||||||
|
@ -13,6 +13,7 @@ import 'package:moxxyv2/shared/models/message.dart';
|
|||||||
import 'package:moxxyv2/shared/warning_types.dart';
|
import 'package:moxxyv2/shared/warning_types.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/constants.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/helpers.dart';
|
||||||
import 'package:moxxyv2/ui/pages/conversation/blink.dart';
|
import 'package:moxxyv2/ui/pages/conversation/blink.dart';
|
||||||
import 'package:moxxyv2/ui/pages/conversation/bottom.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';
|
import 'package:moxxyv2/ui/widgets/overview_menu.dart';
|
||||||
|
|
||||||
class ConversationPage extends StatefulWidget {
|
class ConversationPage extends StatefulWidget {
|
||||||
const ConversationPage({ super.key });
|
const ConversationPage({
|
||||||
|
required this.conversationJid,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
/// The JID of the current conversation
|
||||||
builder: (context) => const ConversationPage(),
|
final String conversationJid;
|
||||||
settings: const RouteSettings(
|
|
||||||
name: conversationRoute,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConversationPageState createState() => ConversationPageState();
|
ConversationPageState createState() => ConversationPageState();
|
||||||
@ -40,7 +40,6 @@ class ConversationPage extends StatefulWidget {
|
|||||||
|
|
||||||
class ConversationPageState extends State<ConversationPage> with TickerProviderStateMixin {
|
class ConversationPageState extends State<ConversationPage> with TickerProviderStateMixin {
|
||||||
final TextEditingController _controller = TextEditingController();
|
final TextEditingController _controller = TextEditingController();
|
||||||
final ScrollController _scrollController = ScrollController();
|
|
||||||
late final AnimationController _animationController;
|
late final AnimationController _animationController;
|
||||||
late final AnimationController _overviewAnimationController;
|
late final AnimationController _overviewAnimationController;
|
||||||
late final TabController _tabController;
|
late final TabController _tabController;
|
||||||
@ -50,6 +49,8 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
|||||||
late FocusNode _textfieldFocus;
|
late FocusNode _textfieldFocus;
|
||||||
final ValueNotifier<bool> _isSpeedDialOpen = ValueNotifier(false);
|
final ValueNotifier<bool> _isSpeedDialOpen = ValueNotifier(false);
|
||||||
|
|
||||||
|
late final BidirectionalConversationController _conversationController;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@ -58,7 +59,13 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
|||||||
vsync: this,
|
vsync: this,
|
||||||
);
|
);
|
||||||
_textfieldFocus = FocusNode();
|
_textfieldFocus = FocusNode();
|
||||||
_scrollController.addListener(_onScroll);
|
|
||||||
|
// TODO
|
||||||
|
_conversationController = BidirectionalConversationController(
|
||||||
|
widget.conversationJid,
|
||||||
|
);
|
||||||
|
_conversationController.scrollController.addListener(_onScroll);
|
||||||
|
_conversationController.fetchOlderMessages();
|
||||||
|
|
||||||
_overviewAnimationController = AnimationController(
|
_overviewAnimationController = AnimationController(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
@ -80,9 +87,7 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_tabController.dispose();
|
_tabController.dispose();
|
||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
_scrollController
|
_conversationController.dispose();
|
||||||
..removeListener(_onScroll)
|
|
||||||
..dispose();
|
|
||||||
_animationController.dispose();
|
_animationController.dispose();
|
||||||
_overviewAnimationController.dispose();
|
_overviewAnimationController.dispose();
|
||||||
_textfieldFocus.dispose();
|
_textfieldFocus.dispose();
|
||||||
@ -133,10 +138,13 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
|||||||
|
|
||||||
final start = index - 1 < 0 ?
|
final start = index - 1 < 0 ?
|
||||||
true :
|
true :
|
||||||
isSent(state.messages[index - 1], state.jid) != isSent(item, state.jid);
|
false;
|
||||||
final end = index + 1 >= state.messages.length ?
|
// isSent(state.messages[index - 1], state.jid) != isSent(item, state.jid);
|
||||||
true :
|
// final end = index + 1 >= state.messages.length ?
|
||||||
isSent(state.messages[index + 1], state.jid) != isSent(item, state.jid);
|
// true :
|
||||||
|
// false;
|
||||||
|
final end = true;
|
||||||
|
// isSent(state.messages[index + 1], state.jid) != isSent(item, state.jid);
|
||||||
final between = !start && !end;
|
final between = !start && !end;
|
||||||
final sentBySelf = isSent(message, state.jid);
|
final sentBySelf = isSent(message, state.jid);
|
||||||
|
|
||||||
@ -401,9 +409,9 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
|||||||
|
|
||||||
/// Taken from https://bloclibrary.dev/#/flutterinfinitelisttutorial
|
/// Taken from https://bloclibrary.dev/#/flutterinfinitelisttutorial
|
||||||
bool _isScrolledToBottom() {
|
bool _isScrolledToBottom() {
|
||||||
if (!_scrollController.hasClients) return false;
|
if (!_conversationController.scrollController.hasClients) return false;
|
||||||
|
|
||||||
return _scrollController.offset <= 10;
|
return _conversationController.scrollController.offset <= 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onScroll() {
|
void _onScroll() {
|
||||||
@ -495,43 +503,47 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
BlocBuilder<ConversationBloc, ConversationState>(
|
Expanded(
|
||||||
// NOTE: We don't need to update when the jid changes as it should
|
child: StreamBuilder<List<Message>>(
|
||||||
// be static over the entire lifetime of the BLoC.
|
initialData: [],
|
||||||
buildWhen: (prev, next) => prev.messages != next.messages || prev.conversation?.encrypted != next.conversation?.encrypted,
|
stream: _conversationController.messageStream,
|
||||||
builder: (context, state) => Expanded(
|
builder: (context, snapshot) {
|
||||||
// Inspired by https://github.com/SimformSolutionsPvtLtd/flutter_chatview/blob/main/lib/src/widgets/chat_groupedlist_widget.dart
|
if (snapshot.hasData) {
|
||||||
child: SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
reverse: true,
|
reverse: true,
|
||||||
controller: _scrollController,
|
controller: _conversationController.scrollController,
|
||||||
child: GroupedListView<Message, DateTime>(
|
child: GroupedListView<Message, DateTime>(
|
||||||
elements: state.messages,
|
elements: snapshot.data!,
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
groupBy: (message) {
|
groupBy: (message) {
|
||||||
final dt = DateTime.fromMillisecondsSinceEpoch(message.timestamp);
|
final dt = DateTime.fromMillisecondsSinceEpoch(message.timestamp);
|
||||||
return DateTime(
|
return DateTime(
|
||||||
dt.year,
|
dt.year,
|
||||||
dt.month,
|
dt.month,
|
||||||
dt.day,
|
dt.day,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
groupSeparatorBuilder: (DateTime dt) => Row(
|
groupSeparatorBuilder: (DateTime dt) => Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
DateBubble(
|
DateBubble(
|
||||||
formatDateBubble(dt, DateTime.now()),
|
formatDateBubble(dt, DateTime.now()),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
indexedItemBuilder: (context, message, index) => _renderBubble(
|
||||||
),
|
context.read<ConversationBloc>().state,
|
||||||
indexedItemBuilder: (context, message, index) => _renderBubble(
|
message,
|
||||||
state,
|
index,
|
||||||
message,
|
maxWidth,
|
||||||
index,
|
),
|
||||||
maxWidth,
|
),
|
||||||
),
|
);
|
||||||
),
|
}
|
||||||
),
|
|
||||||
|
return CircularProgressIndicator();
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
@ -543,6 +555,7 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
|||||||
_controller,
|
_controller,
|
||||||
_tabController,
|
_tabController,
|
||||||
_textfieldFocus,
|
_textfieldFocus,
|
||||||
|
_conversationController,
|
||||||
_isSpeedDialOpen,
|
_isSpeedDialOpen,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -570,7 +583,7 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
|||||||
heroTag: 'fabScrollDown',
|
heroTag: 'fabScrollDown',
|
||||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
_scrollController.jumpTo(0);
|
_conversationController.animateToBottom();
|
||||||
},
|
},
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
Icons.arrow_downward,
|
Icons.arrow_downward,
|
||||||
|
Loading…
Reference in New Issue
Block a user