Compare commits

...

2 Commits

17 changed files with 206 additions and 23 deletions

View File

@ -260,6 +260,8 @@ files:
quotedMessage:
type: Message?
deserialise: true
editSid: String?
editId: int?
- name: SendFilesCommand
extends: BackgroundCommand
implements:

View File

@ -52,6 +52,7 @@ Future<void> createDatabase(Database db, int version) async {
isUploading INTEGER NOT NULL,
mediaSize INTEGER,
isRetracted INTEGER,
isEdited INTEGER NOT NULL,
CONSTRAINT fk_quote FOREIGN KEY (quote_id) REFERENCES $messagesTable (id)
)''',
);

View File

@ -12,6 +12,7 @@ import 'package:moxxyv2/service/database/migrations/0000_conversations.dart';
import 'package:moxxyv2/service/database/migrations/0000_conversations2.dart';
import 'package:moxxyv2/service/database/migrations/0000_conversations3.dart';
import 'package:moxxyv2/service/database/migrations/0000_language.dart';
import 'package:moxxyv2/service/database/migrations/0000_lmc.dart';
import 'package:moxxyv2/service/database/migrations/0000_retraction.dart';
import 'package:moxxyv2/service/database/migrations/0000_retraction_conversation.dart';
import 'package:moxxyv2/service/database/migrations/0000_shared_media.dart';
@ -61,7 +62,7 @@ class DatabaseService {
_db = await openDatabase(
dbPath,
password: key,
version: 9,
version: 10,
onCreate: createDatabase,
onConfigure: (db) async {
// In order to do schema changes during database upgrades, we disable foreign
@ -106,6 +107,10 @@ class DatabaseService {
_log.finest('Running migration for database version 9');
await upgradeFromV8ToV9(db);
}
if (oldVersion < 10) {
_log.finest('Running migration for database version 10');
await upgradeFromV9ToV10(db);
}
},
);
@ -451,6 +456,7 @@ class DatabaseService {
Object? sid = notSpecified,
bool? isRetracted,
Object? thumbnailData = notSpecified,
bool? isEdited,
}) async {
final md = (await _db.query(
'Messages',
@ -460,6 +466,9 @@ class DatabaseService {
)).first;
final m = Map<String, dynamic>.from(md);
if (body != notSpecified) {
m['body'] = body as String?;
}
if (mediaUrl != notSpecified) {
m['mediaUrl'] = mediaUrl as String?;
}
@ -526,6 +535,9 @@ class DatabaseService {
if (thumbnailData != notSpecified) {
m['thumbnailData'] = thumbnailData as String?;
}
if (isEdited != null) {
m['isEdited'] = boolToInt(isEdited);
}
await _db.update(
'Messages',

View File

@ -0,0 +1,11 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/shared/models/preference.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV9ToV10(Database db) async {
// Mark all messages as not edited
await db.execute(
'ALTER TABLE $messagesTable ADD COLUMN isEdited INTEGER NOT NULL DEFAULT ${boolToInt(false)};',
);
}

View File

@ -247,7 +247,23 @@ Future<void> performSetOpenConversation(SetOpenConversationCommand command, { dy
}
Future<void> performSendMessage(SendMessageCommand command, { dynamic extra }) async {
await GetIt.I.get<XmppService>().sendMessage(
final xs = GetIt.I.get<XmppService>();
if (command.editSid != null && command.editId != null) {
assert(command.recipients.length == 1, 'Edits must not be sent to multiple recipients');
await xs.sendMessageCorrection(
command.editId!,
command.body,
command.editSid!,
command.recipients.first,
command.chatState.isNotEmpty
? chatStateFromString(command.chatState)
: null,
);
return;
}
await xs.sendMessage(
body: command.body,
recipients: command.recipients,
chatState: command.chatState.isNotEmpty

View File

@ -151,6 +151,7 @@ class MessageService {
Object? sid = notSpecified,
Object? thumbnailData = notSpecified,
bool? isRetracted,
bool? isEdited,
}) async {
final newMessage = await GetIt.I.get<DatabaseService>().updateMessage(
id,
@ -177,6 +178,7 @@ class MessageService {
isRetracted: isRetracted,
isMedia: isMedia,
thumbnailData: thumbnailData,
isEdited: isEdited,
);
if (_messageCache.containsKey(newMessage.conversationJid)) {

View File

@ -124,6 +124,49 @@ class XmppService {
/// Returns the JID of the chat that is currently opened. Null, if none is open.
String? getCurrentlyOpenedChatJid() => _currentlyOpenedChatJid;
/// Sends a message correction to [recipient] regarding the message with stanza id
/// [oldId]. The old message's body gets corrected to [newBody]. [id] is the message's
/// database id. [chatState] can be optionally specified to also include a chat state
/// in the message.
///
/// This function handles updating the message and optionally the corresponding
/// conversation.
Future<void> sendMessageCorrection(int id, String newBody, String oldId, String recipient, ChatState? chatState) async {
final ms = GetIt.I.get<MessageService>();
final cs = GetIt.I.get<ConversationService>();
final conn = GetIt.I.get<XmppConnection>();
final timestamp = DateTime.now().millisecondsSinceEpoch;
// Update the database
final msg = await ms.updateMessage(
id,
isEdited: true,
body: newBody,
);
sendEvent(MessageUpdatedEvent(message: msg));
final conv = await cs.getConversationByJid(msg.conversationJid);
if (conv != null && conv.lastMessage?.id == id) {
final newConv = await cs.updateConversation(
conv.id,
lastChangeTimestamp: timestamp,
lastMessage: msg,
);
cs.setConversation(newConv);
sendEvent(ConversationUpdatedEvent(conversation: newConv));
}
// Send the correction
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
MessageDetails(
to: recipient,
body: newBody,
lastMessageCorrectionId: oldId,
chatState: chatState,
),
);
}
/// Sends a message to JIDs in [recipients] with the body of [body].
Future<void> sendMessage({

View File

@ -54,6 +54,7 @@ class Message with _$Message {
@Default(false) bool displayed,
@Default(false) bool acked,
@Default(false) bool isRetracted,
@Default(false) bool isEdited,
String? originId,
Message? quotes,
String? filename,
@ -82,6 +83,7 @@ class Message with _$Message {
'isDownloading': intToBool(json['isDownloading']! as int),
'isUploading': intToBool(json['isUploading']! as int),
'isRetracted': intToBool(json['isRetracted']! as int),
'isEdited': intToBool(json['isEdited']! as int),
}).copyWith(quotes: quotes);
}
@ -105,6 +107,7 @@ class Message with _$Message {
'isDownloading': boolToInt(isDownloading),
'isUploading': boolToInt(isUploading),
'isRetracted': boolToInt(isRetracted),
'isEdited': boolToInt(isEdited),
};
}

View File

@ -46,6 +46,8 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
on<OwnJidReceivedEvent>(_onOwnJidReceived);
on<OmemoSetEvent>(_onOmemoSet);
on<MessageRetractedEvent>(_onMessageRetracted);
on<MessageEditSelectedEvent>(_onMessageEditSelected);
on<MessageEditCancelledEvent>(_onMessageEditCancelled);
}
/// The current chat state with the conversation partner
ChatState _currentChatState;
@ -115,6 +117,11 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
state.copyWith(
conversation: conversation,
quotedMessage: null,
messageEditing: false,
messageEditingOriginalBody: '',
messageEditingId: null,
messageEditingSid: null,
showSendButton: false,
),
);
@ -174,24 +181,33 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
_stopComposeTimer();
// ignore: cast_nullable_to_non_nullable
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
final r = await MoxplatformPlugin.handler.getDataSender().sendData(
SendMessageCommand(
recipients: [state.conversation!.jid],
body: state.messageText,
quotedMessage: state.quotedMessage,
chatState: chatStateToString(ChatState.active),
),
) as events.MessageAddedEvent;
emit(
state.copyWith(
messages: List<Message>.from(<Message>[ ...state.messages, result.message ]),
messageText: '',
quotedMessage: null,
showSendButton: false,
emojiPickerVisible: false,
editId: state.messageEditingId,
editSid: state.messageEditingSid,
),
);
if (!state.messageEditing) {
final result = r! as events.MessageAddedEvent;
emit(
state.copyWith(
messages: List<Message>.from(<Message>[ ...state.messages, result.message ]),
messageText: '',
quotedMessage: null,
showSendButton: false,
emojiPickerVisible: false,
messageEditing: false,
messageEditingOriginalBody: '',
messageEditingId: null,
messageEditingSid: null,
),
);
}
}
Future<void> _onMessageQuoted(MessageQuotedEvent event, Emitter<ConversationState> emit) async {
@ -352,4 +368,31 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
awaitable: false,
);
}
Future<void> _onMessageEditSelected(MessageEditSelectedEvent event, Emitter<ConversationState> emit) async {
emit(
state.copyWith(
messageText: event.message.body,
quotedMessage: event.message.quotes,
messageEditing: true,
messageEditingOriginalBody: event.message.body,
messageEditingId: event.message.id,
messageEditingSid: event.message.sid,
),
);
}
Future<void> _onMessageEditCancelled(MessageEditCancelledEvent event, Emitter<ConversationState> emit) async {
emit(
state.copyWith(
messageText: '',
quotedMessage: null,
messageEditing: false,
messageEditingOriginalBody: '',
messageEditingId: null,
messageEditingSid: null,
showSendButton: false,
),
);
}
}

View File

@ -120,3 +120,14 @@ class MessageRetractedEvent extends ConversationEvent {
MessageRetractedEvent(this.id);
final String id;
}
/// Triggered when a message has been selected for editing
class MessageEditSelectedEvent extends ConversationEvent {
MessageEditSelectedEvent(this.message);
final Message message;
}
/// Triggered when a message edit has been cancelled
class MessageEditCancelledEvent extends ConversationEvent {
MessageEditCancelledEvent();
}

View File

@ -12,5 +12,9 @@ class ConversationState with _$ConversationState {
@Default(null) Conversation? conversation,
@Default('') String backgroundPath,
@Default(false) bool emojiPickerVisible,
@Default(false) bool messageEditing,
@Default('') String messageEditingOriginalBody,
@Default(null) String? messageEditingSid,
@Default(null) int? messageEditingId,
}) = _ConversationState;
}

View File

@ -24,6 +24,16 @@ class ConversationBottomRow extends StatelessWidget {
return Colors.black;
}
bool _shouldCancelEdit(ConversationState state) {
return state.messageEditing && controller.text == state.messageEditingOriginalBody;
}
IconData _getSpeeddialIcon(ConversationState state) {
if (_shouldCancelEdit(state)) return Icons.clear;
return state.showSendButton ? Icons.send : Icons.add;
}
@override
Widget build(BuildContext context) {
@ -34,7 +44,7 @@ class ConversationBottomRow extends StatelessWidget {
Padding(
padding: const EdgeInsets.all(8),
child: BlocBuilder<ConversationBloc, ConversationState>(
buildWhen: (prev, next) => prev.showSendButton != next.showSendButton || prev.quotedMessage != next.quotedMessage || prev.emojiPickerVisible != next.emojiPickerVisible || prev.messageText != next.messageText,
buildWhen: (prev, next) => prev.showSendButton != next.showSendButton || prev.quotedMessage != next.quotedMessage || prev.emojiPickerVisible != next.emojiPickerVisible || prev.messageText != next.messageText || prev.messageEditing != next.messageEditing || prev.messageEditingOriginalBody != next.messageEditingOriginalBody,
builder: (context, state) => Row(
children: [
Expanded(
@ -131,13 +141,19 @@ class ConversationBottomRow extends StatelessWidget {
width: 45,
child: FittedBox(
child: SpeedDial(
icon: state.showSendButton ? Icons.send : Icons.add,
icon: _getSpeeddialIcon(state),
curve: Curves.bounceInOut,
backgroundColor: primaryColor,
// TODO(Unknown): Theme dependent?
foregroundColor: Colors.white,
openCloseDial: isSpeedDialOpen,
onPress: () {
if (_shouldCancelEdit(state)) {
context.read<ConversationBloc>().add(MessageEditCancelledEvent());
controller.text = '';
return;
}
if (state.showSendButton) {
context.read<ConversationBloc>().add(MessageSentEvent());
controller.text = '';

View File

@ -179,15 +179,17 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
onPressed: () => _retractMessage(context, item.originId!),
),
] : [],
...item.canEdit(sentBySelf) ? [
// TODO(Unknown): Also allow correcting older messages
...item.canEdit(sentBySelf) && state.conversation!.lastMessage?.id == item.id ? [
OverviewMenuItem(
icon: Icons.edit,
text: t.pages.conversation.edit,
onPressed: () {
showNotImplementedDialog(
'editing',
context,
context.read<ConversationBloc>().add(
MessageEditSelectedEvent(item),
);
_controller.text = item.body;
Navigator.of(context).pop();
},
),
] : [],

View File

@ -87,6 +87,15 @@ class MessageBubbleBottomState extends State<MessageBubbleBottom> {
),
),
),
...widget.message.isEdited ? [
const Padding(
padding: EdgeInsets.only(left: 3),
child: Icon(
Icons.edit,
size: _bubbleBottomIconSize,
),
),
] : [],
...widget.message.encrypted ? [
const Padding(
padding: EdgeInsets.only(left: 3),

View File

@ -130,6 +130,14 @@
<xmpp:version>1.0.0</xmpp:version>
</xmpp:SupportedXep>
</implements>
<implements>
<xmpp:SupportedXep>
<xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0308.html" />
<xmpp:status>partial</xmpp:status>
<xmpp:note xml:lang="en">Supports only sending corrections</xmpp:note>
<xmpp:version>1.2.1</xmpp:version>
</xmpp:SupportedXep>
</implements>
<implements>
<xmpp:SupportedXep>
<xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0333.html" />

View File

@ -718,14 +718,14 @@ packages:
name: moxxmpp
url: "https://git.polynom.me/api/packages/Moxxy/pub/"
source: hosted
version: "0.1.5"
version: "0.1.6"
moxxmpp_socket_tcp:
dependency: "direct main"
description:
name: moxxmpp_socket_tcp
url: "https://git.polynom.me/api/packages/Moxxy/pub/"
source: hosted
version: "0.1.2+7"
version: "0.1.2+8"
moxxyv2_builders:
dependency: "direct main"
description:

View File

@ -59,10 +59,10 @@ dependencies:
version: 0.1.15
moxxmpp:
hosted: https://git.polynom.me/api/packages/Moxxy/pub
version: 0.1.5
version: 0.1.6
moxxmpp_socket_tcp:
hosted: https://git.polynom.me/api/packages/Moxxy/pub
version: 0.1.2+7
version: 0.1.2+8
moxxyv2_builders:
hosted: https://git.polynom.me/api/packages/Moxxy/pub
version: 0.1.0