Performance improvements
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run

This commit is contained in:
otsmr 2026-08-24 01:13:31 +02:00
parent 74c20389c9
commit e8abfe421b
15 changed files with 744 additions and 277 deletions

View file

@ -36,6 +36,11 @@ class ContactsDao extends DatabaseAccessor<TwonlyDB> with _$ContactsDaoMixin {
return select(contacts)..where((t) => t.userId.equals(userId));
}
Stream<List<Contact>> watchContactsByIds(Set<int> userIds) {
if (userIds.isEmpty) return Stream.value(const []);
return (select(contacts)..where((t) => t.userId.isIn(userIds))).watch();
}
Future<Contact?> getContactById(int userId) async {
return (select(
contacts,

View file

@ -86,9 +86,18 @@ class GroupsDao extends DatabaseAccessor<TwonlyDB> with _$GroupsDaoMixin {
await into(groupHistories).insert(insertAction);
}
Stream<List<GroupHistory>> watchGroupActions(String groupId) {
Stream<List<GroupHistory>> watchGroupActions(
String groupId, {
DateTime? since,
}) {
return (select(groupHistories)
..where((t) => t.groupId.equals(groupId))
..where(
(t) =>
t.groupId.equals(groupId) &
(since == null
? const Constant(true)
: t.actionAt.isBiggerOrEqualValue(since)),
)
..orderBy([(t) => OrderingTerm.asc(t.actionAt)]))
.watch();
}

View file

@ -166,6 +166,13 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
.watch();
}
Stream<List<MediaFile>> watchMediaFilesByIds(Set<String> mediaIds) {
if (mediaIds.isEmpty) return Stream.value(const []);
return (select(
mediaFiles,
)..where((file) => file.mediaId.isIn(mediaIds))).watch();
}
Stream<List<MediaFile>> watchMediaFilesForGroup(String groupId) {
final query = select(mediaFiles).join([
innerJoin(

View file

@ -142,7 +142,10 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
.not() |
mediaFiles.downloadState.isNull()),
)
..orderBy([OrderingTerm.desc(messages.createdAt)])
..orderBy([
OrderingTerm.desc(messages.createdAt),
OrderingTerm.desc(messages.messageId),
])
..limit(1);
return query.map((row) => row.readTable(messages)).watchSingleOrNull();
@ -180,7 +183,10 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
);
}
Future<Stream<List<Message>>> watchByGroupId(String groupId) async {
Future<Stream<List<Message>>> watchByGroupId(
String groupId, {
int limit = 100,
}) async {
final group = await twonlyDB.groupsDao.getGroup(groupId);
final deletionTime = clock.now().subtract(
Duration(
@ -211,9 +217,61 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
.equals(DownloadState.reuploadRequested.name)
.not()))),
)
..orderBy([OrderingTerm.asc(messages.createdAt)]);
..orderBy([OrderingTerm.desc(messages.createdAt)])
..limit(limit);
return query.map((row) => row.readTable(messages)).watch();
return query
.map((row) => row.readTable(messages))
.watch()
.map((items) => items.reversed.toList());
}
Future<List<Message>> getMessagesBefore(
String groupId,
DateTime before, {
required String beforeMessageId,
int limit = 100,
}) async {
final group = await twonlyDB.groupsDao.getGroup(groupId);
final deletionTime = clock.now().subtract(
Duration(milliseconds: group!.deleteMessagesAfterMilliseconds),
);
final query =
select(messages).join([
leftOuterJoin(
mediaFiles,
mediaFiles.mediaId.equalsExp(messages.mediaId),
),
])
..where(
messages.groupId.equals(groupId) &
(messages.createdAt.isSmallerThanValue(before) |
(messages.createdAt.equals(before) &
messages.messageId.isSmallerThanValue(
beforeMessageId,
))) &
(messages.openedAt.isBiggerThanValue(deletionTime) |
messages.openedAt.isNull() |
messages.mediaStored.equals(true)) &
(messages.isDeletedFromSender.equals(true) |
(messages.type.equals(MessageType.text.name).not() &
messages.type.equals(MessageType.media.name).not()) |
(messages.type.equals(MessageType.text.name) &
messages.content.isNotNull()) |
(messages.type.equals(MessageType.media.name) &
messages.mediaId.isNotNull() &
(mediaFiles.downloadState.isNull() |
mediaFiles.downloadState
.equals(DownloadState.reuploadRequested.name)
.not()))),
)
..orderBy([
OrderingTerm.desc(messages.createdAt),
OrderingTerm.desc(messages.messageId),
])
..limit(limit);
final items = await query.map((row) => row.readTable(messages)).get();
return items.reversed.toList();
}
Stream<List<(GroupMember, Contact)>> watchMembersByGroupId(String groupId) {
@ -628,9 +686,19 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
.watch();
}
Stream<List<MessageAction>> watchMessageActionsForGroup(
String groupId,
Stream<List<MessageAction>> watchAcknowledgementsForMessages(
Set<String> messageIds,
) {
if (messageIds.isEmpty) return Stream.value(const []);
return (select(messageActions)..where(
(action) =>
action.messageId.isIn(messageIds) &
action.type.equals(MessageActionType.ackByUserAt.name),
))
.watch();
}
Stream<List<MessageAction>> watchMessageActionsForGroup(String groupId) {
final query = select(messageActions).join([
innerJoin(
messages,

View file

@ -108,6 +108,14 @@ class ReactionsDao extends DatabaseAccessor<TwonlyDB> with _$ReactionsDaoMixin {
.watch();
}
Stream<List<Reaction>> watchReactionsForMessages(Set<String> messageIds) {
if (messageIds.isEmpty) return Stream.value(const []);
return (select(reactions)
..where((reaction) => reaction.messageId.isIn(messageIds))
..orderBy([(reaction) => OrderingTerm.desc(reaction.createdAt)]))
.watch();
}
Stream<List<Reaction>> watchReactionsForGroup(String groupId) {
final query =
select(reactions).join([

View file

@ -296,11 +296,9 @@ class MediaFileService {
bool get imagePreviewAvailable =>
mediaFile.hasThumbnail ||
(thumbnailPath.existsSync() && thumbnailPath.lengthSync() > 0) ||
mediaFile.type == MediaType.audio ||
((mediaFile.type == MediaType.image || mediaFile.type == MediaType.gif) &&
storedPath.existsSync() &&
storedPath.lengthSync() > 0);
mediaFile.stored);
Future<void> storeMediaFile() async {
Log.info('Storing media file ${mediaFile.mediaId}');

View file

@ -6,7 +6,6 @@ import 'package:flutter_svg/svg.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/utils/avatars.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:vector_graphics/vector_graphics.dart';
class AvatarIcon extends StatefulWidget {
@ -34,6 +33,7 @@ class AvatarIcon extends StatefulWidget {
class _AvatarIconState extends State<AvatarIcon> {
List<Contact> _avatarContacts = [];
Set<int> _contactsWithPngAvatar = {};
String? _myAvatarPath;
StreamSubscription<List<Contact>>? groupStream;
@ -59,9 +59,24 @@ class _AvatarIconState extends State<AvatarIcon> {
_avatarContacts = contacts
.where((contact) => contact.avatarSvgCompressed != null)
.toList();
unawaited(_refreshAvatarFiles());
if (mounted) setState(() {});
}
Future<void> _refreshAvatarFiles() async {
final available = <int>{};
for (final contact in _avatarContacts) {
final file = avatarPNGFile(contact.userId);
// Async file access keeps avatar discovery off the UI thread.
// ignore: avoid_slow_async_io
if (await file.exists()) {
available.add(contact.userId);
}
}
if (!mounted) return;
setState(() => _contactsWithPngAvatar = available);
}
@override
void dispose() {
groupStream?.cancel();
@ -79,18 +94,13 @@ class _AvatarIconState extends State<AvatarIcon> {
Widget getAvatarForContact(Contact contact) {
final avatarFile = avatarPNGFile(contact.userId);
if (avatarFile.existsSync()) {
if (_contactsWithPngAvatar.contains(contact.userId)) {
return Image.file(
avatarFile,
errorBuilder: errorBuilder,
);
}
Log.warn(
'PNG avatar file for contact ${contact.userId} does not exist. Generating in background.',
);
unawaited(createPushAvatars(forceForUserId: contact.userId));
if (contact.avatarSvgCompressed != null) {
return SvgPicture.string(
getAvatarSvg(contact.avatarSvgCompressed!),
@ -119,6 +129,7 @@ class _AvatarIconState extends State<AvatarIcon> {
}
}
}
unawaited(_refreshAvatarFiles());
setState(() {});
});
} else if (widget.myAvatar) {
@ -132,6 +143,7 @@ class _AvatarIconState extends State<AvatarIcon> {
.listen((contact) {
if (contact != null && contact.avatarSvgCompressed != null) {
_avatarContacts = [contact];
unawaited(_refreshAvatarFiles());
setState(() {});
}
});

View file

@ -13,7 +13,9 @@ import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/tables/messages.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/memory_item.model.dart';
import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart';
import 'package:twonly/src/services/api/messages.api.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/notifications/background.notifications.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
@ -36,6 +38,7 @@ class _MessageAnimationState {
bool hasReceivedFirstBatch = false;
final HashSet<String> knownMessageIds = HashSet<String>();
final HashSet<String> animateMessageIds = HashSet<String>();
final HashSet<String> reportedOpenedMessageIds = HashSet<String>();
}
class _ChatViewData {
@ -46,10 +49,15 @@ class _ChatViewData {
List<ChatItem> chatItems = [];
List<Message> allMessages = [];
List<Message> latestMessages = [];
List<Message> olderMessages = [];
Map<String, Message> messagesById = {};
List<GroupHistory> groupActions = [];
List<MemoryItem> galleryItems = [];
Set<String> galleryMessageIds = {};
Set<String> watchedMessageIds = {};
Set<int> watchedContactIds = {};
DateTime? watchedGroupActionsSince;
}
class _ChatSubscriptions {
@ -83,11 +91,12 @@ class ChatMessagesView extends StatefulWidget {
class _ChatMessagesViewState extends State<ChatMessagesView>
with WidgetsBindingObserver {
HashSet<int> alreadyReportedOpened = HashSet<int>();
static const _messagePageSize = 100;
final _animationState = _MessageAnimationState();
final _subscriptions = _ChatSubscriptions();
final _data = _ChatViewData();
final ValueNotifier<int> _messageDataVersion = ValueNotifier(0);
Group? _group;
List<Contact> _groupContacts = [];
@ -95,8 +104,12 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
GlobalKey verifyShieldKey = GlobalKey();
FocusNode? textFieldFocus;
final ItemScrollController itemScrollController = ItemScrollController();
final ItemPositionsListener itemPositionsListener =
ItemPositionsListener.create();
int? focusedScrollItem;
bool _receiverDeletedAccount = false;
Future<void>? _olderMessagesLoad;
bool _hasMoreMessages = true;
Timer? _nextTypingIndicator;
@ -105,12 +118,15 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
super.initState();
textFieldFocus = FocusNode();
WidgetsBinding.instance.addObserver(this);
itemPositionsListener.itemPositions.addListener(_loadOlderWhenNeeded);
initStreams();
}
@override
void dispose() {
_subscriptions.cancelAll();
_messageDataVersion.dispose();
itemPositionsListener.itemPositions.removeListener(_loadOlderWhenNeeded);
_nextTypingIndicator?.cancel();
try {
textFieldFocus?.dispose();
@ -126,6 +142,10 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
(ModalRoute.of(context)?.isCurrent ?? false);
}
void _notifyMessageDataChanged() {
_messageDataVersion.value++;
}
Future<void> initStreams() async {
final groupStream = twonlyDB.groupsDao.watchGroup(widget.groupId);
_subscriptions.group = groupStream.listen((newGroup) {
@ -134,71 +154,18 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
setState(() {
_group = newGroup;
});
if (_subscriptions.groupActions == null) {
final actionsStream = twonlyDB.groupsDao.watchGroupActions(
newGroup.groupId,
);
_subscriptions.groupActions = actionsStream.listen((update) async {
_data.groupActions = update;
await setMessages(_data.allMessages, update);
});
final contactsStream = twonlyDB.contactsDao.watchAllContacts();
_subscriptions.contacts = contactsStream.listen((contacts) {
final contactMap = <int, Contact>{};
for (final contact in contacts) {
contactMap[contact.userId] = contact;
}
if (mounted) setState(() => _data.contactsById = contactMap);
});
}
});
final msgStream = await twonlyDB.messagesDao.watchByGroupId(widget.groupId);
_subscriptions.messages = msgStream.listen((update) async {
_data.allMessages = update;
_data.messagesById = {
for (final message in update) message.messageId: message,
};
await setMessages(update, _data.groupActions);
_data.latestMessages = update;
if (_data.olderMessages.isEmpty) {
_hasMoreMessages = update.length == _messagePageSize;
}
await _applyLoadedMessages(reportOpened: true);
_animationState.hasReceivedFirstBatch = true;
});
_subscriptions.media = twonlyDB.mediaFilesDao
.watchMediaFilesForGroup(widget.groupId)
.listen((mediaFiles) {
if (!mounted) return;
setState(
() => _data.mediaFilesById = {
for (final mediaFile in mediaFiles) mediaFile.mediaId: mediaFile,
},
);
});
_subscriptions.reactions = twonlyDB.reactionsDao
.watchReactionsForGroup(widget.groupId)
.listen((reactions) {
if (!mounted) return;
final byMessage = <String, List<Reaction>>{};
for (final reaction in reactions) {
byMessage.putIfAbsent(reaction.messageId, () => []).add(reaction);
}
setState(() => _data.reactionsByMessageId = byMessage);
});
_subscriptions.messageActions = twonlyDB.messagesDao
.watchMessageActionsForGroup(widget.groupId)
.listen((actions) {
if (!mounted) return;
setState(
() => _data.ackedMessageIds = actions
.where(
(action) => action.type == MessageActionType.ackByUserAt,
)
.map((action) => action.messageId)
.toSet(),
);
});
final groupContacts = await twonlyDB.groupsDao.getGroupContact(
widget.groupId,
);
@ -208,6 +175,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
_receiverDeletedAccount =
groupContacts.length == 1 && groupContacts.first.accountDeleted;
});
_watchRelevantContacts();
}
if (userService.currentUser.typingIndicators) {
@ -222,11 +190,172 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
}
}
Future<void> _applyLoadedMessages({required bool reportOpened}) async {
final byId = <String, Message>{
for (final message in _data.olderMessages) message.messageId: message,
for (final message in _data.latestMessages) message.messageId: message,
};
final loadedMessages = byId.values.toList()
..sort((a, b) {
final byTime = a.createdAt.compareTo(b.createdAt);
return byTime != 0 ? byTime : a.messageId.compareTo(b.messageId);
});
_data.allMessages = loadedMessages;
_data.messagesById = byId;
_watchGroupActionsForLoadedRange();
_watchLoadedMessageData();
_watchRelevantContacts();
await setMessages(
loadedMessages,
_data.groupActions,
reportOpened: reportOpened,
);
}
void _watchGroupActionsForLoadedRange() {
final since = _data.allMessages.firstOrNull?.createdAt;
if (_subscriptions.groupActions != null &&
_data.watchedGroupActionsSince == since) {
return;
}
_data.watchedGroupActionsSince = since;
unawaited(_subscriptions.groupActions?.cancel());
_subscriptions.groupActions = twonlyDB.groupsDao
.watchGroupActions(widget.groupId, since: since)
.listen((actions) async {
_data.groupActions = actions;
_watchRelevantContacts();
await setMessages(_data.allMessages, actions);
});
}
void _loadOlderWhenNeeded() {
if (_olderMessagesLoad != null || !_hasMoreMessages) return;
final positions = itemPositionsListener.itemPositions.value;
if (positions.isEmpty) return;
final oldestVisibleIndex = positions
.map((position) => position.index)
.reduce((a, b) => a > b ? a : b);
if (oldestVisibleIndex >= _data.chatItems.length - 10) {
unawaited(_loadOlderMessages());
}
}
Future<void> _loadOlderMessages() async {
if (_olderMessagesLoad case final pending?) return pending;
if (!_hasMoreMessages) return;
final load = _performLoadOlderMessages();
_olderMessagesLoad = load;
try {
await load;
} finally {
_olderMessagesLoad = null;
}
}
Future<void> _performLoadOlderMessages() async {
final oldestMessage = _data.allMessages.firstOrNull;
if (oldestMessage == null) return;
final older = await twonlyDB.messagesDao.getMessagesBefore(
widget.groupId,
oldestMessage.createdAt,
beforeMessageId: oldestMessage.messageId,
);
_hasMoreMessages = older.length == _messagePageSize;
_data.olderMessages = [...older, ..._data.olderMessages];
await _applyLoadedMessages(reportOpened: true);
}
void _watchLoadedMessageData() {
final messageIds = _data.messagesById.keys.toSet();
if (setEquals(_data.watchedMessageIds, messageIds)) return;
_data.watchedMessageIds = messageIds;
unawaited(_subscriptions.media?.cancel());
unawaited(_subscriptions.reactions?.cancel());
unawaited(_subscriptions.messageActions?.cancel());
final mediaIds = _data.allMessages
.map((message) => message.mediaId)
.whereType<String>()
.toSet();
_subscriptions.media = twonlyDB.mediaFilesDao
.watchMediaFilesByIds(mediaIds)
.listen((mediaFiles) {
if (!mounted) return;
_data.mediaFilesById = {
for (final mediaFile in mediaFiles) mediaFile.mediaId: mediaFile,
};
_notifyMessageDataChanged();
_updateGalleryItems(force: true);
});
_subscriptions.reactions = twonlyDB.reactionsDao
.watchReactionsForMessages(messageIds)
.listen((reactions) {
if (!mounted) return;
final byMessage = <String, List<Reaction>>{};
for (final reaction in reactions) {
byMessage.putIfAbsent(reaction.messageId, () => []).add(reaction);
}
_data.reactionsByMessageId = byMessage;
_notifyMessageDataChanged();
});
_subscriptions.messageActions = twonlyDB.messagesDao
.watchAcknowledgementsForMessages(messageIds)
.listen((actions) {
if (!mounted) return;
_data.ackedMessageIds = actions
.map((action) => action.messageId)
.toSet();
_notifyMessageDataChanged();
});
}
void _watchRelevantContacts() {
final contactIds = <int>{
..._groupContacts.map((contact) => contact.userId),
..._data.allMessages.map((message) => message.senderId).whereType<int>(),
..._data.groupActions
.expand((action) => [action.contactId, action.affectedContactId])
.whereType<int>(),
..._referencedContactIds(),
};
if (setEquals(_data.watchedContactIds, contactIds)) return;
_data.watchedContactIds = contactIds;
unawaited(_subscriptions.contacts?.cancel());
_subscriptions.contacts = twonlyDB.contactsDao
.watchContactsByIds(contactIds)
.listen((contacts) {
if (!mounted) return;
_data.contactsById = {
for (final contact in contacts) contact.userId: contact,
};
_notifyMessageDataChanged();
});
}
Iterable<int> _referencedContactIds() sync* {
for (final message in _data.allMessages) {
final bytes = message.additionalMessageData;
if (bytes == null) continue;
try {
final data = AdditionalMessageData.fromBuffer(bytes);
if (data.hasAskAboutUserId()) yield data.askAboutUserId.toInt();
for (final contact in data.contacts) {
yield contact.userId.toInt();
}
} catch (_) {
// Invalid additional data is handled by the corresponding bubble.
}
}
}
Future<void> setMessages(
List<Message> newMessages,
List<GroupHistory> groupActions,
) async {
if (_isViewActive()) {
List<GroupHistory> groupActions, {
bool reportOpened = false,
}) async {
if (reportOpened && _isViewActive()) {
unawaited(flutterLocalNotificationsPlugin.cancelAll());
}
@ -241,6 +370,12 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
final chatItems = <ChatItem>[];
final storedMediaFiles = <Message>[];
final oldestLoadedAt = newMessages.firstOrNull?.createdAt;
final visibleGroupActions = oldestLoadedAt == null
? groupActions
: groupActions
.where((action) => !action.actionAt.isBefore(oldestLoadedAt))
.toList();
DateTime? lastDate;
@ -249,11 +384,17 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
var groupHistoryIndex = 0;
for (final msg in newMessages) {
if (groupHistoryIndex < groupActions.length) {
for (; groupHistoryIndex < groupActions.length; groupHistoryIndex++) {
if (msg.createdAt.isAfter(groupActions[groupHistoryIndex].actionAt)) {
if (groupHistoryIndex < visibleGroupActions.length) {
for (
;
groupHistoryIndex < visibleGroupActions.length;
groupHistoryIndex++
) {
if (msg.createdAt.isAfter(
visibleGroupActions[groupHistoryIndex].actionAt,
)) {
chatItems.add(
ChatItem.groupAction(groupActions[groupHistoryIndex]),
ChatItem.groupAction(visibleGroupActions[groupHistoryIndex]),
);
// groupHistoryIndex++;
} else {
@ -263,7 +404,8 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
}
if (msg.type != MessageType.media.name &&
msg.senderId != null &&
msg.openedAt == null) {
msg.openedAt == null &&
!_animationState.reportedOpenedMessageIds.contains(msg.messageId)) {
if (openedMessages[msg.senderId!] == null) {
openedMessages[msg.senderId!] = [];
}
@ -283,14 +425,17 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
}
chatItems.add(ChatItem.message(msg));
}
if (groupHistoryIndex < groupActions.length) {
for (var i = groupHistoryIndex; i < groupActions.length; i++) {
chatItems.add(ChatItem.groupAction(groupActions[i]));
if (groupHistoryIndex < visibleGroupActions.length) {
for (var i = groupHistoryIndex; i < visibleGroupActions.length; i++) {
chatItems.add(ChatItem.groupAction(visibleGroupActions[i]));
}
}
if (_isViewActive()) {
if (reportOpened && _isViewActive()) {
for (final contactId in openedMessages.keys) {
_animationState.reportedOpenedMessageIds.addAll(
openedMessages[contactId]!,
);
unawaited(
notifyContactAboutOpeningMessage(
contactId,
@ -306,9 +451,8 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
newMessages.last.senderId == null;
if (!mounted) return;
setState(() {
_data.chatItems = chatItems.reversed.toList();
});
_data.chatItems = chatItems.reversed.toList();
_notifyMessageDataChanged();
if (wasSentByMe) {
WidgetsBinding.instance.addPostFrameCallback((_) {
@ -326,26 +470,56 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
});
}
final galleryMessageIds = storedMediaFiles
_updateGalleryItems(messages: storedMediaFiles);
}
void _updateGalleryItems({List<Message>? messages, bool force = false}) {
final storedMediaMessages =
messages ??
_data.allMessages
.where(
(message) =>
message.type == MessageType.media.name && message.mediaStored,
)
.toList();
final messageIds = storedMediaMessages
.map((message) => message.messageId)
.toSet();
if (setEquals(_data.galleryMessageIds, galleryMessageIds)) return;
final items = await MemoryItem.convertFromMessages(storedMediaFiles);
if (!force && setEquals(_data.galleryMessageIds, messageIds)) return;
final items = <String, MemoryItem>{};
for (final message in storedMediaMessages) {
final mediaFile = _data.mediaFilesById[message.mediaId];
if (mediaFile == null) continue;
final mediaService = MediaFileService(mediaFile);
if (!mediaService.imagePreviewAvailable) continue;
items
.putIfAbsent(
mediaFile.mediaId,
() => MemoryItem(mediaService: mediaService, messages: []),
)
.messages
.add(message);
}
if (!mounted) return;
setState(() {
_data.galleryMessageIds = galleryMessageIds;
_data.galleryItems = items.values.toList();
});
_data.galleryMessageIds = messageIds;
_data.galleryItems = items.values.toList();
_notifyMessageDataChanged();
}
Future<void> scrollToMessage(String messageId) async {
final index = _data.chatItems.indexWhere(
var index = _data.chatItems.indexWhere(
(x) => x.isMessage && x.message!.messageId == messageId,
);
while (index == -1 && _hasMoreMessages) {
await _loadOlderMessages();
index = _data.chatItems.indexWhere(
(item) => item.isMessage && item.message!.messageId == messageId,
);
}
if (index == -1) return;
setState(() {
focusedScrollItem = index;
});
focusedScrollItem = index;
_notifyMessageDataChanged();
await itemScrollController.scrollTo(
index: index,
duration: const Duration(milliseconds: 300),
@ -353,9 +527,8 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
);
Future.delayed(const Duration(milliseconds: 300), () {
if (!context.mounted) return;
setState(() {
focusedScrollItem = null;
});
focusedScrollItem = null;
_notifyMessageDataChanged();
});
}
@ -411,20 +584,9 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
FlameCounterWidget(group: group),
],
),
if (group.isDirectChat)
StreamBuilder<List<Contact>>(
stream: twonlyDB.groupsDao.watchGroupContact(
group.groupId,
),
builder: (context, snapshot) {
final contacts = snapshot.data ?? [];
if (contacts.isEmpty) {
return const SizedBox.shrink();
}
return ContactLabels(
contactId: contacts.first.userId,
);
},
if (group.isDirectChat && _groupContacts.isNotEmpty)
ContactLabels(
contactId: _groupContacts.first.userId,
),
],
),
@ -438,85 +600,91 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
child: Column(
children: [
Expanded(
child: Align(
alignment: Alignment.topCenter,
child: ChatMessageActionScope(
ackedMessageIds: _data.ackedMessageIds,
child: ScrollablePositionedList.builder(
reverse: true,
itemCount: _data.chatItems.length + 1 + 1,
itemScrollController: itemScrollController,
itemBuilder: (context, i) {
if (i == 0) {
return userService.currentUser.typingIndicators
? TypingIndicator(group: group)
: Container();
}
i -= 1;
if (i == _data.chatItems.length) {
return Padding(
key: Key('overview_${group.groupId}'),
padding: const EdgeInsets.only(top: 10),
child: InChatGroupOverview(
group: group,
),
);
}
if (_data.chatItems[i].isDate) {
return ChatDateChip(
item: _data.chatItems[i],
);
} else if (_data.chatItems[i].isGroupAction) {
return ChatGroupAction(
key: Key(
_data.chatItems[i].groupAction!.groupHistoryId,
),
action: _data.chatItems[i].groupAction!,
contactsById: _data.contactsById,
);
} else {
final chatMessage = _data.chatItems[i].message!;
return BlinkWidget(
key: Key('blink_${chatMessage.messageId}'),
enabled: focusedScrollItem == i,
child: AnimatedNewMessage(
key: Key('anim_${chatMessage.messageId}'),
messageId: chatMessage.messageId,
animateIds: _animationState.animateMessageIds,
child: ChatListEntry(
key: Key(chatMessage.messageId),
message: _data.chatItems[i].message!,
nextMessage: (i > 0)
? _data.chatItems[i - 1].message
: null,
prevMessage: ((i + 1) < _data.chatItems.length)
? _data.chatItems[i + 1].message
: null,
child: ValueListenableBuilder<int>(
valueListenable: _messageDataVersion,
builder: (context, _, _) => Align(
alignment: Alignment.topCenter,
child: ChatMessageActionScope(
ackedMessageIds: _data.ackedMessageIds,
child: ScrollablePositionedList.builder(
reverse: true,
itemCount: _data.chatItems.length + 1 + 1,
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
itemBuilder: (context, i) {
if (i == 0) {
return userService.currentUser.typingIndicators
? TypingIndicator(group: group)
: Container();
}
i -= 1;
if (i == _data.chatItems.length) {
return Padding(
key: Key('overview_${group.groupId}'),
padding: const EdgeInsets.only(top: 10),
child: InChatGroupOverview(
group: group,
galleryItems: _data.galleryItems,
userIdToContact: _data.contactsById,
mediaFile: chatMessage.mediaId == null
? null
: _data.mediaFilesById[chatMessage.mediaId],
reactions:
_data.reactionsByMessageId[chatMessage
.messageId] ??
const [],
messagesById: _data.messagesById,
mediaFilesById: _data.mediaFilesById,
useSharedData: true,
scrollToMessage: scrollToMessage,
onResponseTriggered: () {
setState(() {
quotesMessage = chatMessage;
});
textFieldFocus?.requestFocus();
},
),
),
);
}
},
);
}
if (_data.chatItems[i].isDate) {
return ChatDateChip(
item: _data.chatItems[i],
);
} else if (_data.chatItems[i].isGroupAction) {
return ChatGroupAction(
key: Key(
_data.chatItems[i].groupAction!.groupHistoryId,
),
action: _data.chatItems[i].groupAction!,
contactsById: _data.contactsById,
);
} else {
final chatMessage = _data.chatItems[i].message!;
return BlinkWidget(
key: Key('blink_${chatMessage.messageId}'),
enabled: focusedScrollItem == i,
child: AnimatedNewMessage(
key: Key('anim_${chatMessage.messageId}'),
messageId: chatMessage.messageId,
animateIds: _animationState.animateMessageIds,
child: ChatListEntry(
key: Key(chatMessage.messageId),
message: _data.chatItems[i].message!,
nextMessage: (i > 0)
? _data.chatItems[i - 1].message
: null,
prevMessage:
((i + 1) < _data.chatItems.length)
? _data.chatItems[i + 1].message
: null,
group: group,
galleryItems: _data.galleryItems,
userIdToContact: _data.contactsById,
mediaFile: chatMessage.mediaId == null
? null
: _data.mediaFilesById[chatMessage
.mediaId],
reactions:
_data.reactionsByMessageId[chatMessage
.messageId] ??
const [],
messagesById: _data.messagesById,
mediaFilesById: _data.mediaFilesById,
useSharedData: true,
scrollToMessage: scrollToMessage,
onResponseTriggered: () {
setState(() {
quotesMessage = chatMessage;
});
textFieldFocus?.requestFocus();
},
),
),
);
}
},
),
),
),
),

View file

@ -168,6 +168,7 @@ class _ChatListEntryState extends State<ChatListEntry> {
message: widget.message,
borderRadius: borderRadius,
info: info,
contactsById: widget.useSharedData ? widget.userIdToContact : null,
);
}
@ -184,6 +185,7 @@ class _ChatListEntryState extends State<ChatListEntry> {
message: widget.message,
borderRadius: borderRadius,
info: info,
contactsById: widget.useSharedData ? widget.userIdToContact : null,
);
}

View file

@ -20,12 +20,14 @@ class ChatAskAFriendEntry extends StatefulWidget {
required this.message,
required this.borderRadius,
required this.info,
this.contactsById,
super.key,
});
final Message message;
final BorderRadiusGeometry borderRadius;
final BubbleInfo info;
final Map<int, Contact>? contactsById;
@override
State<ChatAskAFriendEntry> createState() => _ChatAskAFriendEntryState();
@ -56,6 +58,11 @@ class _ChatAskAFriendEntryState extends State<ChatAskAFriendEntry> {
Future<void> _loadUser() async {
if (_data == null || !_data!.hasAskAboutUserId()) return;
final userId = _data!.askAboutUserId.toInt();
final sharedContact = widget.contactsById?[userId];
if (sharedContact != null) {
_username = sharedContact.displayName ?? sharedContact.username;
return;
}
setState(() {
_isLoading = true;
});
@ -162,7 +169,9 @@ class _ChatAskAFriendEntryState extends State<ChatAskAFriendEntry> {
mainAxisSize: MainAxisSize.min,
children: [
StreamBuilder<Contact?>(
stream: twonlyDB.contactsDao.watchContact(userId),
stream: widget.contactsById == null
? twonlyDB.contactsDao.watchContact(userId)
: Stream.value(widget.contactsById![userId]),
builder: (context, snapshot) {
final contactInDb = snapshot.data;
return GestureDetector(
@ -181,6 +190,9 @@ class _ChatAskAFriendEntryState extends State<ChatAskAFriendEntry> {
children: [
AvatarIcon(
contactId: userId,
contacts: widget.contactsById?[userId] == null
? null
: [widget.contactsById![userId]!],
fontSize: 12,
),
const SizedBox(width: 8),
@ -255,7 +267,9 @@ class _ChatAskAFriendEntryState extends State<ChatAskAFriendEntry> {
),
] else ...[
StreamBuilder<Contact?>(
stream: twonlyDB.contactsDao.watchContact(userId),
stream: widget.contactsById == null
? twonlyDB.contactsDao.watchContact(userId)
: Stream.value(widget.contactsById![userId]),
builder: (context, contactSnapshot) {
final contactInDb = contactSnapshot.data;
if (contactInDb != null) {
@ -269,8 +283,9 @@ class _ChatAskAFriendEntryState extends State<ChatAskAFriendEntry> {
}
return StreamBuilder<UserDiscoveryAnnouncedUser?>(
stream:
twonlyDB.userDiscoveryDao.watchAnnouncedUser(userId),
stream: twonlyDB.userDiscoveryDao.watchAnnouncedUser(
userId,
),
builder: (context, userSnapshot) {
final announcedUser = userSnapshot.data;
if (announcedUser != null && announcedUser.isHidden) {

View file

@ -10,7 +10,7 @@ import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/c
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/friendly_message_time.comp.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/message_send_state_icon.dart';
class ChatAudioEntry extends StatelessWidget {
class ChatAudioEntry extends StatefulWidget {
const ChatAudioEntry({
required this.message,
required this.mediaService,
@ -24,10 +24,50 @@ class ChatAudioEntry extends StatelessWidget {
final BorderRadius borderRadius;
final BubbleInfo info;
@override
State<ChatAudioEntry> createState() => _ChatAudioEntryState();
}
class _ChatAudioEntryState extends State<ChatAudioEntry> {
bool? _hasAudioFile;
bool _hasTempFile = false;
Message get message => widget.message;
MediaFileService get mediaService => widget.mediaService;
BorderRadius get borderRadius => widget.borderRadius;
BubbleInfo get info => widget.info;
@override
void initState() {
super.initState();
_checkAudioFiles();
}
@override
void didUpdateWidget(ChatAudioEntry oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.mediaService.mediaFile != oldWidget.mediaService.mediaFile) {
_checkAudioFiles();
}
}
Future<void> _checkAudioFiles() async {
// Async checks keep audio discovery off the UI thread.
// ignore: avoid_slow_async_io
final hasTempFile = await mediaService.tempPath.exists();
// ignore: avoid_slow_async_io
final hasOriginalFile = await mediaService.originalPath.exists();
final hasAudioFile = hasTempFile || hasOriginalFile;
if (!mounted) return;
setState(() {
_hasTempFile = hasTempFile;
_hasAudioFile = hasAudioFile;
});
}
@override
Widget build(BuildContext context) {
if (!mediaService.tempPath.existsSync() &&
!mediaService.originalPath.existsSync()) {
if (_hasAudioFile != true) {
return Container(); // media file was purged
}
@ -73,16 +113,20 @@ class ChatAudioEntry extends StatelessWidget {
)
else
Expanded(
child: mediaService.mediaFile.downloadState ==
child:
mediaService.mediaFile.downloadState ==
DownloadState.ready ||
mediaService.mediaFile.downloadState == null
? (mediaService.tempPath.existsSync()
? InChatAudioPlayer(
path: mediaService.tempPath.path,
message: message,
)
: Container())
: MessageSendStateIcon([message], [mediaService.mediaFile]),
? (_hasTempFile
? InChatAudioPlayer(
path: mediaService.tempPath.path,
message: message,
)
: Container())
: MessageSendStateIcon(
[message],
[mediaService.mediaFile],
),
),
if (showTime) FriendlyMessageTime(message: message),
],

View file

@ -20,12 +20,14 @@ class ChatContactsEntry extends StatefulWidget {
required this.message,
required this.borderRadius,
required this.info,
this.contactsById,
super.key,
});
final Message message;
final BorderRadiusGeometry borderRadius;
final BubbleInfo info;
final Map<int, Contact>? contactsById;
@override
State<ChatContactsEntry> createState() => _ChatContactsEntryState();
@ -73,6 +75,7 @@ class _ChatContactsEntryState extends State<ChatContactsEntry> {
_ContactRow(
contact: data.contacts[i],
message: widget.message,
contactsById: widget.contactsById,
),
],
],
@ -86,10 +89,12 @@ class _ContactRow extends StatefulWidget {
const _ContactRow({
required this.contact,
required this.message,
this.contactsById,
});
final SharedContact contact;
final Message message;
final Map<int, Contact>? contactsById;
@override
State<_ContactRow> createState() => _ContactRowState();
@ -161,61 +166,68 @@ class _ContactRowState extends State<_ContactRow> {
@override
Widget build(BuildContext context) {
if (widget.contactsById != null) {
return _buildContactRow(
widget.contactsById![widget.contact.userId.toInt()],
);
}
return StreamBuilder<Contact?>(
stream: twonlyDB.contactsDao.watchContact(widget.contact.userId.toInt()),
builder: (context, snapshot) {
final contactInDb = snapshot.data;
final isAdded =
contactInDb != null ||
widget.contact.userId.toInt() == userService.currentUser.userId;
return GestureDetector(
onTap: _isLoading ? null : () => _onContactClick(isAdded),
child: ColoredBox(
color: Colors.transparent,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
child: Row(
children: [
const FaIcon(
FontAwesomeIcons.user,
color: Colors.white,
size: 16,
),
const SizedBox(width: 8),
Flexible(
child: BetterText(
text: widget.contact.displayName,
textColor: Colors.white,
),
),
if (widget.message.senderId != null && !isAdded) ...[
const Spacer(),
const SizedBox(width: 8),
if (_isLoading)
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator.adaptive(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.white,
),
),
)
else
const FaIcon(
FontAwesomeIcons.userPlus,
color: Colors.white,
size: 16,
),
],
],
),
),
),
);
return _buildContactRow(snapshot.data);
},
);
}
Widget _buildContactRow(Contact? contactInDb) {
final isAdded =
contactInDb != null ||
widget.contact.userId.toInt() == userService.currentUser.userId;
return GestureDetector(
onTap: _isLoading ? null : () => _onContactClick(isAdded),
child: ColoredBox(
color: Colors.transparent,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
child: Row(
children: [
const FaIcon(
FontAwesomeIcons.user,
color: Colors.white,
size: 16,
),
const SizedBox(width: 8),
Flexible(
child: BetterText(
text: widget.contact.displayName,
textColor: Colors.white,
),
),
if (widget.message.senderId != null && !isAdded) ...[
const Spacer(),
const SizedBox(width: 8),
if (_isLoading)
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator.adaptive(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.white,
),
),
)
else
const FaIcon(
FontAwesomeIcons.userPlus,
color: Colors.white,
size: 16,
),
],
],
),
),
),
);
}
}

View file

@ -63,7 +63,9 @@ class _ChatMediaEntryState extends State<ChatMediaEntry> {
widget.mediaService.mediaFile.displayLimitInMilliseconds != null) {
return;
}
if (widget.mediaService.tempPath.existsSync() && mounted) {
// Async check keeps media discovery off the UI thread.
// ignore: avoid_slow_async_io
if (await widget.mediaService.tempPath.exists() && mounted) {
setState(() {
_canBeReopened = true;
});

View file

@ -234,20 +234,19 @@ class _ResponsePreviewState extends State<ResponsePreview> {
final pathToCheck = isVideo
? _mediaService!.thumbnailPath
: _mediaService!.storedPath;
if (pathToCheck.existsSync() && pathToCheck.lengthSync() > 0) {
imageWidget = Container(
height: 40,
width: 40,
margin: const EdgeInsets.only(left: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Image.file(
pathToCheck,
fit: BoxFit.cover,
),
imageWidget = Container(
height: 40,
width: 40,
margin: const EdgeInsets.only(left: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Image.file(
pathToCheck,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => const SizedBox.shrink(),
),
);
}
),
);
}
return Container(

View file

@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'dart:ui' as ui;
import 'package:clock/clock.dart';
@ -35,6 +36,7 @@ class DeveloperSettingsView extends StatefulWidget {
class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
bool _isGeneratingMockImages = false;
bool _isGeneratingMockMessages = false;
@override
void initState() {
@ -254,6 +256,105 @@ class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
}
}
Future<void> _generate1000RandomMessages() async {
if (!kDebugMode || _isGeneratingMockMessages) return;
final groups = await twonlyDB.groupsDao.getAllGroups();
groups.sort((a, b) => a.groupName.compareTo(b.groupName));
if (!mounted) return;
if (groups.isEmpty) {
showSnackbar(context, 'No groups available for message generation.');
return;
}
final group = await showDialog<Group>(
context: context,
builder: (context) => SimpleDialog(
title: const Text('Generate messages in group'),
children: [
for (final group in groups)
SimpleDialogOption(
onPressed: () => Navigator.pop(context, group),
child: Text(group.groupName),
),
],
),
);
if (group == null || !mounted) return;
setState(() => _isGeneratingMockMessages = true);
try {
final members = await twonlyDB.groupsDao.getGroupNonLeftMembers(
group.groupId,
);
if (members.isEmpty) {
if (mounted) {
showSnackbar(context, 'The selected group has no active members.');
}
return;
}
const samples = [
'Hey! How is everyone doing?',
'This is a randomly generated debug message.',
'Did anyone see this?',
'Sounds good to me!',
'I will check that later.',
'That made me laugh 😄',
'What do you think?',
'Lets do it!',
'Quick update from my side.',
'Testing a longer message to see how the chat bubble wraps across multiple lines in this conversation.',
];
final random = Random();
final now = clock.now();
final idPrefix = 'debug_${now.microsecondsSinceEpoch}';
await twonlyDB.batch((batch) {
for (var i = 0; i < 1000; i++) {
final member = members[random.nextInt(members.length)];
final createdAt = now.subtract(Duration(seconds: 1000 - i));
final sample = samples[random.nextInt(samples.length)];
batch.insert(
twonlyDB.messages,
MessagesCompanion(
groupId: Value(group.groupId),
messageId: Value('${idPrefix}_$i'),
senderId: Value(member.contactId),
type: const Value('text'),
content: Value('$sample #${i + 1}'),
openedAt: Value(now),
ackByServer: Value(createdAt),
ackByUser: Value(createdAt),
createdAt: Value(createdAt),
),
);
}
});
await twonlyDB.groupsDao.updateGroup(
group.groupId,
GroupsCompanion(
archived: const Value(false),
deletedContent: const Value(false),
lastMessageReceived: Value(now),
lastMessageExchange: Value(now),
),
);
if (mounted) {
showSnackbar(
context,
'Generated 1000 messages in ${group.groupName}.',
level: SnackbarLevel.success,
);
}
} catch (error) {
if (mounted) showSnackbar(context, 'Could not generate messages: $error');
} finally {
if (mounted) setState(() => _isGeneratingMockMessages = false);
}
}
Future<void> toggleDeveloperSettings() async {
await UserService.update((u) => u.isDeveloper = !u.isDeveloper);
}
@ -376,6 +477,23 @@ class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
onTap: () =>
context.push(Routes.settingsDeveloperAutomatedTesting),
),
if (kDebugMode)
ListTile(
title: const Text('Generate 1000 Random Messages'),
subtitle: const Text('Choose a group to populate'),
trailing: _isGeneratingMockMessages
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator.adaptive(
strokeWidth: 2,
),
)
: null,
onTap: _isGeneratingMockMessages
? null
: _generate1000RandomMessages,
),
if (kDebugMode)
ListTile(
title: const Text('Generate 1000 Mock Images'),