mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-06-02 20:22:12 +00:00
Merge pull request #416 from twonlyapp/dev
Some checks failed
Publish on Github / build_and_publish (push) Has been cancelled
Some checks failed
Publish on Github / build_and_publish (push) Has been cancelled
- Improves: Smaller UI changes - Fix: Some messages were not marked as opened.
This commit is contained in:
commit
0602f043d2
24 changed files with 398 additions and 129 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> 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<TwonlyDB> 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) |
|
||||
|
|
@ -253,41 +253,46 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
|||
List<String> messageIds,
|
||||
DateTime timestamp,
|
||||
) async {
|
||||
try {
|
||||
await twonlyDB.batch((batch) async {
|
||||
for (final messageId in messageIds) {
|
||||
batch.insert(
|
||||
messageActions,
|
||||
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),
|
||||
),
|
||||
mode: InsertMode.insertOrReplace,
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
Log.error(e);
|
||||
}
|
||||
|
||||
for (final messageId in messageIds) {
|
||||
try {
|
||||
final isOpenedByAll = await haveAllMembers(
|
||||
messageId,
|
||||
MessageActionType.openedAt,
|
||||
);
|
||||
final rowsUpdated =
|
||||
await (update(
|
||||
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(e);
|
||||
Log.error('handleMessagesOpened failed for $messageId: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,14 @@ class ReactionsDao extends DatabaseAccessor<TwonlyDB> 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) {
|
||||
|
|
|
|||
|
|
@ -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;');
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1988,4 +1988,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get unverifiedWarningButton => 'Jetzt verifizieren';
|
||||
|
||||
@override
|
||||
String get today => 'Heute';
|
||||
|
||||
@override
|
||||
String get yesterday => 'Gestern';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1972,4 +1972,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get unverifiedWarningButton => 'Verify now';
|
||||
|
||||
@override
|
||||
String get today => 'Today';
|
||||
|
||||
@override
|
||||
String get yesterday => 'Yesterday';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit c33a4c3be99b38596abd0cfa91333db3a340dee2
|
||||
Subproject commit 9a538845a340f41ee8d2d23bc4c3e8fd73797203
|
||||
|
|
@ -10,7 +10,10 @@ Future<void> 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,
|
||||
|
|
|
|||
|
|
@ -413,6 +413,9 @@ Future<void> 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<void> _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()) {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
@ -350,7 +351,9 @@ Future<void> 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 +486,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 +582,21 @@ Future<void> sendContactMyProfileData(int contactId) async {
|
|||
);
|
||||
await sendCipherText(contactId, encryptedContent, blocking: false);
|
||||
}
|
||||
|
||||
String _getEncryptedContentTypes(pb.EncryptedContent content) {
|
||||
final ignoredFields = {
|
||||
'groupId',
|
||||
'isDirectChat',
|
||||
'senderProfileCounter',
|
||||
'senderUserDiscoveryVersion',
|
||||
};
|
||||
|
||||
final types = <String>[];
|
||||
for (final field in content.info_.byName.values) {
|
||||
if (content.hasField(field.tagNumber) &&
|
||||
!ignoredFields.contains(field.name)) {
|
||||
types.add(field.name);
|
||||
}
|
||||
}
|
||||
return types.join(', ');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<void> 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,
|
||||
|
|
|
|||
|
|
@ -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).',
|
||||
|
|
|
|||
|
|
@ -212,6 +212,7 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
|||
// Maybe this is the reason?
|
||||
return;
|
||||
} else {
|
||||
await androidVolumeDownSub?.cancel();
|
||||
androidVolumeDownSub = FlutterAndroidVolumeKeydown.stream.listen((
|
||||
event,
|
||||
) {
|
||||
|
|
@ -233,6 +234,7 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
|||
}
|
||||
if (Platform.isAndroid) {
|
||||
await androidVolumeDownSub?.cancel();
|
||||
androidVolumeDownSub = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<ChatMessagesView>
|
||||
with WidgetsBindingObserver {
|
||||
HashSet<int> alreadyReportedOpened = HashSet<int>();
|
||||
|
||||
bool _hasReceivedFirstMessageBatch = false;
|
||||
final HashSet<String> _knownMessageIds = HashSet<String>();
|
||||
final HashSet<String> _animateMessageIds = HashSet<String>();
|
||||
|
||||
StreamSubscription<Group?>? userSub;
|
||||
StreamSubscription<List<Message>>? messageSub;
|
||||
StreamSubscription<List<GroupHistory>>? groupActionsSub;
|
||||
|
|
@ -131,6 +137,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
|||
allMessages = update;
|
||||
await protectMessageUpdating.protect(() async {
|
||||
await setMessages(update, groupActions);
|
||||
_hasReceivedFirstMessageBatch = true;
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -161,6 +168,15 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
|||
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 = <ChatItem>[];
|
||||
final storedMediaFiles = <Message>[];
|
||||
|
||||
|
|
@ -337,11 +353,18 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
|||
} else {
|
||||
final chatMessage = messages[i].message!;
|
||||
return BlinkWidget(
|
||||
key: Key('blink_${chatMessage.messageId}'),
|
||||
enabled: focusedScrollItem == i,
|
||||
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,
|
||||
nextMessage: (i > 0)
|
||||
? messages[i - 1].message
|
||||
: null,
|
||||
prevMessage: ((i + 1) < messages.length)
|
||||
? messages[i + 1].message
|
||||
: null,
|
||||
|
|
@ -356,6 +379,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
|||
textFieldFocus?.requestFocus();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<String> animateIds;
|
||||
|
||||
@override
|
||||
State<AnimatedNewMessage> createState() => _AnimatedNewMessageState();
|
||||
}
|
||||
|
||||
class _AnimatedNewMessageState extends State<AnimatedNewMessage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _scaleAnimation;
|
||||
late Animation<double> _opacityAnimation;
|
||||
bool _didAnimate = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
vsync: this,
|
||||
);
|
||||
_scaleAnimation =
|
||||
Tween<double>(
|
||||
begin: 0,
|
||||
end: 1,
|
||||
).animate(
|
||||
CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.decelerate,
|
||||
),
|
||||
);
|
||||
_opacityAnimation =
|
||||
Tween<double>(
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -86,7 +86,8 @@ class _MessageInputState extends State<MessageInput> {
|
|||
) 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<MessageInput> {
|
|||
|
||||
Future<void> _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<MessageInput> {
|
|||
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<MessageInput> {
|
|||
),
|
||||
child: FaIcon(
|
||||
size: 20,
|
||||
_emojiShowing ? FontAwesomeIcons.keyboard : FontAwesomeIcons.faceSmile,
|
||||
_emojiShowing
|
||||
? FontAwesomeIcons.keyboard
|
||||
: FontAwesomeIcons.faceSmile,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -293,7 +294,8 @@ class _MessageInputState extends State<MessageInput> {
|
|||
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<MessageInput> {
|
|||
_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<MessageInput> {
|
|||
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<MessageInput> {
|
|||
}
|
||||
|
||||
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<MessageInput> {
|
|||
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<MessageInput> {
|
|||
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<MessageInput> {
|
|||
),
|
||||
),
|
||||
),
|
||||
if (_recordingState == RecordingState.recording && !_audioRecordingLock)
|
||||
if (_recordingState ==
|
||||
RecordingState.recording &&
|
||||
!_audioRecordingLock)
|
||||
Positioned.fill(
|
||||
top: -20,
|
||||
left: -25,
|
||||
|
|
@ -488,10 +511,15 @@ class _MessageInputState extends State<MessageInput> {
|
|||
),
|
||||
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<MessageInput> {
|
|||
color: context.color.primary,
|
||||
FontAwesomeIcons.solidPaperPlane,
|
||||
),
|
||||
onPressed: _audioRecordingLock ? _stopAudioRecording : _sendMessage,
|
||||
onPressed: _audioRecordingLock
|
||||
? _stopAudioRecording
|
||||
: _sendMessage,
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
|
|
|
|||
|
|
@ -24,16 +24,16 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
// message received
|
||||
if (msg.openedAt == null) {
|
||||
|
|
|
|||
|
|
@ -23,11 +23,16 @@ class UnverifiedContactWarningComp extends StatelessWidget {
|
|||
return StreamBuilder<void>(
|
||||
stream: userService.onUserUpdated,
|
||||
builder: (context, _) {
|
||||
if (!userService.currentUser.securityProfile.showWarningForNonVerifiedContacts) {
|
||||
if (!userService
|
||||
.currentUser
|
||||
.securityProfile
|
||||
.showWarningForNonVerifiedContacts) {
|
||||
return child;
|
||||
}
|
||||
return StreamBuilder<VerificationStatus>(
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -62,7 +62,6 @@ class _MutualGroupsExpansionTileCompState
|
|||
shape: const RoundedRectangleBorder(),
|
||||
backgroundColor: context.color.surfaceContainer,
|
||||
collapsedShape: const RoundedRectangleBorder(),
|
||||
initiallyExpanded: _groups.length < 5,
|
||||
onExpansionChanged: (expanded) {
|
||||
setState(() {});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<DeveloperSettingsView> {
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -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.23+132
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.0
|
||||
|
|
|
|||
Loading…
Reference in a new issue