starting with message info page

This commit is contained in:
otsmr 2025-10-27 01:11:18 +01:00
parent 8f8f2cabe0
commit 4f68d22e07
10 changed files with 342 additions and 111 deletions

View file

@ -191,13 +191,13 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
} }
Future<void> handleTextEdit( Future<void> handleTextEdit(
int contactId, int? contactId,
String messageId, String messageId,
String text, String text,
DateTime timestamp, DateTime timestamp,
) async { ) async {
final msg = await getMessageById(messageId).getSingleOrNull(); final msg = await getMessageById(messageId).getSingleOrNull();
if (msg == null || msg.content == null || msg.senderId == contactId) { if (msg == null || msg.content == null || msg.senderId != contactId) {
return; return;
} }
await into(messageHistories).insert( await into(messageHistories).insert(
@ -209,11 +209,12 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
); );
await (update(messages) await (update(messages)
..where( ..where(
(t) => t.messageId.equals(messageId) & t.senderId.equals(contactId), (t) => t.messageId.equals(messageId),
)) ))
.write( .write(
MessagesCompanion( MessagesCompanion(
content: Value(text), content: Value(text),
modifiedAt: Value(timestamp),
), ),
); );
} }

View file

@ -174,6 +174,7 @@
"submit": "Abschicken", "submit": "Abschicken",
"close": "Schließen", "close": "Schließen",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"edit": "Bearbeiten",
"ok": "Ok", "ok": "Ok",
"now": "Jetzt", "now": "Jetzt",
"you": "Du", "you": "Du",

View file

@ -307,6 +307,7 @@
"react": "React", "react": "React",
"reply": "Reply", "reply": "Reply",
"copy": "Copy", "copy": "Copy",
"edit": "Edit",
"delete": "Delete", "delete": "Delete",
"info": "Info", "info": "Info",
"ok": "Ok", "ok": "Ok",

View file

@ -1106,6 +1106,12 @@ abstract class AppLocalizations {
/// **'Copy'** /// **'Copy'**
String get copy; String get copy;
/// No description provided for @edit.
///
/// In en, this message translates to:
/// **'Edit'**
String get edit;
/// No description provided for @delete. /// No description provided for @delete.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -560,6 +560,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get copy => 'Kopieren'; String get copy => 'Kopieren';
@override
String get edit => 'Bearbeiten';
@override @override
String get delete => 'Löschen'; String get delete => 'Löschen';

View file

@ -555,6 +555,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get copy => 'Copy'; String get copy => 'Copy';
@override
String get edit => 'Edit';
@override @override
String get delete => 'Delete'; String get delete => 'Delete';

View file

@ -23,6 +23,7 @@ class ChatListEntry extends StatefulWidget {
required this.nextMessage, required this.nextMessage,
required this.onResponseTriggered, required this.onResponseTriggered,
required this.scrollToMessage, required this.scrollToMessage,
this.disableContextMenu = false,
super.key, super.key,
}); });
final Message? prevMessage; final Message? prevMessage;
@ -32,6 +33,7 @@ class ChatListEntry extends StatefulWidget {
final List<MemoryItem> galleryItems; final List<MemoryItem> galleryItems;
final void Function(String) scrollToMessage; final void Function(String) scrollToMessage;
final void Function() onResponseTriggered; final void Function() onResponseTriggered;
final bool disableContextMenu;
@override @override
State<ChatListEntry> createState() => _ChatListEntryState(); State<ChatListEntry> createState() => _ChatListEntryState();
@ -96,81 +98,84 @@ class _ChatListEntryState extends State<ChatListEntry> {
reactions.where((t) => seen.add(t.emoji)).toList().length; reactions.where((t) => seen.add(t.emoji)).toList().length;
if (reactionsForWidth > 4) reactionsForWidth = 4; if (reactionsForWidth > 4) reactionsForWidth = 4;
return Align( Widget child = Column(
alignment: right ? Alignment.centerRight : Alignment.centerLeft, mainAxisAlignment:
child: Padding( right ? MainAxisAlignment.end : MainAxisAlignment.start,
padding: padding, crossAxisAlignment:
child: MessageContextMenu( right ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
MessageActions(
message: widget.message, message: widget.message,
onResponseTriggered: widget.onResponseTriggered, onResponseTriggered: widget.onResponseTriggered,
child: Column( child: Stack(
mainAxisAlignment: // overflow: Overflow.visible,
right ? MainAxisAlignment.end : MainAxisAlignment.start, // clipBehavior: Clip.none,
crossAxisAlignment: alignment: right ? Alignment.centerRight : Alignment.centerLeft,
right ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [ children: [
MessageActions( if (widget.message.isDeletedFromSender)
message: widget.message, ChatTextEntry(
onResponseTriggered: widget.onResponseTriggered, message: widget.message,
child: Stack( nextMessage: widget.nextMessage,
// overflow: Overflow.visible, borderRadius: borderRadius,
// clipBehavior: Clip.none, minWidth: reactionsForWidth * 43,
alignment: )
right ? Alignment.centerRight : Alignment.centerLeft, else
Column(
children: [ children: [
if (widget.message.isDeletedFromSender) ResponseContainer(
ChatTextEntry( msg: widget.message,
message: widget.message, group: widget.group,
nextMessage: widget.nextMessage, mediaService: mediaService,
borderRadius: borderRadius, borderRadius: borderRadius,
minWidth: reactionsForWidth * 43, scrollToMessage: widget.scrollToMessage,
) child: (widget.message.type == MessageType.text)
else ? ChatTextEntry(
Column( message: widget.message,
children: [ nextMessage: widget.nextMessage,
ResponseContainer( borderRadius: borderRadius,
msg: widget.message, minWidth: reactionsForWidth * 43,
group: widget.group, )
mediaService: mediaService, : (mediaService == null)
borderRadius: borderRadius, ? null
scrollToMessage: widget.scrollToMessage, : ChatMediaEntry(
child: (widget.message.type == MessageType.text) message: widget.message,
? ChatTextEntry( group: widget.group,
message: widget.message, mediaService: mediaService!,
nextMessage: widget.nextMessage, galleryItems: widget.galleryItems,
borderRadius: borderRadius, ),
minWidth: reactionsForWidth * 43, ),
) if (reactionsForWidth > 0)
: (mediaService == null) const SizedBox(height: 20, width: 10),
? null
: ChatMediaEntry(
message: widget.message,
group: widget.group,
mediaService: mediaService!,
galleryItems: widget.galleryItems,
),
),
if (reactionsForWidth > 0)
const SizedBox(height: 20, width: 10),
],
),
if (!widget.message.isDeletedFromSender)
Positioned(
bottom: -20,
left: 5,
right: 5,
child: ReactionRow(
message: widget.message,
reactions: reactions,
),
),
], ],
), ),
), if (!widget.message.isDeletedFromSender)
Positioned(
bottom: -20,
left: 5,
right: 5,
child: ReactionRow(
message: widget.message,
reactions: reactions,
),
),
], ],
), ),
), ),
), ],
);
if (!widget.disableContextMenu) {
child = MessageContextMenu(
message: widget.message,
group: widget.group,
onResponseTriggered: widget.onResponseTriggered,
child: child,
);
}
return Align(
alignment: right ? Alignment.centerRight : Alignment.centerLeft,
child: Padding(padding: padding, child: child),
); );
} }
} }

View file

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:intl/intl.dart' hide TextDirection; import 'package:intl/intl.dart' hide TextDirection;
import 'package:twonly/src/database/tables/messages.table.dart'; import 'package:twonly/src/database/tables/messages.table.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
@ -89,12 +90,28 @@ class ChatTextEntry extends StatelessWidget {
alignment: AlignmentGeometry.centerRight, alignment: AlignmentGeometry.centerRight,
child: Padding( child: Padding(
padding: const EdgeInsets.only(left: 6), padding: const EdgeInsets.only(left: 6),
child: Text( child: Row(
friendlyTime(context, message.createdAt), children: [
style: TextStyle( if (message.modifiedAt != null)
fontSize: 10, Padding(
color: Colors.white.withAlpha(150), padding: const EdgeInsets.only(right: 5),
), child: SizedBox(
height: 10,
child: FaIcon(
FontAwesomeIcons.pencil,
color: Colors.white.withAlpha(150),
size: 10,
),
),
),
Text(
friendlyTime(context, message.createdAt),
style: TextStyle(
fontSize: 10,
color: Colors.white.withAlpha(150),
),
),
],
), ),
), ),
), ),

View file

@ -1,10 +1,12 @@
// ignore_for_file: inference_failure_on_function_invocation // ignore_for_file: inference_failure_on_function_invocation
import 'package:fixnum/fixnum.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:pie_menu/pie_menu.dart'; import 'package:pie_menu/pie_menu.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/src/database/tables/messages.table.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/client/generated/messages.pbserver.dart' import 'package:twonly/src/model/protobuf/client/generated/messages.pbserver.dart'
as pb; as pb;
@ -12,15 +14,18 @@ import 'package:twonly/src/services/api/messages.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/camera/image_editor/data/layer.dart'; import 'package:twonly/src/views/camera/image_editor/data/layer.dart';
import 'package:twonly/src/views/camera/image_editor/modules/all_emojis.dart'; import 'package:twonly/src/views/camera/image_editor/modules/all_emojis.dart';
import 'package:twonly/src/views/chats/message_info.view.dart';
import 'package:twonly/src/views/components/alert_dialog.dart'; import 'package:twonly/src/views/components/alert_dialog.dart';
class MessageContextMenu extends StatelessWidget { class MessageContextMenu extends StatelessWidget {
const MessageContextMenu({ const MessageContextMenu({
required this.message, required this.message,
required this.group,
required this.child, required this.child,
required this.onResponseTriggered, required this.onResponseTriggered,
super.key, super.key,
}); });
final Group group;
final Widget child; final Widget child;
final Message message; final Message message;
final VoidCallback onResponseTriggered; final VoidCallback onResponseTriggered;
@ -35,40 +40,52 @@ class MessageContextMenu extends StatelessWidget {
} }
}, },
actions: [ actions: [
PieAction( if (!message.isDeletedFromSender)
tooltip: Text(context.lang.react), PieAction(
onSelect: () async { tooltip: Text(context.lang.react),
final layer = await showModalBottomSheet( onSelect: () async {
context: context, final layer = await showModalBottomSheet(
backgroundColor: Colors.black, context: context,
builder: (BuildContext context) { backgroundColor: Colors.black,
return const Emojis(); builder: (BuildContext context) {
}, return const Emojis();
) as EmojiLayerData?; },
if (layer == null) return; ) as EmojiLayerData?;
if (layer == null) return;
await twonlyDB.reactionsDao await twonlyDB.reactionsDao
.updateMyReaction(message.messageId, layer.text); .updateMyReaction(message.messageId, layer.text);
await sendCipherTextToGroup( await sendCipherTextToGroup(
message.groupId, message.groupId,
pb.EncryptedContent( pb.EncryptedContent(
reaction: pb.EncryptedContent_Reaction( reaction: pb.EncryptedContent_Reaction(
targetMessageId: message.messageId, targetMessageId: message.messageId,
emoji: layer.text, emoji: layer.text,
remove: false, remove: false,
),
), ),
), null,
null, );
); },
}, child: const FaIcon(FontAwesomeIcons.faceLaugh),
child: const FaIcon(FontAwesomeIcons.faceLaugh), ),
), if (!message.isDeletedFromSender)
PieAction( PieAction(
tooltip: Text(context.lang.reply), tooltip: Text(context.lang.reply),
onSelect: onResponseTriggered, onSelect: onResponseTriggered,
child: const FaIcon(FontAwesomeIcons.reply), child: const FaIcon(FontAwesomeIcons.reply),
), ),
if (!message.isDeletedFromSender &&
message.senderId == null &&
message.type == MessageType.text)
PieAction(
tooltip: Text(context.lang.edit),
onSelect: () async {
await editTextMessage(context, message);
},
child: const FaIcon(FontAwesomeIcons.pencil),
),
if (message.content != null) if (message.content != null)
PieAction( PieAction(
tooltip: Text(context.lang.copy), tooltip: Text(context.lang.copy),
@ -115,13 +132,107 @@ class MessageContextMenu extends StatelessWidget {
}, },
child: const FaIcon(FontAwesomeIcons.trash), child: const FaIcon(FontAwesomeIcons.trash),
), ),
// PieAction( if (!message.isDeletedFromSender)
// tooltip: Text(context.lang.info), PieAction(
// onSelect: () {}, tooltip: Text(context.lang.info),
// child: const FaIcon(FontAwesomeIcons.circleInfo), onSelect: () async {
// ), await Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return MessageInfoView(
message: message,
group: group,
);
},
),
);
},
child: const FaIcon(FontAwesomeIcons.circleInfo),
),
], ],
child: child, child: child,
); );
} }
} }
Future<void> editTextMessage(BuildContext context, Message message) async {
var newText = message.content;
final controller = TextEditingController(text: message.content);
await showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
content: StatefulBuilder(
builder: (BuildContext context, StateSetter setState) {
return SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(top: 8),
child: TextField(
controller: controller,
autofocus: true,
keyboardType: TextInputType.multiline,
maxLines: 4,
minLines: 1,
onChanged: (value) => setState(() {
newText = value;
}),
decoration: const InputDecoration(
border: OutlineInputBorder(),
),
textCapitalization: TextCapitalization.characters,
),
),
],
),
);
},
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(context.lang.cancel),
),
TextButton(
onPressed: () async {
if (newText != null &&
newText != message.content &&
newText != '') {
final timestamp = DateTime.now();
await twonlyDB.messagesDao.handleTextEdit(
null,
message.messageId,
newText!,
timestamp,
);
await sendCipherTextToGroup(
message.groupId,
pb.EncryptedContent(
messageUpdate: pb.EncryptedContent_MessageUpdate(
type: pb.EncryptedContent_MessageUpdate_Type.EDIT_TEXT,
senderMessageId: message.messageId,
text: newText,
timestamp: Int64(
timestamp.millisecondsSinceEpoch,
),
),
),
null,
);
}
if (!context.mounted) return;
Navigator.of(context).pop();
},
child: Text(context.lang.ok),
),
],
);
},
);
}

View file

@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/chats/chat_messages_components/chat_list_entry.dart';
class MessageInfoView extends StatefulWidget {
const MessageInfoView({
required this.message,
required this.group,
super.key,
});
final Message message;
final Group group;
@override
State<MessageInfoView> createState() => _MessageInfoViewState();
}
class _MessageInfoViewState extends State<MessageInfoView> {
@override
void initState() {
initAsync();
super.initState();
}
Future<void> initAsync() async {
// watch message edit history
// watch message actions
}
@override
void dispose() {
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text(''),
),
body: Padding(
padding: const EdgeInsets.all(8),
child: ListView(
children: [
Padding(
padding: const EdgeInsets.all(8),
child: ChatListEntry(
group: widget.group,
galleryItems: const [],
prevMessage: null,
message: widget.message,
disableContextMenu: true,
nextMessage: null,
onResponseTriggered: () {},
scrollToMessage: (_) {},
),
),
Row(
children: [
const Text('Versendet'),
const SizedBox(width: 13),
Text(formatDateTime(context, widget.message.createdAt)),
],
),
// Row(
// children: [
// Text("Empfangen"),
// SizedBox(width: 13),
// Text(formatDateTime(context, widget.message.ackByUser)),
// ],
// )
const SizedBox(height: 10),
const Divider(),
const SizedBox(height: 10),
const Text('Zugestelt an'),
],
),
),
);
}
}