Compare commits

...

14 Commits

Author SHA1 Message Date
9a0bc87636 Merge pull request 'Message reactions' (#178) from feat/reactions into master
Reviewed-on: https://codeberg.org/moxxy/moxxyv2/pulls/178
2022-12-09 14:50:55 +00:00
d73d27dccc fix(service): Fix senders being added multiple times to a reaction 2022-12-09 15:47:15 +01:00
6fa5e73226 feat(service): Handle messages with <no-store/> Message Processing Hints 2022-12-09 12:57:26 +01:00
1ff9ea256b fix(ui): Switch from Row to Wrap 2022-12-06 22:40:59 +01:00
7fca7e0246 fix(meta): Add a moxxmpp git override 2022-12-06 18:49:56 +01:00
846270b714 feat(ui): Translate the reaction button 2022-12-06 18:46:40 +01:00
50e7c5683f feat(service): Handle reactions from our own carbons 2022-12-06 15:45:31 +01:00
6883a9570f feat(ui): Swap around the emoji and the number of reactions 2022-12-06 14:10:44 +01:00
8f34bc001d docs(meta): Update DOAP 2022-12-06 14:09:54 +01:00
2f95e5452b fix(service): We don't need chat states for reactions 2022-12-06 13:57:02 +01:00
59a6307a21 feat(service): Send reactions 2022-12-06 13:52:42 +01:00
c8d52e6c41 feat(service): Implement receiving reactions 2022-12-06 13:23:17 +01:00
044766bf8a feat(ui): Build the UI for reactions 2022-12-06 12:19:21 +01:00
1f7c851228 feat(ui): Implement the basic UI for displaying reactions 2022-12-06 00:18:06 +01:00
23 changed files with 604 additions and 26 deletions

View File

@ -115,6 +115,7 @@
"edit": "Edit",
"quote": "Quote",
"copy": "Copy content",
"addReaction": "Add reaction",
"showError": "Show error",
"showWarning": "Show warning",
"addToContacts": "Add to contacts",

View File

@ -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",

View File

@ -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"

View File

@ -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)
)''',
);

View File

@ -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',

View 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 '[]';"
);
}

View File

@ -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)};'
);
}

View File

@ -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,
),
);
}

View File

@ -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)) {

View File

@ -198,6 +198,7 @@ Future<void> entrypoint() async {
DelayedDeliveryManager(),
MessageRetractionManager(),
LastMessageCorrectionManager(),
MessageReactionsManager(),
])
..registerFeatureNegotiators([
ResourceBindingNegotiator(),

View File

@ -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,

View File

@ -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() {

View File

@ -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) {

View 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;
}
}

View File

@ -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,
);
}
}

View File

@ -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;
}

View File

@ -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,

View File

@ -110,6 +110,7 @@ class NewConversationPage extends StatelessWidget {
false,
false,
false,
false,
),
item.avatarUrl,
item.jid,

View File

@ -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(),
],
),
],
),
),
),
);
}

View 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,
),
),
),
),
),
);
}
}

View File

@ -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" />

View File

@ -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"

View File

@ -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