467 lines
13 KiB
Dart
467 lines
13 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/widgets.dart';
|
|
import 'package:logging/logging.dart';
|
|
import 'package:moxxmpp/moxxmpp.dart';
|
|
import 'package:moxxy_native/moxxy_native.dart';
|
|
import 'package:moxxyv2/shared/commands.dart';
|
|
import 'package:moxxyv2/shared/constants.dart';
|
|
import 'package:moxxyv2/shared/events.dart';
|
|
import 'package:moxxyv2/shared/models/conversation.dart';
|
|
import 'package:moxxyv2/shared/models/message.dart';
|
|
import 'package:moxxyv2/shared/models/sticker.dart' as sticker;
|
|
import 'package:moxxyv2/ui/controller/bidirectional_controller.dart';
|
|
import 'package:moxxyv2/ui/state/conversation.dart' as conversation;
|
|
import 'package:moxxyv2/ui/widgets/messaging_textfield/controller.dart';
|
|
|
|
class MessageEditingState {
|
|
const MessageEditingState(
|
|
this.sid,
|
|
this.originalBody,
|
|
this.quoted,
|
|
);
|
|
|
|
/// The message's original body
|
|
final String originalBody;
|
|
|
|
/// The message's stanza id.
|
|
final String sid;
|
|
|
|
/// The message the message quoted
|
|
final Message? quoted;
|
|
}
|
|
|
|
class TextFieldData {
|
|
const TextFieldData(
|
|
this.isBodyEmpty,
|
|
this.quotedMessage,
|
|
);
|
|
|
|
/// Flag indicating whether the current text input is empty.
|
|
final bool isBodyEmpty;
|
|
|
|
/// The currently quoted message.
|
|
final Message? quotedMessage;
|
|
}
|
|
|
|
class BidirectionalConversationController
|
|
extends BidirectionalController<Message> {
|
|
BidirectionalConversationController(
|
|
this.conversationJid,
|
|
this.conversationType,
|
|
this.focusNode, {
|
|
String? initialText,
|
|
}) : assert(
|
|
BidirectionalConversationController.currentController == null,
|
|
'There can only be one BidirectionalConversationController',
|
|
),
|
|
super(
|
|
pageSize: messagePaginationSize,
|
|
maxPageAmount: maxMessagePages,
|
|
) {
|
|
_textController.addListener(_handleTextChanged);
|
|
if (initialText != null) {
|
|
_textController.text = initialText;
|
|
}
|
|
|
|
BidirectionalConversationController.currentController = this;
|
|
|
|
_updateChatState(ChatState.active);
|
|
|
|
// Set up the controller for audio messages.
|
|
messagingController = MobileMessagingTextFieldController(conversationJid);
|
|
messagingController.isRecordingNotifier.addListener(_onRecordingChanged);
|
|
}
|
|
|
|
/// Logging.
|
|
final Logger _log = Logger('BidirectionalConversationController');
|
|
|
|
/// A singleton referring to the current instance as there can only be one
|
|
/// BidirectionalConversationController at a time.
|
|
static BidirectionalConversationController? currentController;
|
|
|
|
/// TextEditingController for the TextField
|
|
final TextEditingController _textController = TextEditingController();
|
|
TextEditingController get textController => _textController;
|
|
|
|
/// The focus node of the textfield used for message text input. Useful for
|
|
/// forcing focus after selecting a message for editing.
|
|
final FocusNode focusNode;
|
|
|
|
/// Stream for SendButtonState updates
|
|
final StreamController<conversation.SendButtonState>
|
|
_sendButtonStreamController = StreamController();
|
|
Stream<conversation.SendButtonState> get sendButtonStream =>
|
|
_sendButtonStreamController.stream;
|
|
|
|
/// The JID of the current chat
|
|
final String conversationJid;
|
|
|
|
/// The type of the current conversation
|
|
final ConversationType conversationType;
|
|
|
|
/// Data about a message we're editing
|
|
MessageEditingState? _messageEditingState;
|
|
|
|
/// Flag indicating whether we are scrolled to the bottom or not.
|
|
bool _scrolledToBottomState = true;
|
|
final StreamController<bool> _scrollToBottomStateStreamController =
|
|
StreamController();
|
|
Stream<bool> get scrollToBottomStateStream =>
|
|
_scrollToBottomStateStreamController.stream;
|
|
|
|
/// The currently quoted message
|
|
Message? _quotedMessage;
|
|
|
|
/// Stream containing data for the TextField
|
|
final StreamController<TextFieldData> _textFieldDataStreamController =
|
|
StreamController.broadcast();
|
|
Stream<TextFieldData> get textFieldDataStream =>
|
|
_textFieldDataStreamController.stream;
|
|
|
|
/// The timer for managing the "compose" state
|
|
Timer? _composeTimer;
|
|
|
|
/// The last time the TextField was modified
|
|
int _lastChangeTimestamp = 0;
|
|
|
|
/// The controller for audio message recording
|
|
late final MobileMessagingTextFieldController messagingController;
|
|
|
|
void _onRecordingChanged() {
|
|
_sendButtonStreamController.add(
|
|
messagingController.isRecordingNotifier.value
|
|
? conversation.SendButtonState.sendVoiceMessage
|
|
: conversation.defaultSendButtonState,
|
|
);
|
|
}
|
|
|
|
void _updateChatState(ChatState state) {
|
|
if (conversationType != ConversationType.chat) {
|
|
return;
|
|
}
|
|
|
|
getForegroundService().send(
|
|
SendChatStateCommand(
|
|
state: state.toString().split('.').last,
|
|
jid: conversationJid,
|
|
conversationType: conversationType.value,
|
|
),
|
|
awaitable: false,
|
|
);
|
|
}
|
|
|
|
void _startComposeTimer() {
|
|
if (_composeTimer != null) return;
|
|
|
|
_updateChatState(ChatState.composing);
|
|
_composeTimer = Timer.periodic(
|
|
const Duration(seconds: 3),
|
|
(_) {
|
|
final now = DateTime.now().millisecondsSinceEpoch;
|
|
if (now - _lastChangeTimestamp >= 3000) {
|
|
// No change since 3 seconds
|
|
_stopComposeTimer();
|
|
_updateChatState(ChatState.active);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
void _stopComposeTimer() {
|
|
if (_composeTimer == null) return;
|
|
|
|
_composeTimer?.cancel();
|
|
_composeTimer = null;
|
|
}
|
|
|
|
void _handleTextChanged() {
|
|
final text = _textController.text;
|
|
if (_messageEditingState != null) {
|
|
_sendButtonStreamController.add(
|
|
text == _messageEditingState?.originalBody
|
|
? conversation.SendButtonState.cancelCorrection
|
|
: conversation.SendButtonState.send,
|
|
);
|
|
} else {
|
|
_sendButtonStreamController.add(
|
|
text.isEmpty
|
|
? conversation.defaultSendButtonState
|
|
: conversation.SendButtonState.send,
|
|
);
|
|
}
|
|
|
|
_textFieldDataStreamController.add(
|
|
TextFieldData(
|
|
messageBody.isEmpty,
|
|
_quotedMessage,
|
|
),
|
|
);
|
|
|
|
_lastChangeTimestamp = DateTime.now().millisecondsSinceEpoch;
|
|
_startComposeTimer();
|
|
}
|
|
|
|
@override
|
|
void handleScroll() {
|
|
super.handleScroll();
|
|
|
|
if (isScrolledToBottom && !_scrolledToBottomState && !hasNewerData) {
|
|
_scrolledToBottomState = true;
|
|
_scrollToBottomStateStreamController.add(false);
|
|
} else if (!isScrolledToBottom && _scrolledToBottomState) {
|
|
_scrolledToBottomState = false;
|
|
_scrollToBottomStateStreamController.add(true);
|
|
}
|
|
}
|
|
|
|
String get messageBody => _textController.text;
|
|
|
|
Future<void> onMessageReceived(Message message) async {
|
|
// Drop the message if we don't really care about it
|
|
if (message.conversationJid != conversationJid) {
|
|
_log.finest(
|
|
"Not processing message as JIDs don't match: ${message.conversationJid} != $conversationJid",
|
|
);
|
|
return;
|
|
}
|
|
|
|
// TODO(Unknown): This is probably not the best solution
|
|
if (isFetching) {
|
|
_log.finest('Not processing message as we are currently fetching');
|
|
return;
|
|
}
|
|
|
|
var shouldScrollToBottom = true;
|
|
if (cache.isEmpty && hasFetchedOnce) {
|
|
// We do this check here to prevent a StateException being thrown because
|
|
// the cache is empty. So just add the message.
|
|
addItem(message);
|
|
|
|
// As this is the first message, we don't have to scroll to the bottom.
|
|
shouldScrollToBottom = false;
|
|
} else if (message.timestamp < cache.last.timestamp) {
|
|
if (message.timestamp < cache.first.timestamp) {
|
|
// The message is older than the oldest message we know about. Drop it.
|
|
// It will be fetched when scrolling up.
|
|
hasOlderData = true;
|
|
return;
|
|
}
|
|
|
|
// Insert the message at the appropriate place
|
|
shouldScrollToBottom = addItemWhereFirst(
|
|
(item, next) {
|
|
if (next == null) return false;
|
|
|
|
return item.timestamp <= message.timestamp &&
|
|
next.timestamp >= message.timestamp;
|
|
},
|
|
message,
|
|
);
|
|
} else {
|
|
// Just add the new message
|
|
addItem(message);
|
|
}
|
|
|
|
// Scroll to bottom if we're at the bottom
|
|
if (isScrolledToBottom && shouldScrollToBottom) {
|
|
await Future<void>.delayed(const Duration(milliseconds: 300));
|
|
animateToBottom();
|
|
}
|
|
}
|
|
|
|
void onMessageUpdated(Message newMessage) {
|
|
// Ignore message updates for messages in chats that are not open.
|
|
if (newMessage.conversationJid != conversationJid) return;
|
|
|
|
// Ignore message updates for messages older than the oldest message
|
|
// we know about.
|
|
if (newMessage.timestamp < cache.first.timestamp) return;
|
|
|
|
replaceItem(
|
|
(msg) => msg.id == newMessage.id,
|
|
newMessage,
|
|
);
|
|
}
|
|
|
|
/// Retract the message with originId [originId].
|
|
void retractMessage(String originId) {
|
|
getForegroundService().send(
|
|
RetractMessageCommentCommand(
|
|
originId: originId,
|
|
conversationJid: conversationJid,
|
|
),
|
|
awaitable: false,
|
|
);
|
|
}
|
|
|
|
/// Send the sticker [sticker].
|
|
void sendSticker(sticker.Sticker sticker) {
|
|
getForegroundService().send(
|
|
SendStickerCommand(
|
|
sticker: sticker,
|
|
recipient: conversationJid,
|
|
quotes: _quotedMessage,
|
|
),
|
|
awaitable: false,
|
|
);
|
|
|
|
// Remove a possible quote
|
|
removeQuote();
|
|
}
|
|
|
|
Future<void> sendMessage(bool encrypted) async {
|
|
// Stop the compose timer
|
|
_stopComposeTimer();
|
|
|
|
// Reset the text field
|
|
final text = _textController.text;
|
|
assert(text.isNotEmpty, 'Cannot send empty text messages');
|
|
_textController.text = '';
|
|
|
|
// Add message to the database and send it
|
|
// ignore: cast_nullable_to_non_nullable
|
|
final result = await getForegroundService().send(
|
|
SendMessageCommand(
|
|
recipients: [conversationJid],
|
|
body: text,
|
|
quotedMessage: _quotedMessage,
|
|
chatState: ChatState.active.toName(),
|
|
editSid: _messageEditingState?.sid,
|
|
currentConversationJid: conversationJid,
|
|
),
|
|
) as MessageAddedEvent;
|
|
|
|
// Reset the message editing state
|
|
final wasEditing = _messageEditingState != null;
|
|
_messageEditingState = null;
|
|
|
|
// Reset the quote
|
|
removeQuote();
|
|
|
|
var foundMessage = false;
|
|
if (!hasNewerData) {
|
|
if (wasEditing) {
|
|
foundMessage = replaceItem(
|
|
(message) => message.id == result.message.id,
|
|
result.message,
|
|
);
|
|
} else {
|
|
addItem(result.message);
|
|
foundMessage = false;
|
|
}
|
|
|
|
if (foundMessage) {
|
|
await Future<void>.delayed(const Duration(milliseconds: 300));
|
|
animateToBottom();
|
|
}
|
|
} else {
|
|
// TODO(PapaTutuWawa): Load the newest page and scroll to it
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<List<Message>> fetchOlderDataImpl(Message? oldestElement) async {
|
|
// ignore: cast_nullable_to_non_nullable
|
|
final result = await getForegroundService().send(
|
|
GetPagedMessagesCommand(
|
|
conversationJid: conversationJid,
|
|
timestamp: oldestElement?.timestamp,
|
|
olderThan: true,
|
|
),
|
|
) as PagedMessagesResultEvent;
|
|
|
|
return result.messages.reversed.toList();
|
|
}
|
|
|
|
@override
|
|
Future<List<Message>> fetchNewerDataImpl(Message? newestElement) async {
|
|
// ignore: cast_nullable_to_non_nullable
|
|
final result = await getForegroundService().send(
|
|
GetPagedMessagesCommand(
|
|
conversationJid: conversationJid,
|
|
timestamp: newestElement?.timestamp,
|
|
olderThan: false,
|
|
),
|
|
) as PagedMessagesResultEvent;
|
|
|
|
return result.messages.reversed.toList();
|
|
}
|
|
|
|
/// Quote [message] for a message.
|
|
void quoteMessage(Message message) {
|
|
_quotedMessage = message;
|
|
_textFieldDataStreamController.add(
|
|
TextFieldData(
|
|
messageBody.isEmpty,
|
|
message,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Remove the currently active quote.
|
|
void removeQuote() {
|
|
_quotedMessage = null;
|
|
_textFieldDataStreamController.add(
|
|
TextFieldData(
|
|
messageBody.isEmpty,
|
|
null,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Enter the "edit mode" for a message.
|
|
void beginMessageEditing(
|
|
String originalBody,
|
|
Message? quotes,
|
|
String sid,
|
|
) {
|
|
_log.fine('Beginning editing for $sid');
|
|
|
|
_messageEditingState = MessageEditingState(
|
|
sid,
|
|
originalBody,
|
|
quotes,
|
|
);
|
|
_textController.text = originalBody;
|
|
if (quotes != null) {
|
|
quoteMessage(quotes);
|
|
}
|
|
|
|
_sendButtonStreamController
|
|
.add(conversation.SendButtonState.cancelCorrection);
|
|
|
|
// Focus the textfield.
|
|
focusNode.requestFocus();
|
|
}
|
|
|
|
/// Exit the "edit mode" for a message.
|
|
void endMessageEditing() {
|
|
_messageEditingState = null;
|
|
_textController.text = '';
|
|
|
|
_sendButtonStreamController.add(conversation.defaultSendButtonState);
|
|
}
|
|
|
|
/// React to app livecycle changes
|
|
void handleAppStateChange(bool open) {
|
|
_updateChatState(
|
|
open ? ChatState.active : ChatState.gone,
|
|
);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
// Reset the singleton
|
|
BidirectionalConversationController.currentController = null;
|
|
|
|
messagingController.isRecordingNotifier.removeListener(_onRecordingChanged);
|
|
|
|
// Dispose of controllers
|
|
_textController.dispose();
|
|
messagingController.dispose();
|
|
|
|
super.dispose();
|
|
}
|
|
}
|