Files
moxxy/lib/ui/controller/conversation_controller.dart

576 lines
16 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:flutter/widgets.dart';
import 'package:flutter_vibrate/flutter_vibrate.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:logging/logging.dart';
import 'package:moxplatform/moxplatform.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/constants.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/shared/models/sticker.dart' as sticker;
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart' as conversation;
import 'package:moxxyv2/ui/controller/bidirectional_controller.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:record/record.dart';
class MessageEditingState {
const MessageEditingState(
this.id,
this.sid,
this.originalBody,
this.quoted,
);
/// The message's original body
final String originalBody;
/// The message's database id
final int id;
/// 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 RecordingData {
const RecordingData(
this.isRecording,
this.isLocked,
);
/// Flag indicating whether we are currently recording (true) or not (false).
final bool isRecording;
/// Flag indicating whether the recording draggable is locked (true) or not (false).
final bool isLocked;
}
class BidirectionalConversationController
extends BidirectionalController<Message> {
BidirectionalConversationController(
this.conversationJid,
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);
}
/// 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;
/// 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();
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;
/// Flag indicating whether we are currently recording an audio message (true) or not
/// (false).
final Record _audioRecorder = Record();
DateTime? _recordingStart;
final StreamController<RecordingData> _recordingAudioMessageStreamController =
StreamController<RecordingData>.broadcast();
Stream<RecordingData> get recordingAudioMessageStream =>
_recordingAudioMessageStreamController.stream;
void _updateChatState(ChatState state) {
MoxplatformPlugin.handler.getDataSender().sendData(
SendChatStateCommand(
state: state.toString().split('.').last,
jid: conversationJid,
),
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) {
MoxplatformPlugin.handler.getDataSender().sendData(
RetractMessageCommentCommand(
originId: originId,
conversationJid: conversationJid,
),
awaitable: false,
);
}
/// Send the sticker [sticker].
void sendSticker(sticker.Sticker sticker) {
MoxplatformPlugin.handler.getDataSender().sendData(
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 MoxplatformPlugin.handler.getDataSender().sendData(
SendMessageCommand(
recipients: [conversationJid],
body: text,
quotedMessage: _quotedMessage,
chatState: ChatState.active.toName(),
editId: _messageEditingState?.id,
editSid: _messageEditingState?.sid,
currentConversationJid: conversationJid,
),
awaitable: true,
) 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 MoxplatformPlugin.handler.getDataSender().sendData(
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 MoxplatformPlugin.handler.getDataSender().sendData(
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,
int id,
String sid,
) {
_log.fine('Beginning editing for id: $id, sid: $sid');
_messageEditingState = MessageEditingState(
id,
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);
}
Future<void> startAudioMessageRecording() async {
final status = await Permission.speech.status;
if (status.isDenied) {
await Permission.speech.request();
return;
}
_recordingAudioMessageStreamController.add(
const RecordingData(
true,
false,
),
);
_sendButtonStreamController.add(conversation.SendButtonState.hidden);
final now = DateTime.now();
_recordingStart = now;
final tempDir = await getTemporaryDirectory();
final timestamp =
'${now.year}${now.month}${now.day}${now.hour}${now.minute}${now.second}';
final tempFile = path.join(tempDir.path, 'audio_$timestamp.aac');
await _audioRecorder.start(
path: tempFile,
);
}
void lockAudioMessageRecording() {
_recordingAudioMessageStreamController.add(
const RecordingData(
true,
true,
),
);
}
Future<void> cancelAudioMessageRecording() async {
Vibrate.feedback(FeedbackType.heavy);
_recordingAudioMessageStreamController.add(
const RecordingData(
false,
false,
),
);
_sendButtonStreamController.add(conversation.defaultSendButtonState);
_recordingStart = null;
final file = await _audioRecorder.stop();
unawaited(File(file!).delete());
}
Future<void> endAudioMessageRecording() async {
_recordingAudioMessageStreamController.add(
const RecordingData(
false,
false,
),
);
_sendButtonStreamController.add(conversation.defaultSendButtonState);
if (_recordingStart == null) {
return;
}
Vibrate.feedback(FeedbackType.heavy);
final file = await _audioRecorder.stop();
final now = DateTime.now();
if (now.difference(_recordingStart!).inSeconds < 1) {
_recordingStart = null;
unawaited(File(file!).delete());
await Fluttertoast.showToast(
msg: t.warnings.conversation.holdForLonger,
gravity: ToastGravity.SNACKBAR,
toastLength: Toast.LENGTH_SHORT,
);
return;
}
// Reset the recording timestamp
_recordingStart = null;
// Handle something unexpected
if (file == null) {
await Fluttertoast.showToast(
msg: t.errors.conversation.audioRecordingError,
gravity: ToastGravity.SNACKBAR,
toastLength: Toast.LENGTH_SHORT,
);
return;
}
// Send the file
await MoxplatformPlugin.handler.getDataSender().sendData(
SendFilesCommand(
paths: [file],
recipients: [conversationJid],
),
awaitable: false,
);
}
/// React to app livecycle changes
void handleAppStateChange(bool open) {
_updateChatState(
open ? ChatState.active : ChatState.gone,
);
}
@override
void dispose() {
// Reset the singleton
BidirectionalConversationController.currentController = null;
// Dispose of controllers
_textController.dispose();
_audioRecorder.dispose();
// Tell the contact that we're gone
_updateChatState(ChatState.gone);
super.dispose();
}
}