From c7826ad6ddf658caaccd5e6e69b28e6619ac4390 Mon Sep 17 00:00:00 2001 From: otsmr Date: Tue, 26 May 2026 11:57:59 +0200 Subject: [PATCH 1/4] improve logging --- lib/src/database/daos/messages.dao.dart | 50 +++++++++---------- lib/src/database/daos/reactions.dao.dart | 9 +++- .../api/client2client/reaction.c2c.dart | 5 +- lib/src/services/api/messages.api.dart | 33 +++++++++++- .../camera_preview_controller_view.dart | 2 + .../message_send_state_icon.dart | 10 ++-- pubspec.yaml | 2 +- 7 files changed, 77 insertions(+), 34 deletions(-) diff --git a/lib/src/database/daos/messages.dao.dart b/lib/src/database/daos/messages.dao.dart index d794b8cf..ff1e0a64 100644 --- a/lib/src/database/daos/messages.dao.dart +++ b/lib/src/database/daos/messages.dao.dart @@ -253,23 +253,19 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { List messageIds, DateTime timestamp, ) async { - try { - await twonlyDB.batch((batch) async { - for (final messageId in messageIds) { - batch.insert( - messageActions, - MessageActionsCompanion( - messageId: Value(messageId), - contactId: contactId, - type: const Value(MessageActionType.openedAt), - actionAt: Value(timestamp), - ), - mode: InsertMode.insertOrReplace, - ); - } - }); - } catch (e) { - Log.error(e); + for (final messageId in messageIds) { + try { + await into(messageActions).insertOnConflictUpdate( + MessageActionsCompanion( + messageId: Value(messageId), + contactId: contactId, + type: const Value(MessageActionType.openedAt), + actionAt: Value(timestamp), + ), + ); + } catch (e) { + Log.error('handleMessagesOpened insert failed for $messageId: $e'); + } } for (final messageId in messageIds) { @@ -278,16 +274,20 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { messageId, MessageActionType.openedAt, ); - await (update( - messages, - )..where((tbl) => tbl.messageId.equals(messageId))).write( - MessagesCompanion( - openedAt: Value(timestamp), - openedByAll: Value(isOpenedByAll ? timestamp : null), - ), + final rowsUpdated = + await (update( + messages, + )..where((tbl) => tbl.messageId.equals(messageId))).write( + MessagesCompanion( + openedAt: Value(timestamp), + openedByAll: Value(isOpenedByAll ? timestamp : null), + ), + ); + Log.info( + 'handleMessagesOpened updated $rowsUpdated rows for message $messageId', ); } catch (e) { - Log.error(e); + Log.error('handleMessagesOpened update failed: $e'); } } } diff --git a/lib/src/database/daos/reactions.dao.dart b/lib/src/database/daos/reactions.dao.dart index bd1405b3..a54deb20 100644 --- a/lib/src/database/daos/reactions.dao.dart +++ b/lib/src/database/daos/reactions.dao.dart @@ -29,7 +29,14 @@ class ReactionsDao extends DatabaseAccessor with _$ReactionsDaoMixin { final msg = await twonlyDB.messagesDao .getMessageById(messageId) .getSingleOrNull(); - if (msg == null || msg.groupId != groupId) return; + if (msg == null) { + Log.error('updateReaction: Message $messageId not found!'); + return; + } + if (msg.groupId != groupId) { + Log.error('updateReaction: Message groupId ${msg.groupId} != $groupId'); + return; + } try { if (remove) { diff --git a/lib/src/services/api/client2client/reaction.c2c.dart b/lib/src/services/api/client2client/reaction.c2c.dart index 7988e55b..03a423c1 100644 --- a/lib/src/services/api/client2client/reaction.c2c.dart +++ b/lib/src/services/api/client2client/reaction.c2c.dart @@ -10,7 +10,10 @@ Future handleReaction( EncryptedContent_Reaction reaction, String receiptId, ) async { - Log.info('[$receiptId] Got a reaction from $fromUserId (remove=${reaction.remove})'); + Log.info( + '[$receiptId] Got a reaction from for ${reaction.targetMessageId} (remove=${reaction.remove})', + ); + await twonlyDB.reactionsDao.updateReaction( fromUserId, reaction.targetMessageId, diff --git a/lib/src/services/api/messages.api.dart b/lib/src/services/api/messages.api.dart index 7d26da32..29a234fe 100644 --- a/lib/src/services/api/messages.api.dart +++ b/lib/src/services/api/messages.api.dart @@ -350,7 +350,9 @@ Future insertAndSendAskAboutUserMessage( ) async { final directChat = await twonlyDB.groupsDao.createOrGetDirectChat(contactId); if (directChat == null) { - Log.error('Failed to get or create direct chat group for contact $contactId'); + Log.error( + 'Failed to get or create direct chat group for contact $contactId', + ); return; } @@ -483,6 +485,17 @@ Future<(Uint8List, Uint8List?)?> sendCipherText( ); if (receipt != null) { + try { + final typeKeys = _getEncryptedContentTypes(encryptedContent); + Log.info( + 'sendCipherText: type=[$typeKeys] messageId=$messageId receiptId=${receipt.receiptId}', + ); + } catch (_) { + Log.info( + 'sendCipherText: messageId=$messageId receiptId=${receipt.receiptId}', + ); + } + final tmp = tryToSendCompleteMessage( receipt: receipt, onlyReturnEncryptedData: onlyReturnEncryptedData, @@ -568,3 +581,21 @@ Future sendContactMyProfileData(int contactId) async { ); await sendCipherText(contactId, encryptedContent, blocking: false); } + +String _getEncryptedContentTypes(pb.EncryptedContent content) { + final ignoredFields = { + 'groupId', + 'isDirectChat', + 'senderProfileCounter', + 'senderUserDiscoveryVersion', + }; + + final types = []; + for (final field in content.info_.byName.values) { + if (content.hasField(field.tagNumber) && + !ignoredFields.contains(field.name)) { + types.add(field.name); + } + } + return types.join(', '); +} diff --git a/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart b/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart index 1a3e52a3..6f638aa6 100644 --- a/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart +++ b/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart @@ -212,6 +212,7 @@ class _CameraPreviewViewState extends State { // Maybe this is the reason? return; } else { + await androidVolumeDownSub?.cancel(); androidVolumeDownSub = FlutterAndroidVolumeKeydown.stream.listen(( event, ) { @@ -233,6 +234,7 @@ class _CameraPreviewViewState extends State { } if (Platform.isAndroid) { await androidVolumeDownSub?.cancel(); + androidVolumeDownSub = null; } } diff --git a/lib/src/visual/views/chats/chat_messages_components/message_send_state_icon.dart b/lib/src/visual/views/chats/chat_messages_components/message_send_state_icon.dart index 1d35e52f..9df84bbb 100644 --- a/lib/src/visual/views/chats/chat_messages_components/message_send_state_icon.dart +++ b/lib/src/visual/views/chats/chat_messages_components/message_send_state_icon.dart @@ -24,15 +24,15 @@ enum MessageSendState { MessageSendState messageSendStateFromMessage(Message msg) { if (msg.senderId == null) { + if (msg.openedByAll != null || msg.openedAt != null) { + return MessageSendState.sendOpened; + } + /// messages was send by me, look up if every messages was received by the server... if (msg.ackByServer == null) { return MessageSendState.sending; } - if (msg.openedAt != null) { - return MessageSendState.sendOpened; - } else { - return MessageSendState.send; - } + return MessageSendState.send; } // message received diff --git a/pubspec.yaml b/pubspec.yaml index fd9437ed..8c4fb1ad 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec publish_to: 'none' -version: 0.2.20+129 +version: 0.2.21+130 environment: sdk: ^3.11.0 From 872592af21243eeb1c36c19c431c165742984abe Mon Sep 17 00:00:00 2001 From: otsmr Date: Thu, 28 May 2026 00:09:12 +0200 Subject: [PATCH 2/4] fix: database error and some ui improvements --- lib/src/database/daos/messages.dao.dart | 31 ++++--- lib/src/database/twonly.db.dart | 1 + .../generated/app_localizations.dart | 12 +++ .../generated/app_localizations_de.dart | 6 ++ .../generated/app_localizations_en.dart | 6 ++ lib/src/localization/translations | 2 +- .../services/api/mediafiles/upload.api.dart | 7 ++ lib/src/services/api/messages.api.dart | 5 +- .../mediafiles/mediafile.service.dart | 7 ++ lib/src/services/migrations.service.dart | 32 ------- lib/src/utils/startup_guard.dart | 2 +- .../views/chats/chat_messages.view.dart | 58 ++++++++---- .../animated_new_message.dart | 90 +++++++++++++++++++ .../entries/chat_date_chip.dart | 20 ++++- .../message_input.dart | 82 +++++++++++------ .../unverified_contact_warning.comp.dart | 37 ++++++-- .../mutual_groups_expansion_tile.comp.dart | 1 - .../settings/developer/developer.view.dart | 28 ++++++ pubspec.yaml | 2 +- 19 files changed, 325 insertions(+), 104 deletions(-) create mode 100644 lib/src/visual/views/chats/chat_messages_components/animated_new_message.dart diff --git a/lib/src/database/daos/messages.dao.dart b/lib/src/database/daos/messages.dao.dart index ff1e0a64..45e09d0f 100644 --- a/lib/src/database/daos/messages.dao.dart +++ b/lib/src/database/daos/messages.dao.dart @@ -102,7 +102,7 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { t.openedAt.isNull() | t.mediaStored.equals(true)) & (t.isDeletedFromSender.equals(true) | - (t.type.equals(MessageType.text.name).not() | + (t.type.equals(MessageType.text.name).not() & t.type.equals(MessageType.media.name).not()) | (t.type.equals(MessageType.text.name) & t.content.isNotNull()) | @@ -156,8 +156,8 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { await (delete(messages)..where( (m) => m.groupId.isIn(groupIds) & - (m.mediaStored.equals(true) & - m.isDeletedFromSender.equals(true) | + ((m.mediaStored.equals(true) & + m.isDeletedFromSender.equals(true)) | m.mediaStored.equals(false)) & // Only remove the message when ALL members have seen it. Otherwise the receipt will also be deleted which could cause issues in case a member opens the image later.. (m.openedByAll.isSmallerThanValue(deletionTime) | @@ -255,21 +255,26 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { ) async { for (final messageId in messageIds) { try { + var actionTimestamp = timestamp; + final msg = await getMessageById(messageId).getSingleOrNull(); + if (msg != null && actionTimestamp.isBefore(msg.createdAt)) { + Log.warn( + 'Receiver clock skew detected for message $messageId. ' + 'Action timestamp $actionTimestamp is before message creation ${msg.createdAt}. ' + 'Clamping to creation time.', + ); + actionTimestamp = msg.createdAt; + } + await into(messageActions).insertOnConflictUpdate( MessageActionsCompanion( messageId: Value(messageId), contactId: contactId, type: const Value(MessageActionType.openedAt), - actionAt: Value(timestamp), + actionAt: Value(actionTimestamp), ), ); - } catch (e) { - Log.error('handleMessagesOpened insert failed for $messageId: $e'); - } - } - for (final messageId in messageIds) { - try { final isOpenedByAll = await haveAllMembers( messageId, MessageActionType.openedAt, @@ -279,15 +284,15 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { messages, )..where((tbl) => tbl.messageId.equals(messageId))).write( MessagesCompanion( - openedAt: Value(timestamp), - openedByAll: Value(isOpenedByAll ? timestamp : null), + openedAt: Value(actionTimestamp), + openedByAll: Value(isOpenedByAll ? actionTimestamp : null), ), ); Log.info( 'handleMessagesOpened updated $rowsUpdated rows for message $messageId', ); } catch (e) { - Log.error('handleMessagesOpened update failed: $e'); + Log.error('handleMessagesOpened failed for $messageId: $e'); } } } diff --git a/lib/src/database/twonly.db.dart b/lib/src/database/twonly.db.dart index 5ecf15e3..5ab35682 100644 --- a/lib/src/database/twonly.db.dart +++ b/lib/src/database/twonly.db.dart @@ -92,6 +92,7 @@ class TwonlyDB extends _$TwonlyDB { setup: (rawDb) { rawDb ..execute('PRAGMA journal_mode=WAL;') + ..execute('PRAGMA synchronous=FULL;') ..execute('PRAGMA busy_timeout=5000;'); }, ), diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index 1d4500b3..3050dcf0 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -3493,6 +3493,18 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Verify now'** String get unverifiedWarningButton; + + /// No description provided for @today. + /// + /// In en, this message translates to: + /// **'Today'** + String get today; + + /// No description provided for @yesterday. + /// + /// In en, this message translates to: + /// **'Yesterday'** + String get yesterday; } class _AppLocalizationsDelegate diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index cfd37cb7..21d62e6a 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -1988,4 +1988,10 @@ class AppLocalizationsDe extends AppLocalizations { @override String get unverifiedWarningButton => 'Jetzt verifizieren'; + + @override + String get today => 'Heute'; + + @override + String get yesterday => 'Gestern'; } diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index 3cfe0b43..30dc06cb 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -1972,4 +1972,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get unverifiedWarningButton => 'Verify now'; + + @override + String get today => 'Today'; + + @override + String get yesterday => 'Yesterday'; } diff --git a/lib/src/localization/translations b/lib/src/localization/translations index c33a4c3b..9a538845 160000 --- a/lib/src/localization/translations +++ b/lib/src/localization/translations @@ -1 +1 @@ -Subproject commit c33a4c3be99b38596abd0cfa91333db3a340dee2 +Subproject commit 9a538845a340f41ee8d2d23bc4c3e8fd73797203 diff --git a/lib/src/services/api/mediafiles/upload.api.dart b/lib/src/services/api/mediafiles/upload.api.dart index da176777..bce9db1d 100644 --- a/lib/src/services/api/mediafiles/upload.api.dart +++ b/lib/src/services/api/mediafiles/upload.api.dart @@ -413,6 +413,9 @@ Future insertMediaFileInMessagesTable( ); await twonlyDB.groupsDao.increaseLastMessageExchange(groupId, clock.now()); if (message != null) { + Log.info( + 'Created message ${message.messageId} for media ${message.mediaId}', + ); // de-archive contact when sending a new message await twonlyDB.groupsDao.updateGroup( message.groupId, @@ -444,6 +447,10 @@ Future _startBackgroundMediaUploadInternal( if (mediaService.mediaFile.uploadState == UploadState.initialized || mediaService.mediaFile.uploadState == UploadState.preprocessing) { + Log.info( + 'Hanlding media file ${mediaService.mediaFile.mediaId} in ${mediaService.mediaFile.uploadState}', + ); + await mediaService.setUploadState(UploadState.preprocessing); if (!mediaService.tempPath.existsSync()) { diff --git a/lib/src/services/api/messages.api.dart b/lib/src/services/api/messages.api.dart index 29a234fe..60055ca2 100644 --- a/lib/src/services/api/messages.api.dart +++ b/lib/src/services/api/messages.api.dart @@ -148,8 +148,6 @@ Future<(Uint8List, Uint8List?)?> _tryToSendCompleteMessageInternal({ message.encryptedContent, ); - Log.info('Uploading message with receiptID ${receipt.receiptId}.'); - Uint8List? pushData; if (receipt.retryCount == 0) { final pushNotification = await getPushNotificationFromEncryptedContent( @@ -194,9 +192,12 @@ Future<(Uint8List, Uint8List?)?> _tryToSendCompleteMessageInternal({ } if (onlyReturnEncryptedData) { + Log.info('Returning message with receiptID ${receipt.receiptId}.'); return (message.writeToBuffer(), pushData); } + Log.info('Uploading message with receiptID ${receipt.receiptId}.'); + final resp = await apiService.sendTextMessage( receipt.contactId, message.writeToBuffer(), diff --git a/lib/src/services/mediafiles/mediafile.service.dart b/lib/src/services/mediafiles/mediafile.service.dart index f6c1c4b6..deae9049 100644 --- a/lib/src/services/mediafiles/mediafile.service.dart +++ b/lib/src/services/mediafiles/mediafile.service.dart @@ -72,6 +72,13 @@ class MediaFileService { delete = false; } + // Never purge temp files while an upload is still in progress. + // The temp file is actively needed for encryption/upload. + if (mediaFile.uploadState != UploadState.uploaded && + mediaFile.uploadState != UploadState.fileLimitReached) { + delete = false; + } + final messages = messageMap[mediaId] ?? []; // in case messages in empty the file will be deleted, as delete is true by default diff --git a/lib/src/services/migrations.service.dart b/lib/src/services/migrations.service.dart index 9ba47d8e..979cb260 100644 --- a/lib/src/services/migrations.service.dart +++ b/lib/src/services/migrations.service.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:convert'; -import 'package:clock/clock.dart'; import 'package:drift/drift.dart'; import 'package:flutter/foundation.dart'; import 'package:intl/intl.dart'; @@ -145,37 +144,6 @@ Future runMigrations() async { } } - if (userService.currentUser.appVersion < 116) { - // Because of a Bug in the handleMessagesOpened function, some messages where not marked as opened. So use the logs, - // to mark the files as opened. - final logs = await loadLogFile(); - final openedMessages = logs.split( - 'messages.c2c.dart:12 > Opened message [', - ); - for (final opened in openedMessages) { - final messageIds = opened.split(']'); - if (messageIds.isNotEmpty) { - final now = clock.now(); - for (final messageId in messageIds.first.split(',')) { - await (twonlyDB.update( - twonlyDB.messages, - )..where( - (tbl) => - tbl.messageId.equals(messageId) & - (tbl.openedByAll.isNull() | tbl.openedAt.isNull()), - )) - .write( - MessagesCompanion( - openedAt: Value(now), - openedByAll: Value(now), - ), - ); - } - } - } - await UserService.update((u) => u.appVersion = 116); - } - if (kDebugMode) { assert( AppState.latestAppVersionId == 116, diff --git a/lib/src/utils/startup_guard.dart b/lib/src/utils/startup_guard.dart index 958b6565..e9b9a835 100644 --- a/lib/src/utils/startup_guard.dart +++ b/lib/src/utils/startup_guard.dart @@ -28,7 +28,7 @@ class StartupGuard { final stat = file.statSync(); final diff = DateTime.now().difference(stat.modified); - final starting = diff.inSeconds < 30; + final starting = diff.inSeconds < 5; if (starting) { Log.info( 'Startup guard: App is currently starting (${diff.inSeconds}s ago).', diff --git a/lib/src/visual/views/chats/chat_messages.view.dart b/lib/src/visual/views/chats/chat_messages.view.dart index 46077800..f3daa1de 100644 --- a/lib/src/visual/views/chats/chat_messages.view.dart +++ b/lib/src/visual/views/chats/chat_messages.view.dart @@ -20,6 +20,7 @@ import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; import 'package:twonly/src/visual/components/flame_counter.comp.dart'; import 'package:twonly/src/visual/components/verification_badge.comp.dart'; import 'package:twonly/src/visual/themes/colors.dart'; +import 'package:twonly/src/visual/views/chats/chat_messages_components/animated_new_message.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/blink.component.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/chat_group_action.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/chat_list_entry.dart'; @@ -40,6 +41,11 @@ class ChatMessagesView extends StatefulWidget { class _ChatMessagesViewState extends State with WidgetsBindingObserver { HashSet alreadyReportedOpened = HashSet(); + + bool _hasReceivedFirstMessageBatch = false; + final HashSet _knownMessageIds = HashSet(); + final HashSet _animateMessageIds = HashSet(); + StreamSubscription? userSub; StreamSubscription>? messageSub; StreamSubscription>? groupActionsSub; @@ -131,6 +137,7 @@ class _ChatMessagesViewState extends State allMessages = update; await protectMessageUpdating.protect(() async { await setMessages(update, groupActions); + _hasReceivedFirstMessageBatch = true; }); }); @@ -161,6 +168,15 @@ class _ChatMessagesViewState extends State await flutterLocalNotificationsPlugin.cancelAll(); } + for (final msg in newMessages) { + if (_hasReceivedFirstMessageBatch && + !_knownMessageIds.contains(msg.messageId) && + msg.senderId == null) { + _animateMessageIds.add(msg.messageId); + } + _knownMessageIds.add(msg.messageId); + } + final chatItems = []; final storedMediaFiles = []; @@ -337,24 +353,32 @@ class _ChatMessagesViewState extends State } else { final chatMessage = messages[i].message!; return BlinkWidget( + key: Key('blink_${chatMessage.messageId}'), enabled: focusedScrollItem == i, - child: ChatListEntry( - key: Key(chatMessage.messageId), - message: messages[i].message!, - nextMessage: (i > 0) ? messages[i - 1].message : null, - prevMessage: ((i + 1) < messages.length) - ? messages[i + 1].message - : null, - group: group, - galleryItems: galleryItems, - userIdToContact: userIdToContact, - scrollToMessage: scrollToMessage, - onResponseTriggered: () { - setState(() { - quotesMessage = chatMessage; - }); - textFieldFocus?.requestFocus(); - }, + child: AnimatedNewMessage( + key: Key('anim_${chatMessage.messageId}'), + messageId: chatMessage.messageId, + animateIds: _animateMessageIds, + child: ChatListEntry( + key: Key(chatMessage.messageId), + message: messages[i].message!, + nextMessage: (i > 0) + ? messages[i - 1].message + : null, + prevMessage: ((i + 1) < messages.length) + ? messages[i + 1].message + : null, + group: group, + galleryItems: galleryItems, + userIdToContact: userIdToContact, + scrollToMessage: scrollToMessage, + onResponseTriggered: () { + setState(() { + quotesMessage = chatMessage; + }); + textFieldFocus?.requestFocus(); + }, + ), ), ); } diff --git a/lib/src/visual/views/chats/chat_messages_components/animated_new_message.dart b/lib/src/visual/views/chats/chat_messages_components/animated_new_message.dart new file mode 100644 index 00000000..01569e01 --- /dev/null +++ b/lib/src/visual/views/chats/chat_messages_components/animated_new_message.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; + +class AnimatedNewMessage extends StatefulWidget { + const AnimatedNewMessage({ + required this.child, + required this.messageId, + required this.animateIds, + super.key, + }); + + final Widget child; + final String messageId; + final Set animateIds; + + @override + State createState() => _AnimatedNewMessageState(); +} + +class _AnimatedNewMessageState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _scaleAnimation; + late Animation _opacityAnimation; + bool _didAnimate = false; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 200), + vsync: this, + ); + _scaleAnimation = + Tween( + begin: 0, + end: 1, + ).animate( + CurvedAnimation( + parent: _controller, + curve: Curves.decelerate, + ), + ); + _opacityAnimation = + Tween( + begin: 0, + end: 1, + ).animate( + CurvedAnimation( + parent: _controller, + curve: Curves.easeOut, + ), + ); + + if (widget.animateIds.contains(widget.messageId)) { + widget.animateIds.remove(widget.messageId); + _didAnimate = true; + _controller.forward(); + } else { + _controller.value = 1.0; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (!_didAnimate) { + return widget.child; + } + return SizeTransition( + sizeFactor: CurvedAnimation( + parent: _controller, + curve: Curves.easeOut, + ), + axisAlignment: 1, + child: ScaleTransition( + scale: _scaleAnimation, + alignment: Alignment.bottomRight, + child: FadeTransition( + opacity: _opacityAnimation, + child: widget.child, + ), + ), + ); + } +} diff --git a/lib/src/visual/views/chats/chat_messages_components/entries/chat_date_chip.dart b/lib/src/visual/views/chats/chat_messages_components/entries/chat_date_chip.dart index fa8ccbe2..b1365fb0 100644 --- a/lib/src/visual/views/chats/chat_messages_components/entries/chat_date_chip.dart +++ b/lib/src/visual/views/chats/chat_messages_components/entries/chat_date_chip.dart @@ -9,8 +9,24 @@ class ChatDateChip extends StatelessWidget { @override Widget build(BuildContext context) { - final formattedDate = - '${DateFormat.Hm(Localizations.localeOf(context).toLanguageTag()).format(item.date!)}\n${DateFormat.yMd(Localizations.localeOf(context).toLanguageTag()).format(item.date!)}'; + final now = DateTime.now(); + final date = item.date!; + final locale = Localizations.localeOf(context).toLanguageTag(); + + final today = DateTime(now.year, now.month, now.day); + final yesterday = today.subtract(const Duration(days: 1)); + final itemDay = DateTime(date.year, date.month, date.day); + + String formattedDate; + if (itemDay == today) { + formattedDate = context.lang.today; + } else if (itemDay == yesterday) { + formattedDate = context.lang.yesterday; + } else if (date.year == now.year) { + formattedDate = DateFormat('E, d. MMM.', locale).format(date); + } else { + formattedDate = DateFormat('E, d. MMM. y', locale).format(date); + } return Center( child: Container( diff --git a/lib/src/visual/views/chats/chat_messages_components/message_input.dart b/lib/src/visual/views/chats/chat_messages_components/message_input.dart index 915a7d55..6c543f0f 100644 --- a/lib/src/visual/views/chats/chat_messages_components/message_input.dart +++ b/lib/src/visual/views/chats/chat_messages_components/message_input.dart @@ -86,7 +86,8 @@ class _MessageInputState extends State { ) async { if (widget.textFieldFocus.hasFocus && _lastTextChangeTime != null && - DateTime.now().difference(_lastTextChangeTime!) <= const Duration(seconds: 6)) { + DateTime.now().difference(_lastTextChangeTime!) <= + const Duration(seconds: 6)) { await sendTypingIndication(widget.group.groupId, true); } }); @@ -210,7 +211,9 @@ class _MessageInputState extends State { Future _loadContactId() async { if (widget.group.isDirectChat) { - final members = await twonlyDB.groupsDao.getGroupContact(widget.group.groupId); + final members = await twonlyDB.groupsDao.getGroupContact( + widget.group.groupId, + ); if (members.isNotEmpty && mounted) { setState(() { _contactId = members.first.userId; @@ -240,18 +243,14 @@ class _MessageInputState extends State { UnverifiedContactWarningComp( group: widget.group, child: Padding( - padding: const EdgeInsets.only( - bottom: 10, - left: 10, - top: 5, - ), + padding: const EdgeInsets.only(left: 10, bottom: 10), child: Row( children: [ Expanded( child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 3, - ), + // padding: const EdgeInsets.symmetric( + // horizontal: 3, + // ), decoration: BoxDecoration( color: context.color.surfaceContainer, borderRadius: BorderRadius.circular(20), @@ -281,7 +280,9 @@ class _MessageInputState extends State { ), child: FaIcon( size: 20, - _emojiShowing ? FontAwesomeIcons.keyboard : FontAwesomeIcons.faceSmile, + _emojiShowing + ? FontAwesomeIcons.keyboard + : FontAwesomeIcons.faceSmile, ), ), ), @@ -293,7 +294,8 @@ class _MessageInputState extends State { controller: _textFieldController, focusNode: widget.textFieldFocus, keyboardType: TextInputType.multiline, - showCursor: _recordingState != RecordingState.recording, + showCursor: + _recordingState != RecordingState.recording, maxLines: 4, minLines: 1, onChanged: (value) async { @@ -344,16 +346,21 @@ class _MessageInputState extends State { _currentDuration, ), style: TextStyle( - color: isDarkMode(context) ? Colors.white : Colors.black, + color: isDarkMode(context) + ? Colors.white + : Colors.black, fontSize: 12, ), ), if (!_audioRecordingLock) ...[ SizedBox( - width: (100 - _cancelSlideOffset) % 101, + width: + (100 - _cancelSlideOffset) % 101, ), Text( - context.lang.voiceMessageSlideToCancel, + context + .lang + .voiceMessageSlideToCancel, ), ] else ...[ Expanded( @@ -394,13 +401,17 @@ class _MessageInputState extends State { GestureDetector( onLongPressMoveUpdate: (details) { if (_audioRecordingLock) return; - if (_recordingOffset.dy - details.localPosition.dy >= 100) { + if (_recordingOffset.dy - + details.localPosition.dy >= + 100) { HapticFeedback.heavyImpact(); setState(() { _audioRecordingLock = true; }); } - if (_recordingOffset.dx - details.localPosition.dx >= 90 && + if (_recordingOffset.dx - + details.localPosition.dx >= + 90 && _recordingState == RecordingState.recording) { _recordingState = RecordingState.none; HapticFeedback.heavyImpact(); @@ -408,9 +419,13 @@ class _MessageInputState extends State { } setState(() { - final a = _recordingOffset.dx - details.localPosition.dx; + final a = + _recordingOffset.dx - + details.localPosition.dx; if (a > 0 && a <= 90) { - _cancelSlideOffset = _recordingOffset.dx - details.localPosition.dx; + _cancelSlideOffset = + _recordingOffset.dx - + details.localPosition.dx; } }); }, @@ -430,7 +445,9 @@ class _MessageInputState extends State { child: Stack( clipBehavior: Clip.none, children: [ - if (_recordingState == RecordingState.recording && !_audioRecordingLock) + if (_recordingState == + RecordingState.recording && + !_audioRecordingLock) Positioned.fill( top: -120, left: -5, @@ -440,8 +457,12 @@ class _MessageInputState extends State { padding: const EdgeInsets.only(top: 13), height: 60, decoration: BoxDecoration( - borderRadius: BorderRadius.circular(90), - color: isDarkMode(context) ? Colors.black : Colors.white, + borderRadius: BorderRadius.circular( + 90, + ), + color: isDarkMode(context) + ? Colors.black + : Colors.white, ), child: const Center( child: Column( @@ -461,7 +482,9 @@ class _MessageInputState extends State { ), ), ), - if (_recordingState == RecordingState.recording && !_audioRecordingLock) + if (_recordingState == + RecordingState.recording && + !_audioRecordingLock) Positioned.fill( top: -20, left: -25, @@ -488,10 +511,15 @@ class _MessageInputState extends State { ), child: FaIcon( size: 20, - color: (_recordingState == RecordingState.recording) ? Colors.white : null, + color: + (_recordingState == + RecordingState.recording) + ? Colors.white + : null, (_recordingState == RecordingState.none) ? FontAwesomeIcons.microphone - : (_recordingState == RecordingState.recording) + : (_recordingState == + RecordingState.recording) ? FontAwesomeIcons.stop : FontAwesomeIcons.play, ), @@ -511,7 +539,9 @@ class _MessageInputState extends State { color: context.color.primary, FontAwesomeIcons.solidPaperPlane, ), - onPressed: _audioRecordingLock ? _stopAudioRecording : _sendMessage, + onPressed: _audioRecordingLock + ? _stopAudioRecording + : _sendMessage, ) else IconButton( diff --git a/lib/src/visual/views/chats/chat_messages_components/unverified_contact_warning.comp.dart b/lib/src/visual/views/chats/chat_messages_components/unverified_contact_warning.comp.dart index 789c5763..5d569fa7 100644 --- a/lib/src/visual/views/chats/chat_messages_components/unverified_contact_warning.comp.dart +++ b/lib/src/visual/views/chats/chat_messages_components/unverified_contact_warning.comp.dart @@ -23,11 +23,16 @@ class UnverifiedContactWarningComp extends StatelessWidget { return StreamBuilder( stream: userService.onUserUpdated, builder: (context, _) { - if (!userService.currentUser.securityProfile.showWarningForNonVerifiedContacts) { + if (!userService + .currentUser + .securityProfile + .showWarningForNonVerifiedContacts) { return child; } return StreamBuilder( - stream: twonlyDB.keyVerificationDao.watchAllGroupMembersVerified(group.groupId), + stream: twonlyDB.keyVerificationDao.watchAllGroupMembersVerified( + group.groupId, + ), builder: (context, snapshot) { final status = snapshot.data; if (status == null || status == VerificationStatus.trusted) { @@ -39,7 +44,9 @@ class UnverifiedContactWarningComp extends StatelessWidget { decoration: BoxDecoration( color: context.color.errorContainer.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(24), - border: Border.all(color: context.color.error.withValues(alpha: 0.5)), + border: Border.all( + color: context.color.error.withValues(alpha: 0.5), + ), ), child: Column( mainAxisSize: MainAxisSize.min, @@ -93,14 +100,23 @@ class UnverifiedContactWarningComp extends StatelessWidget { style: FilledButton.styleFrom( backgroundColor: context.color.onErrorContainer, foregroundColor: context.color.errorContainer, - padding: const EdgeInsets.symmetric(horizontal: 10), - textStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold), + padding: const EdgeInsets.symmetric( + horizontal: 10, + ), + textStyle: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + ), ), onPressed: () async { if (group.isDirectChat) { - await context.push(Routes.settingsHelpFaqVerifyBadge); + await context.push( + Routes.settingsHelpFaqVerifyBadge, + ); } else { - await context.push(Routes.profileGroup(group.groupId)); + await context.push( + Routes.profileGroup(group.groupId), + ); } }, child: Text(context.lang.unverifiedWarningButton), @@ -109,7 +125,12 @@ class UnverifiedContactWarningComp extends StatelessWidget { ], ), ), - child, + Padding( + padding: const EdgeInsets.only( + top: 5, + ), + child: child, + ), ], ), ); diff --git a/lib/src/visual/views/contact/contact_components/mutual_groups_expansion_tile.comp.dart b/lib/src/visual/views/contact/contact_components/mutual_groups_expansion_tile.comp.dart index e84e0f99..219dca47 100644 --- a/lib/src/visual/views/contact/contact_components/mutual_groups_expansion_tile.comp.dart +++ b/lib/src/visual/views/contact/contact_components/mutual_groups_expansion_tile.comp.dart @@ -62,7 +62,6 @@ class _MutualGroupsExpansionTileCompState shape: const RoundedRectangleBorder(), backgroundColor: context.color.surfaceContainer, collapsedShape: const RoundedRectangleBorder(), - initiallyExpanded: _groups.length < 5, onExpansionChanged: (expanded) { setState(() {}); }, diff --git a/lib/src/visual/views/settings/developer/developer.view.dart b/lib/src/visual/views/settings/developer/developer.view.dart index fcef4c8d..8200b965 100644 --- a/lib/src/visual/views/settings/developer/developer.view.dart +++ b/lib/src/visual/views/settings/developer/developer.view.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'dart:ui' as ui; import 'package:clock/clock.dart'; @@ -9,12 +10,15 @@ import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:restart_app/restart_app.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:twonly/globals.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/database/tables/mediafiles.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; import 'package:twonly/src/services/user.service.dart'; +import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/storage.dart'; import 'package:twonly/src/visual/components/alert.dialog.dart'; @@ -293,6 +297,30 @@ class _DeveloperSettingsViewState extends State { onTap: () => context.navPush(const UserDiscoveryDeveloperView()), ), + ListTile( + title: const Text('Share local database'), + onTap: () async { + final dbCopyPath = + '${AppEnvironment.cacheDir}/twonly_copy.sqlite'; + final dbCopyFile = File(dbCopyPath); + if (dbCopyFile.existsSync()) { + dbCopyFile.deleteSync(); + } + try { + await twonlyDB.customStatement("VACUUM INTO '$dbCopyPath'"); + if (dbCopyFile.existsSync()) { + await SharePlus.instance.share( + ShareParams( + files: [XFile(dbCopyPath)], + text: 'Twonly Database', + ), + ); + } + } catch (e) { + Log.error('Failed to create database copy: $e'); + } + }, + ), ListTile( title: const Text('Toggle Video Stabilization'), onTap: toggleVideoStabilization, diff --git a/pubspec.yaml b/pubspec.yaml index 8c4fb1ad..9ce685fb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec publish_to: 'none' -version: 0.2.21+130 +version: 0.2.22+131 environment: sdk: ^3.11.0 From c10dc193420c33c1eae5498baae00f7376216e25 Mon Sep 17 00:00:00 2001 From: otsmr Date: Thu, 28 May 2026 02:31:05 +0200 Subject: [PATCH 3/4] remove date --- .../chat_messages_components/entries/chat_date_chip.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/visual/views/chats/chat_messages_components/entries/chat_date_chip.dart b/lib/src/visual/views/chats/chat_messages_components/entries/chat_date_chip.dart index b1365fb0..98b6e3fe 100644 --- a/lib/src/visual/views/chats/chat_messages_components/entries/chat_date_chip.dart +++ b/lib/src/visual/views/chats/chat_messages_components/entries/chat_date_chip.dart @@ -23,9 +23,9 @@ class ChatDateChip extends StatelessWidget { } else if (itemDay == yesterday) { formattedDate = context.lang.yesterday; } else if (date.year == now.year) { - formattedDate = DateFormat('E, d. MMM.', locale).format(date); + formattedDate = DateFormat('E, d. MMM', locale).format(date); } else { - formattedDate = DateFormat('E, d. MMM. y', locale).format(date); + formattedDate = DateFormat('E, d. MMM y', locale).format(date); } return Center( From 0c32b41dd0406628fd47cfd584110195530cdda4 Mon Sep 17 00:00:00 2001 From: otsmr Date: Thu, 28 May 2026 02:32:07 +0200 Subject: [PATCH 4/4] bump version --- CHANGELOG.md | 5 +++++ pubspec.yaml | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5654273..de9937f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.2.23 + +- Improves: Smaller UI changes +- Fix: Some messages were not marked as opened. + ## 0.2.20 - New: Adds an "Ask a Friend" button to new contact suggestions. diff --git a/pubspec.yaml b/pubspec.yaml index 9ce685fb..1294c242 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec publish_to: 'none' -version: 0.2.22+131 +version: 0.2.23+132 environment: sdk: ^3.11.0