mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-09-01 07:04:07 +00:00
Performance improvements
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
This commit is contained in:
parent
74c20389c9
commit
e8abfe421b
15 changed files with 744 additions and 277 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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}');
|
||||
|
|
|
|||
|
|
@ -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(() {});
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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?',
|
||||
'Let’s 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'),
|
||||
|
|
|
|||
Loading…
Reference in a new issue