Compare commits
14 Commits
ca90c658ff
...
9a0bc87636
Author | SHA1 | Date | |
---|---|---|---|
9a0bc87636 | |||
d73d27dccc | |||
6fa5e73226 | |||
1ff9ea256b | |||
7fca7e0246 | |||
846270b714 | |||
50e7c5683f | |||
6883a9570f | |||
8f34bc001d | |||
2f95e5452b | |||
59a6307a21 | |||
c8d52e6c41 | |||
044766bf8a | |||
1f7c851228 |
@ -115,6 +115,7 @@
|
||||
"edit": "Edit",
|
||||
"quote": "Quote",
|
||||
"copy": "Copy content",
|
||||
"addReaction": "Add reaction",
|
||||
"showError": "Show error",
|
||||
"showWarning": "Show warning",
|
||||
"addToContacts": "Add to contacts",
|
||||
|
@ -115,6 +115,7 @@
|
||||
"edit": "Bearbeiten",
|
||||
"quote": "Zitieren",
|
||||
"copy": "Inhalt kopieren",
|
||||
"addReaction": "Reaktion hinzufügen",
|
||||
"showError": "Fehler anzeigen",
|
||||
"showWarning": "Warnung anzeigen",
|
||||
"addToContacts": "Zu Kontaken hinzufügen",
|
||||
|
@ -419,6 +419,22 @@ files:
|
||||
conversationJid: String
|
||||
sid: String
|
||||
newUnreadCounter: int
|
||||
- name: AddReactionToMessageCommand
|
||||
extends: BackgroundCommand
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
messageId: int
|
||||
conversationJid: String
|
||||
emoji: String
|
||||
- name: RemoveReactionFromMessageCommand
|
||||
extends: BackgroundCommand
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
messageId: int
|
||||
conversationJid: String
|
||||
emoji: String
|
||||
generate_builder: true
|
||||
# get${builder_Name}FromJson
|
||||
builder_name: "Command"
|
||||
|
@ -53,6 +53,8 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
mediaSize INTEGER,
|
||||
isRetracted INTEGER,
|
||||
isEdited INTEGER NOT NULL,
|
||||
reactions TEXT NOT NULL,
|
||||
containsNoStore INTEGER NOT NULL,
|
||||
CONSTRAINT fk_quote FOREIGN KEY (quote_id) REFERENCES $messagesTable (id)
|
||||
)''',
|
||||
);
|
||||
|
@ -13,6 +13,8 @@ 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_reactions.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0000_reactions_store_hint.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';
|
||||
@ -25,6 +27,7 @@ import 'package:moxxyv2/shared/models/conversation.dart';
|
||||
import 'package:moxxyv2/shared/models/media.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/shared/models/preferences.dart';
|
||||
import 'package:moxxyv2/shared/models/reaction.dart';
|
||||
import 'package:moxxyv2/shared/models/roster.dart';
|
||||
import 'package:omemo_dart/omemo_dart.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
@ -62,7 +65,7 @@ class DatabaseService {
|
||||
_db = await openDatabase(
|
||||
dbPath,
|
||||
password: key,
|
||||
version: 10,
|
||||
version: 12,
|
||||
onCreate: createDatabase,
|
||||
onConfigure: (db) async {
|
||||
// In order to do schema changes during database upgrades, we disable foreign
|
||||
@ -111,6 +114,14 @@ class DatabaseService {
|
||||
_log.finest('Running migration for database version 10');
|
||||
await upgradeFromV9ToV10(db);
|
||||
}
|
||||
if (oldVersion < 11) {
|
||||
_log.finest('Running migration for database version 11');
|
||||
await upgradeFromV10ToV11(db);
|
||||
}
|
||||
if (oldVersion < 12) {
|
||||
_log.finest('Running migration for database version 12');
|
||||
await upgradeFromV11ToV12(db);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@ -317,6 +328,7 @@ class DatabaseService {
|
||||
String sid,
|
||||
bool isFileUploadNotification,
|
||||
bool encrypted,
|
||||
bool containsNoStore,
|
||||
{
|
||||
String? srcUrl,
|
||||
String? key,
|
||||
@ -349,6 +361,7 @@ class DatabaseService {
|
||||
isMedia,
|
||||
isFileUploadNotification,
|
||||
encrypted,
|
||||
containsNoStore,
|
||||
errorType: errorType,
|
||||
warningType: warningType,
|
||||
mediaUrl: mediaUrl,
|
||||
@ -457,6 +470,7 @@ class DatabaseService {
|
||||
bool? isRetracted,
|
||||
Object? thumbnailData = notSpecified,
|
||||
bool? isEdited,
|
||||
Object? reactions = notSpecified,
|
||||
}) async {
|
||||
final md = (await _db.query(
|
||||
'Messages',
|
||||
@ -538,6 +552,14 @@ class DatabaseService {
|
||||
if (isEdited != null) {
|
||||
m['isEdited'] = boolToInt(isEdited);
|
||||
}
|
||||
if (reactions != notSpecified) {
|
||||
assert(reactions != null, 'Cannot set reactions to null');
|
||||
m['reactions'] = jsonEncode(
|
||||
(reactions! as List<Reaction>)
|
||||
.map((r) => r.toJson())
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
await _db.update(
|
||||
'Messages',
|
||||
|
8
lib/service/database/migrations/0000_reactions.dart
Normal file
8
lib/service/database/migrations/0000_reactions.dart
Normal file
@ -0,0 +1,8 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV10ToV11(Database db) async {
|
||||
await db.execute(
|
||||
"ALTER TABLE $messagesTable ADD COLUMN reactions TEXT NOT NULL DEFAULT '[]';"
|
||||
);
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV11ToV12(Database db) async {
|
||||
await db.execute(
|
||||
'ALTER TABLE $messagesTable ADD COLUMN containsNoStore INTEGER NOT NULL DEFAULT ${boolToInt(false)};'
|
||||
);
|
||||
}
|
@ -29,6 +29,7 @@ import 'package:moxxyv2/shared/eventhandler.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
import 'package:moxxyv2/shared/models/preferences.dart';
|
||||
import 'package:moxxyv2/shared/models/reaction.dart';
|
||||
import 'package:moxxyv2/shared/synchronized_queue.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
@ -66,6 +67,8 @@ void setupBackgroundEventHandler() {
|
||||
EventTypeMatcher<RetractMessageCommentCommand>(performMessageRetraction),
|
||||
EventTypeMatcher<MarkConversationAsReadCommand>(performMarkConversationAsRead),
|
||||
EventTypeMatcher<MarkMessageAsReadCommand>(performMarkMessageAsRead),
|
||||
EventTypeMatcher<AddReactionToMessageCommand>(performAddMessageReaction),
|
||||
EventTypeMatcher<RemoveReactionFromMessageCommand>(performRemoveMessageReaction),
|
||||
]);
|
||||
|
||||
GetIt.I.registerSingleton<EventHandler>(handler);
|
||||
@ -654,3 +657,80 @@ Future<void> performMarkMessageAsRead(MarkMessageAsReadCommand command, { dynami
|
||||
command.sid,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> performAddMessageReaction(AddReactionToMessageCommand command, { dynamic extra }) async {
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
final conn = GetIt.I.get<XmppConnection>();
|
||||
final msg = await ms.getMessageById(command.conversationJid, command.messageId);
|
||||
assert(msg != null, 'The message must be found');
|
||||
|
||||
// Update the state
|
||||
final reactions = List<Reaction>.from(msg!.reactions);
|
||||
final i = reactions.indexWhere((r) => r.emoji == command.emoji);
|
||||
if (i == -1) {
|
||||
reactions.add(Reaction([], command.emoji, true));
|
||||
} else {
|
||||
reactions[i] = reactions[i].copyWith(reactedBySelf: true);
|
||||
}
|
||||
await ms.updateMessage(msg.id, reactions: reactions);
|
||||
|
||||
// Collect all our reactions
|
||||
final ownReactions = reactions
|
||||
.where((r) => r.reactedBySelf)
|
||||
.map((r) => r.emoji)
|
||||
.toList();
|
||||
|
||||
// Send the reaction
|
||||
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
||||
MessageDetails(
|
||||
to: command.conversationJid,
|
||||
messageReactions: MessageReactions(
|
||||
msg.originId ?? msg.sid,
|
||||
ownReactions,
|
||||
),
|
||||
requestChatMarkers: false,
|
||||
messageProcessingHints: !msg.containsNoStore ?
|
||||
[MessageProcessingHint.store] :
|
||||
null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> performRemoveMessageReaction(RemoveReactionFromMessageCommand command, { dynamic extra }) async {
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
final conn = GetIt.I.get<XmppConnection>();
|
||||
final msg = await ms.getMessageById(command.conversationJid, command.messageId);
|
||||
assert(msg != null, 'The message must be found');
|
||||
|
||||
// Update the state
|
||||
final reactions = List<Reaction>.from(msg!.reactions);
|
||||
final i = reactions.indexWhere((r) => r.emoji == command.emoji);
|
||||
assert(i >= -1, 'The reaction must be found');
|
||||
if (reactions[i].senders.isEmpty) {
|
||||
reactions.removeAt(i);
|
||||
} else {
|
||||
reactions[i] = reactions[i].copyWith(reactedBySelf: false);
|
||||
}
|
||||
await ms.updateMessage(msg.id, reactions: reactions);
|
||||
|
||||
// Collect all our reactions
|
||||
final ownReactions = reactions
|
||||
.where((r) => r.reactedBySelf)
|
||||
.map((r) => r.emoji)
|
||||
.toList();
|
||||
|
||||
// Send the reaction
|
||||
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
||||
MessageDetails(
|
||||
to: command.conversationJid,
|
||||
messageReactions: MessageReactions(
|
||||
msg.originId ?? msg.sid,
|
||||
ownReactions,
|
||||
),
|
||||
requestChatMarkers: false,
|
||||
messageProcessingHints: !msg.containsNoStore ?
|
||||
[MessageProcessingHint.store] :
|
||||
null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -43,6 +43,7 @@ class MessageService {
|
||||
String sid,
|
||||
bool isFileUploadNotification,
|
||||
bool encrypted,
|
||||
bool containsNoStore,
|
||||
{
|
||||
String? srcUrl,
|
||||
String? key,
|
||||
@ -74,6 +75,7 @@ class MessageService {
|
||||
sid,
|
||||
isFileUploadNotification,
|
||||
encrypted,
|
||||
containsNoStore,
|
||||
srcUrl: srcUrl,
|
||||
key: key,
|
||||
iv: iv,
|
||||
@ -115,6 +117,17 @@ class MessageService {
|
||||
);
|
||||
}
|
||||
|
||||
Future<Message?> getMessageByStanzaOrOriginId(String conversationJid, String id) async {
|
||||
if (!_messageCache.containsKey(conversationJid)) {
|
||||
await getMessagesForJid(conversationJid);
|
||||
}
|
||||
|
||||
return firstWhereOrNull(
|
||||
_messageCache[conversationJid]!,
|
||||
(message) => message.sid == id || message.originId == id,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Message?> getMessageById(String conversationJid, int id) async {
|
||||
if (!_messageCache.containsKey(conversationJid)) {
|
||||
await getMessagesForJid(conversationJid);
|
||||
@ -152,6 +165,7 @@ class MessageService {
|
||||
Object? thumbnailData = notSpecified,
|
||||
bool? isRetracted,
|
||||
bool? isEdited,
|
||||
Object? reactions = notSpecified,
|
||||
}) async {
|
||||
final newMessage = await GetIt.I.get<DatabaseService>().updateMessage(
|
||||
id,
|
||||
@ -179,6 +193,7 @@ class MessageService {
|
||||
isMedia: isMedia,
|
||||
thumbnailData: thumbnailData,
|
||||
isEdited: isEdited,
|
||||
reactions: reactions,
|
||||
);
|
||||
|
||||
if (_messageCache.containsKey(newMessage.conversationJid)) {
|
||||
|
@ -198,6 +198,7 @@ Future<void> entrypoint() async {
|
||||
DelayedDeliveryManager(),
|
||||
MessageRetractionManager(),
|
||||
LastMessageCorrectionManager(),
|
||||
MessageReactionsManager(),
|
||||
])
|
||||
..registerFeatureNegotiators([
|
||||
ResourceBindingNegotiator(),
|
||||
|
@ -33,6 +33,7 @@ import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
import 'package:moxxyv2/shared/models/media.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/shared/models/reaction.dart';
|
||||
import 'package:path/path.dart' as pathlib;
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
@ -194,6 +195,8 @@ class XmppService {
|
||||
sid,
|
||||
false,
|
||||
conversation!.encrypted,
|
||||
// TODO(Unknown): Maybe make this depend on some setting
|
||||
false,
|
||||
originId: originId,
|
||||
quoteId: quotedMessage?.sid,
|
||||
);
|
||||
@ -436,6 +439,8 @@ class XmppService {
|
||||
conn.generateId(),
|
||||
false,
|
||||
encrypt[recipient]!,
|
||||
// TODO(Unknown): Maybe make this depend on some setting
|
||||
false,
|
||||
mediaUrl: path,
|
||||
mediaType: pathMime,
|
||||
originId: conn.generateId(),
|
||||
@ -942,6 +947,104 @@ class XmppService {
|
||||
sendEvent(ConversationUpdatedEvent(conversation: newConv));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleMessageReactions(MessageEvent event, String conversationJid) async {
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
// TODO(Unknown): Once we support groupchats, we need to instead query by the stanza-id
|
||||
final msg = await ms.getMessageByStanzaOrOriginId(
|
||||
conversationJid,
|
||||
event.messageReactions!.messageId,
|
||||
);
|
||||
if (msg == null) {
|
||||
_log.warning('Received reactions for ${event.messageReactions!.messageId} from ${event.fromJid} for $conversationJid, but could not find message.');
|
||||
return;
|
||||
}
|
||||
|
||||
final state = await getXmppState();
|
||||
final sender = event.fromJid.toBare().toString();
|
||||
final isCarbon = sender == state.jid;
|
||||
final reactions = List<Reaction>.from(msg.reactions);
|
||||
final emojis = event.messageReactions!.emojis;
|
||||
|
||||
// Find out what emojis the sender has already sent
|
||||
final sentEmojis = msg.reactions
|
||||
.where((r) {
|
||||
return isCarbon ?
|
||||
r.reactedBySelf :
|
||||
r.senders.contains(sender);
|
||||
})
|
||||
.map((r) => r.emoji)
|
||||
.toList();
|
||||
// Find out what reactions were removed
|
||||
final removedEmojis = sentEmojis
|
||||
.where((e) => !emojis.contains(e));
|
||||
|
||||
for (final emoji in emojis) {
|
||||
final i = reactions.indexWhere((r) => r.emoji == emoji);
|
||||
if (i == -1) {
|
||||
reactions.add(
|
||||
Reaction(
|
||||
isCarbon ?
|
||||
[] :
|
||||
[sender],
|
||||
emoji,
|
||||
isCarbon,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
List<String> senders;
|
||||
if (isCarbon) {
|
||||
senders = reactions[i].senders;
|
||||
} else {
|
||||
// Ensure that we don't add a sender multiple times to the same reaction
|
||||
if (reactions[i].senders.contains(sender)) {
|
||||
senders = reactions[i].senders;
|
||||
} else {
|
||||
senders = [
|
||||
...reactions[i].senders,
|
||||
sender,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
reactions[i] = reactions[i].copyWith(
|
||||
senders: senders,
|
||||
reactedBySelf: isCarbon ?
|
||||
true :
|
||||
reactions[i].reactedBySelf,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (final emoji in removedEmojis) {
|
||||
final i = reactions.indexWhere((r) => r.emoji == emoji);
|
||||
assert(i >= -1, 'The reaction must exist');
|
||||
|
||||
if (isCarbon && reactions[i].senders.isEmpty ||
|
||||
!isCarbon && reactions[i].senders.length == 1 && !reactions[i].reactedBySelf) {
|
||||
reactions.removeAt(i);
|
||||
} else {
|
||||
reactions[i] = reactions[i].copyWith(
|
||||
senders: isCarbon ?
|
||||
reactions[i].senders :
|
||||
reactions[i].senders
|
||||
.where((s) => s != sender)
|
||||
.toList(),
|
||||
reactedBySelf: isCarbon ?
|
||||
false :
|
||||
reactions[i].reactedBySelf,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final newMessage = await ms.updateMessage(
|
||||
msg.id,
|
||||
reactions: reactions,
|
||||
);
|
||||
sendEvent(
|
||||
MessageUpdatedEvent(message: newMessage),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onMessage(MessageEvent event, { dynamic extra }) async {
|
||||
// The jid this message event is meant for
|
||||
@ -974,6 +1077,12 @@ class XmppService {
|
||||
await _handleMessageRetraction(event, conversationJid);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle message reactions
|
||||
if (event.messageReactions != null) {
|
||||
await _handleMessageReactions(event, conversationJid);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop the processing here if the event does not describe a displayable message
|
||||
if (!_isMessageEventMessage(event) && event.other['encryption_error'] == null) return;
|
||||
@ -1038,6 +1147,7 @@ class XmppService {
|
||||
event.sid,
|
||||
event.fun != null,
|
||||
event.encrypted,
|
||||
event.messageProcessingHints?.contains(MessageProcessingHint.noStore) ?? false,
|
||||
srcUrl: embeddedFile?.url,
|
||||
filename: event.fun?.name ?? embeddedFile?.filename,
|
||||
key: embeddedFile?.keyBase64,
|
||||
|
@ -76,10 +76,9 @@ class Conversation with _$Conversation {
|
||||
'subscription': subscription,
|
||||
'encrypted': intToBool(json['encrypted']! as int),
|
||||
'chatState': const ConversationChatStateConverter().toJson(ChatState.gone),
|
||||
'lastMessage': <String, dynamic>{
|
||||
'message': lastMessage?.toJson(),
|
||||
},
|
||||
});
|
||||
}).copyWith(
|
||||
lastMessage: lastMessage,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toDatabaseJson() {
|
||||
|
@ -3,6 +3,7 @@ import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:moxxyv2/shared/error_types.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
import 'package:moxxyv2/shared/models/reaction.dart';
|
||||
import 'package:moxxyv2/shared/warning_types.dart';
|
||||
|
||||
part 'message.freezed.dart';
|
||||
@ -22,20 +23,20 @@ String? _optionalJsonEncode(Map<String, String>? data) {
|
||||
|
||||
@freezed
|
||||
class Message with _$Message {
|
||||
// NOTE: id is the database id of the message
|
||||
// NOTE: isMedia is for telling the UI that this message contains the URL for media but the path is not yet available
|
||||
// NOTE: srcUrl is the Url that a file has been or can be downloaded from
|
||||
|
||||
factory Message(
|
||||
String sender,
|
||||
String body,
|
||||
int timestamp,
|
||||
String sid,
|
||||
// The database-internal identifier of the message
|
||||
int id,
|
||||
String conversationJid,
|
||||
// True if the message contains some embedded media
|
||||
bool isMedia,
|
||||
bool isFileUploadNotification,
|
||||
bool encrypted,
|
||||
// True if the message contains a <no-store> Message Processing Hint. False if not
|
||||
bool containsNoStore,
|
||||
{
|
||||
int? errorType,
|
||||
int? warningType,
|
||||
@ -46,6 +47,7 @@ class Message with _$Message {
|
||||
String? thumbnailData,
|
||||
int? mediaWidth,
|
||||
int? mediaHeight,
|
||||
// If non-null: Indicates where some media entry originated/originates from
|
||||
String? srcUrl,
|
||||
String? key,
|
||||
String? iv,
|
||||
@ -61,6 +63,7 @@ class Message with _$Message {
|
||||
Map<String, String>? plaintextHashes,
|
||||
Map<String, String>? ciphertextHashes,
|
||||
int? mediaSize,
|
||||
@Default([]) List<Reaction> reactions,
|
||||
}
|
||||
) = _Message;
|
||||
|
||||
@ -84,13 +87,22 @@ class Message with _$Message {
|
||||
'isUploading': intToBool(json['isUploading']! as int),
|
||||
'isRetracted': intToBool(json['isRetracted']! as int),
|
||||
'isEdited': intToBool(json['isEdited']! as int),
|
||||
}).copyWith(quotes: quotes);
|
||||
'containsNoStore': intToBool(json['containsNoStore']! as int),
|
||||
'reactions': <Map<String, dynamic>>[],
|
||||
}).copyWith(
|
||||
quotes: quotes,
|
||||
reactions: (jsonDecode(json['reactions']! as String) as List<dynamic>)
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map<Reaction>(Reaction.fromJson)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toDatabaseJson() {
|
||||
final map = toJson()
|
||||
..remove('id')
|
||||
..remove('quotes');
|
||||
..remove('quotes')
|
||||
..remove('reactions');
|
||||
|
||||
return {
|
||||
...map,
|
||||
@ -108,6 +120,12 @@ class Message with _$Message {
|
||||
'isUploading': boolToInt(isUploading),
|
||||
'isRetracted': boolToInt(isRetracted),
|
||||
'isEdited': boolToInt(isEdited),
|
||||
'containsNoStore': boolToInt(containsNoStore),
|
||||
'reactions': jsonEncode(
|
||||
reactions
|
||||
.map((r) => r.toJson())
|
||||
.toList(),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@ -132,6 +150,9 @@ class Message with _$Message {
|
||||
return originId != null && sentBySelf && !isFileUploadNotification && !isUploading && !isDownloading;
|
||||
}
|
||||
|
||||
/// Returns true if we can send a reaction for this message.
|
||||
bool get isReactable => !hasError && !isRetracted && !isFileUploadNotification && !isUploading && !isDownloading;
|
||||
|
||||
/// Returns true if the message can be edited. False if not.
|
||||
/// [sentBySelf] asks whether or not the message was sent by us (the current Jid).
|
||||
bool canEdit(bool sentBySelf) {
|
||||
|
26
lib/shared/models/reaction.dart
Normal file
26
lib/shared/models/reaction.dart
Normal file
@ -0,0 +1,26 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'reaction.freezed.dart';
|
||||
part 'reaction.g.dart';
|
||||
|
||||
@freezed
|
||||
class Reaction with _$Reaction {
|
||||
factory Reaction(
|
||||
List<String> senders,
|
||||
String emoji,
|
||||
// NOTE: Store this with the model to prevent having to to a O(n) search across the
|
||||
// list of reactions on every rebuild
|
||||
bool reactedBySelf,
|
||||
) = _Reaction;
|
||||
|
||||
const Reaction._();
|
||||
|
||||
/// JSON
|
||||
factory Reaction.fromJson(Map<String, dynamic> json) => _$ReactionFromJson(json);
|
||||
|
||||
int get reactions {
|
||||
if (reactedBySelf) return senders.length + 1;
|
||||
|
||||
return senders.length;
|
||||
}
|
||||
}
|
@ -15,6 +15,7 @@ import 'package:moxxyv2/shared/commands.dart';
|
||||
import 'package:moxxyv2/shared/events.dart' as events;
|
||||
import 'package:moxxyv2/shared/models/conversation.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/shared/models/reaction.dart';
|
||||
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/sendfiles_bloc.dart';
|
||||
@ -61,6 +62,8 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
on<SendButtonLockedEvent>(_onSendButtonLocked);
|
||||
on<SendButtonLockPressedEvent>(_onSendButtonLockPressed);
|
||||
on<RecordingCanceledEvent>(_onRecordingCanceled);
|
||||
on<ReactionAddedEvent>(_onReactionAdded);
|
||||
on<ReactionRemovedEvent>(_onReactionRemoved);
|
||||
|
||||
_audioRecorder = Record();
|
||||
}
|
||||
@ -535,4 +538,87 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
final file = await _audioRecorder.stop();
|
||||
unawaited(File(file!).delete());
|
||||
}
|
||||
|
||||
Future<void> _onReactionAdded(ReactionAddedEvent event, Emitter<ConversationState> emit) async {
|
||||
// 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 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,
|
||||
),
|
||||
);
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -146,3 +146,17 @@ class SendButtonLockPressedEvent extends ConversationEvent {}
|
||||
|
||||
/// Triggered when the recording has been canceled
|
||||
class RecordingCanceledEvent extends ConversationEvent {}
|
||||
|
||||
/// Triggered when a reaction has been added
|
||||
class ReactionAddedEvent extends ConversationEvent {
|
||||
ReactionAddedEvent(this.emoji, this.index);
|
||||
final String emoji;
|
||||
final int index;
|
||||
}
|
||||
|
||||
/// Triggered when a reaction has been removed
|
||||
class ReactionRemovedEvent extends ConversationEvent {
|
||||
ReactionRemovedEvent(this.emoji, this.index);
|
||||
final String emoji;
|
||||
final int index;
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import 'dart:io';
|
||||
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@ -135,6 +136,24 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
||||
maxWidth: maxWidth,
|
||||
lastMessageTimestamp: lastMessageTimestamp,
|
||||
onSwipedCallback: (_) => _quoteMessage(context, item),
|
||||
onReactionTap: (reaction) {
|
||||
final bloc = context.read<ConversationBloc>();
|
||||
if (reaction.reactedBySelf) {
|
||||
bloc.add(
|
||||
ReactionRemovedEvent(
|
||||
reaction.emoji,
|
||||
index,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
bloc.add(
|
||||
ReactionAddedEvent(
|
||||
reaction.emoji,
|
||||
index,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onLongPressed: (event) async {
|
||||
if (!item.isLongpressable) {
|
||||
return;
|
||||
@ -173,6 +192,46 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
||||
),
|
||||
highlight: bubble,
|
||||
children: [
|
||||
...item.isReactable ? [
|
||||
OverviewMenuItem(
|
||||
icon: Icons.add_reaction,
|
||||
text: t.pages.conversation.addReaction,
|
||||
onPressed: () async {
|
||||
final emoji = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
// TODO(PapaTutuWawa): Move this to the theme
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: radiusLarge,
|
||||
topRight: radiusLarge,
|
||||
),
|
||||
),
|
||||
builder: (context) => Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: EmojiPicker(
|
||||
onEmojiSelected: (_, emoji) {
|
||||
// ignore: use_build_context_synchronously
|
||||
Navigator.of(context).pop(emoji.emoji);
|
||||
},
|
||||
//height: 250,
|
||||
config: Config(
|
||||
bgColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (emoji != null) {
|
||||
// ignore: use_build_context_synchronously
|
||||
context.read<ConversationBloc>().add(
|
||||
ReactionAddedEvent(emoji, index),
|
||||
);
|
||||
}
|
||||
|
||||
// ignore: use_build_context_synchronously
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
] : [],
|
||||
...item.canRetract(sentBySelf) ? [
|
||||
OverviewMenuItem(
|
||||
icon: Icons.delete,
|
||||
|
@ -110,6 +110,7 @@ class NewConversationPage extends StatelessWidget {
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
item.avatarUrl,
|
||||
item.jid,
|
||||
|
@ -4,9 +4,11 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_vibrate/flutter_vibrate.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/shared/models/reaction.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/datebubble.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/media/media.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/reactionbubble.dart';
|
||||
import 'package:swipeable_tile/swipeable_tile.dart';
|
||||
|
||||
class RawChatBubble extends StatelessWidget {
|
||||
@ -113,6 +115,7 @@ class ChatBubble extends StatefulWidget {
|
||||
required this.onSwipedCallback,
|
||||
required this.bubble,
|
||||
this.onLongPressed,
|
||||
this.onReactionTap,
|
||||
super.key,
|
||||
});
|
||||
final Message message;
|
||||
@ -125,8 +128,10 @@ class ChatBubble extends StatefulWidget {
|
||||
final void Function(Message) onSwipedCallback;
|
||||
// For acting on long-pressing the message
|
||||
final GestureLongPressStartCallback? onLongPressed;
|
||||
// THe actual message bubble
|
||||
// The actual message bubble
|
||||
final RawChatBubble bubble;
|
||||
// For acting on reaction taps
|
||||
final void Function(Reaction)? onReactionTap;
|
||||
|
||||
@override
|
||||
ChatBubbleState createState() => ChatBubbleState();
|
||||
@ -146,6 +151,31 @@ class ChatBubbleState extends State<ChatBubble>
|
||||
return widget.sentBySelf ? SwipeDirection.endToStart : SwipeDirection.startToEnd;
|
||||
}
|
||||
|
||||
Widget _buildReactions() {
|
||||
if (widget.message.reactions.isEmpty) {
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 1),
|
||||
child: Wrap(
|
||||
spacing: 1,
|
||||
runSpacing: 2,
|
||||
children: widget.message.reactions.map(
|
||||
(reaction) => ReactionBubble(
|
||||
emoji: reaction.emoji,
|
||||
reactions: reaction.reactions,
|
||||
reactedTo: reaction.reactedBySelf,
|
||||
sentBySelf: widget.sentBySelf,
|
||||
onTap: widget.onReactionTap != null ?
|
||||
() => widget.onReactionTap!(reaction) :
|
||||
null,
|
||||
),
|
||||
).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBubble(BuildContext context) {
|
||||
return SwipeableTile.swipeToTrigger(
|
||||
direction: _getSwipeDirection(),
|
||||
@ -210,15 +240,25 @@ class ChatBubbleState extends State<ChatBubble>
|
||||
left: !widget.sentBySelf ? 8.0 : 0.0,
|
||||
right: widget.sentBySelf ? 8.0 : 0.0,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: widget.sentBySelf ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onLongPressStart: widget.onLongPressed,
|
||||
child: widget.bubble,
|
||||
child: Align(
|
||||
alignment: widget.sentBySelf ?
|
||||
Alignment.centerRight :
|
||||
Alignment.centerLeft,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: widget.sentBySelf ?
|
||||
CrossAxisAlignment.end :
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onLongPressStart: widget.onLongPressed,
|
||||
child: widget.bubble,
|
||||
),
|
||||
|
||||
_buildReactions(),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
50
lib/ui/widgets/chat/reactionbubble.dart
Normal file
50
lib/ui/widgets/chat/reactionbubble.dart
Normal file
@ -0,0 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
|
||||
class ReactionBubble extends StatelessWidget {
|
||||
const ReactionBubble({
|
||||
required this.emoji,
|
||||
required this.reactions,
|
||||
required this.reactedTo,
|
||||
required this.sentBySelf,
|
||||
this.onTap,
|
||||
super.key,
|
||||
});
|
||||
final String emoji;
|
||||
final int reactions;
|
||||
final bool reactedTo;
|
||||
final bool sentBySelf;
|
||||
final void Function()? onTap;
|
||||
|
||||
Color _getColor() {
|
||||
if (reactedTo) {
|
||||
return const Color(0xff007db0);
|
||||
}
|
||||
|
||||
return sentBySelf ?
|
||||
bubbleColorSent :
|
||||
bubbleColorReceived;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: const BorderRadius.all(radiusLarge),
|
||||
child: Material(
|
||||
color: _getColor(),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Text(
|
||||
'$emoji $reactions',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -210,6 +210,13 @@
|
||||
<xmpp:version>0.3.0</xmpp:version>
|
||||
</xmpp:SupportedXep>
|
||||
</implements>
|
||||
<implements>
|
||||
<xmpp:SupportedXep>
|
||||
<xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0444.html" />
|
||||
<xmpp:status>complete</xmpp:status>
|
||||
<xmpp:version>0.1.0</xmpp:version>
|
||||
</xmpp:SupportedXep>
|
||||
</implements>
|
||||
<implements>
|
||||
<xmpp:SupportedXep>
|
||||
<xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0446.html" />
|
||||
|
@ -778,9 +778,11 @@ packages:
|
||||
moxxmpp:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: moxxmpp
|
||||
url: "https://git.polynom.me/api/packages/Moxxy/pub/"
|
||||
source: hosted
|
||||
path: "packages/moxxmpp"
|
||||
ref: HEAD
|
||||
resolved-ref: "88efdc361c04711cb528bdc8b3a4022335fe4488"
|
||||
url: "https://git.polynom.me/Moxxy/moxxmpp.git"
|
||||
source: git
|
||||
version: "0.1.6+1"
|
||||
moxxmpp_socket_tcp:
|
||||
dependency: "direct main"
|
||||
|
14
pubspec.yaml
14
pubspec.yaml
@ -128,13 +128,21 @@ dependency_overrides:
|
||||
flutter:
|
||||
hosted: https://git.polynom.me/api/packages/Moxxy/pub
|
||||
version: 3.3.8
|
||||
|
||||
# NOTE: Leave here for development purposes
|
||||
#moxxmpp:
|
||||
# path: ../moxxmpp/packages/moxxmpp
|
||||
|
||||
moxxmpp:
|
||||
git:
|
||||
url: https://git.polynom.me/Moxxy/moxxmpp.git
|
||||
rev: 88efdc361c04711cb528bdc8b3a4022335fe4488
|
||||
path: packages/moxxmpp
|
||||
|
||||
omemo_dart:
|
||||
git:
|
||||
url: https://codeberg.org/PapaTutuWawa/omemo_dart.git
|
||||
rev: c68471349ab1b347ec9ad54651265710842c50b7
|
||||
# NOTE: Leave here for development purposes
|
||||
#moxxmpp:
|
||||
# path: ../moxxmpp/packages/moxxmpp
|
||||
|
||||
extra_licenses:
|
||||
- name: undraw.co
|
||||
|
Loading…
Reference in New Issue
Block a user