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));
|
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 {
|
Future<Contact?> getContactById(int userId) async {
|
||||||
return (select(
|
return (select(
|
||||||
contacts,
|
contacts,
|
||||||
|
|
|
||||||
|
|
@ -86,9 +86,18 @@ class GroupsDao extends DatabaseAccessor<TwonlyDB> with _$GroupsDaoMixin {
|
||||||
await into(groupHistories).insert(insertAction);
|
await into(groupHistories).insert(insertAction);
|
||||||
}
|
}
|
||||||
|
|
||||||
Stream<List<GroupHistory>> watchGroupActions(String groupId) {
|
Stream<List<GroupHistory>> watchGroupActions(
|
||||||
|
String groupId, {
|
||||||
|
DateTime? since,
|
||||||
|
}) {
|
||||||
return (select(groupHistories)
|
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)]))
|
..orderBy([(t) => OrderingTerm.asc(t.actionAt)]))
|
||||||
.watch();
|
.watch();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,13 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
|
||||||
.watch();
|
.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) {
|
Stream<List<MediaFile>> watchMediaFilesForGroup(String groupId) {
|
||||||
final query = select(mediaFiles).join([
|
final query = select(mediaFiles).join([
|
||||||
innerJoin(
|
innerJoin(
|
||||||
|
|
|
||||||
|
|
@ -142,7 +142,10 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
||||||
.not() |
|
.not() |
|
||||||
mediaFiles.downloadState.isNull()),
|
mediaFiles.downloadState.isNull()),
|
||||||
)
|
)
|
||||||
..orderBy([OrderingTerm.desc(messages.createdAt)])
|
..orderBy([
|
||||||
|
OrderingTerm.desc(messages.createdAt),
|
||||||
|
OrderingTerm.desc(messages.messageId),
|
||||||
|
])
|
||||||
..limit(1);
|
..limit(1);
|
||||||
|
|
||||||
return query.map((row) => row.readTable(messages)).watchSingleOrNull();
|
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 group = await twonlyDB.groupsDao.getGroup(groupId);
|
||||||
final deletionTime = clock.now().subtract(
|
final deletionTime = clock.now().subtract(
|
||||||
Duration(
|
Duration(
|
||||||
|
|
@ -211,9 +217,61 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
||||||
.equals(DownloadState.reuploadRequested.name)
|
.equals(DownloadState.reuploadRequested.name)
|
||||||
.not()))),
|
.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) {
|
Stream<List<(GroupMember, Contact)>> watchMembersByGroupId(String groupId) {
|
||||||
|
|
@ -628,9 +686,19 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
||||||
.watch();
|
.watch();
|
||||||
}
|
}
|
||||||
|
|
||||||
Stream<List<MessageAction>> watchMessageActionsForGroup(
|
Stream<List<MessageAction>> watchAcknowledgementsForMessages(
|
||||||
String groupId,
|
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([
|
final query = select(messageActions).join([
|
||||||
innerJoin(
|
innerJoin(
|
||||||
messages,
|
messages,
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,14 @@ class ReactionsDao extends DatabaseAccessor<TwonlyDB> with _$ReactionsDaoMixin {
|
||||||
.watch();
|
.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) {
|
Stream<List<Reaction>> watchReactionsForGroup(String groupId) {
|
||||||
final query =
|
final query =
|
||||||
select(reactions).join([
|
select(reactions).join([
|
||||||
|
|
|
||||||
|
|
@ -296,11 +296,9 @@ class MediaFileService {
|
||||||
|
|
||||||
bool get imagePreviewAvailable =>
|
bool get imagePreviewAvailable =>
|
||||||
mediaFile.hasThumbnail ||
|
mediaFile.hasThumbnail ||
|
||||||
(thumbnailPath.existsSync() && thumbnailPath.lengthSync() > 0) ||
|
|
||||||
mediaFile.type == MediaType.audio ||
|
mediaFile.type == MediaType.audio ||
|
||||||
((mediaFile.type == MediaType.image || mediaFile.type == MediaType.gif) &&
|
((mediaFile.type == MediaType.image || mediaFile.type == MediaType.gif) &&
|
||||||
storedPath.existsSync() &&
|
mediaFile.stored);
|
||||||
storedPath.lengthSync() > 0);
|
|
||||||
|
|
||||||
Future<void> storeMediaFile() async {
|
Future<void> storeMediaFile() async {
|
||||||
Log.info('Storing media file ${mediaFile.mediaId}');
|
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/locator.dart';
|
||||||
import 'package:twonly/src/database/twonly.db.dart';
|
import 'package:twonly/src/database/twonly.db.dart';
|
||||||
import 'package:twonly/src/utils/avatars.dart';
|
import 'package:twonly/src/utils/avatars.dart';
|
||||||
import 'package:twonly/src/utils/log.dart';
|
|
||||||
import 'package:vector_graphics/vector_graphics.dart';
|
import 'package:vector_graphics/vector_graphics.dart';
|
||||||
|
|
||||||
class AvatarIcon extends StatefulWidget {
|
class AvatarIcon extends StatefulWidget {
|
||||||
|
|
@ -34,6 +33,7 @@ class AvatarIcon extends StatefulWidget {
|
||||||
|
|
||||||
class _AvatarIconState extends State<AvatarIcon> {
|
class _AvatarIconState extends State<AvatarIcon> {
|
||||||
List<Contact> _avatarContacts = [];
|
List<Contact> _avatarContacts = [];
|
||||||
|
Set<int> _contactsWithPngAvatar = {};
|
||||||
String? _myAvatarPath;
|
String? _myAvatarPath;
|
||||||
|
|
||||||
StreamSubscription<List<Contact>>? groupStream;
|
StreamSubscription<List<Contact>>? groupStream;
|
||||||
|
|
@ -59,9 +59,24 @@ class _AvatarIconState extends State<AvatarIcon> {
|
||||||
_avatarContacts = contacts
|
_avatarContacts = contacts
|
||||||
.where((contact) => contact.avatarSvgCompressed != null)
|
.where((contact) => contact.avatarSvgCompressed != null)
|
||||||
.toList();
|
.toList();
|
||||||
|
unawaited(_refreshAvatarFiles());
|
||||||
if (mounted) setState(() {});
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
groupStream?.cancel();
|
groupStream?.cancel();
|
||||||
|
|
@ -79,18 +94,13 @@ class _AvatarIconState extends State<AvatarIcon> {
|
||||||
|
|
||||||
Widget getAvatarForContact(Contact contact) {
|
Widget getAvatarForContact(Contact contact) {
|
||||||
final avatarFile = avatarPNGFile(contact.userId);
|
final avatarFile = avatarPNGFile(contact.userId);
|
||||||
if (avatarFile.existsSync()) {
|
if (_contactsWithPngAvatar.contains(contact.userId)) {
|
||||||
return Image.file(
|
return Image.file(
|
||||||
avatarFile,
|
avatarFile,
|
||||||
errorBuilder: errorBuilder,
|
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) {
|
if (contact.avatarSvgCompressed != null) {
|
||||||
return SvgPicture.string(
|
return SvgPicture.string(
|
||||||
getAvatarSvg(contact.avatarSvgCompressed!),
|
getAvatarSvg(contact.avatarSvgCompressed!),
|
||||||
|
|
@ -119,6 +129,7 @@ class _AvatarIconState extends State<AvatarIcon> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
unawaited(_refreshAvatarFiles());
|
||||||
setState(() {});
|
setState(() {});
|
||||||
});
|
});
|
||||||
} else if (widget.myAvatar) {
|
} else if (widget.myAvatar) {
|
||||||
|
|
@ -132,6 +143,7 @@ class _AvatarIconState extends State<AvatarIcon> {
|
||||||
.listen((contact) {
|
.listen((contact) {
|
||||||
if (contact != null && contact.avatarSvgCompressed != null) {
|
if (contact != null && contact.avatarSvgCompressed != null) {
|
||||||
_avatarContacts = [contact];
|
_avatarContacts = [contact];
|
||||||
|
unawaited(_refreshAvatarFiles());
|
||||||
setState(() {});
|
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/tables/messages.table.dart';
|
||||||
import 'package:twonly/src/database/twonly.db.dart';
|
import 'package:twonly/src/database/twonly.db.dart';
|
||||||
import 'package:twonly/src/model/memory_item.model.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/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/services/notifications/background.notifications.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
||||||
|
|
@ -36,6 +38,7 @@ class _MessageAnimationState {
|
||||||
bool hasReceivedFirstBatch = false;
|
bool hasReceivedFirstBatch = false;
|
||||||
final HashSet<String> knownMessageIds = HashSet<String>();
|
final HashSet<String> knownMessageIds = HashSet<String>();
|
||||||
final HashSet<String> animateMessageIds = HashSet<String>();
|
final HashSet<String> animateMessageIds = HashSet<String>();
|
||||||
|
final HashSet<String> reportedOpenedMessageIds = HashSet<String>();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ChatViewData {
|
class _ChatViewData {
|
||||||
|
|
@ -46,10 +49,15 @@ class _ChatViewData {
|
||||||
|
|
||||||
List<ChatItem> chatItems = [];
|
List<ChatItem> chatItems = [];
|
||||||
List<Message> allMessages = [];
|
List<Message> allMessages = [];
|
||||||
|
List<Message> latestMessages = [];
|
||||||
|
List<Message> olderMessages = [];
|
||||||
Map<String, Message> messagesById = {};
|
Map<String, Message> messagesById = {};
|
||||||
List<GroupHistory> groupActions = [];
|
List<GroupHistory> groupActions = [];
|
||||||
List<MemoryItem> galleryItems = [];
|
List<MemoryItem> galleryItems = [];
|
||||||
Set<String> galleryMessageIds = {};
|
Set<String> galleryMessageIds = {};
|
||||||
|
Set<String> watchedMessageIds = {};
|
||||||
|
Set<int> watchedContactIds = {};
|
||||||
|
DateTime? watchedGroupActionsSince;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ChatSubscriptions {
|
class _ChatSubscriptions {
|
||||||
|
|
@ -83,11 +91,12 @@ class ChatMessagesView extends StatefulWidget {
|
||||||
|
|
||||||
class _ChatMessagesViewState extends State<ChatMessagesView>
|
class _ChatMessagesViewState extends State<ChatMessagesView>
|
||||||
with WidgetsBindingObserver {
|
with WidgetsBindingObserver {
|
||||||
HashSet<int> alreadyReportedOpened = HashSet<int>();
|
static const _messagePageSize = 100;
|
||||||
|
|
||||||
final _animationState = _MessageAnimationState();
|
final _animationState = _MessageAnimationState();
|
||||||
final _subscriptions = _ChatSubscriptions();
|
final _subscriptions = _ChatSubscriptions();
|
||||||
final _data = _ChatViewData();
|
final _data = _ChatViewData();
|
||||||
|
final ValueNotifier<int> _messageDataVersion = ValueNotifier(0);
|
||||||
|
|
||||||
Group? _group;
|
Group? _group;
|
||||||
List<Contact> _groupContacts = [];
|
List<Contact> _groupContacts = [];
|
||||||
|
|
@ -95,8 +104,12 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
||||||
GlobalKey verifyShieldKey = GlobalKey();
|
GlobalKey verifyShieldKey = GlobalKey();
|
||||||
FocusNode? textFieldFocus;
|
FocusNode? textFieldFocus;
|
||||||
final ItemScrollController itemScrollController = ItemScrollController();
|
final ItemScrollController itemScrollController = ItemScrollController();
|
||||||
|
final ItemPositionsListener itemPositionsListener =
|
||||||
|
ItemPositionsListener.create();
|
||||||
int? focusedScrollItem;
|
int? focusedScrollItem;
|
||||||
bool _receiverDeletedAccount = false;
|
bool _receiverDeletedAccount = false;
|
||||||
|
Future<void>? _olderMessagesLoad;
|
||||||
|
bool _hasMoreMessages = true;
|
||||||
|
|
||||||
Timer? _nextTypingIndicator;
|
Timer? _nextTypingIndicator;
|
||||||
|
|
||||||
|
|
@ -105,12 +118,15 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
||||||
super.initState();
|
super.initState();
|
||||||
textFieldFocus = FocusNode();
|
textFieldFocus = FocusNode();
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
itemPositionsListener.itemPositions.addListener(_loadOlderWhenNeeded);
|
||||||
initStreams();
|
initStreams();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_subscriptions.cancelAll();
|
_subscriptions.cancelAll();
|
||||||
|
_messageDataVersion.dispose();
|
||||||
|
itemPositionsListener.itemPositions.removeListener(_loadOlderWhenNeeded);
|
||||||
_nextTypingIndicator?.cancel();
|
_nextTypingIndicator?.cancel();
|
||||||
try {
|
try {
|
||||||
textFieldFocus?.dispose();
|
textFieldFocus?.dispose();
|
||||||
|
|
@ -126,6 +142,10 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
||||||
(ModalRoute.of(context)?.isCurrent ?? false);
|
(ModalRoute.of(context)?.isCurrent ?? false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _notifyMessageDataChanged() {
|
||||||
|
_messageDataVersion.value++;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> initStreams() async {
|
Future<void> initStreams() async {
|
||||||
final groupStream = twonlyDB.groupsDao.watchGroup(widget.groupId);
|
final groupStream = twonlyDB.groupsDao.watchGroup(widget.groupId);
|
||||||
_subscriptions.group = groupStream.listen((newGroup) {
|
_subscriptions.group = groupStream.listen((newGroup) {
|
||||||
|
|
@ -134,71 +154,18 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
||||||
setState(() {
|
setState(() {
|
||||||
_group = newGroup;
|
_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);
|
final msgStream = await twonlyDB.messagesDao.watchByGroupId(widget.groupId);
|
||||||
_subscriptions.messages = msgStream.listen((update) async {
|
_subscriptions.messages = msgStream.listen((update) async {
|
||||||
_data.allMessages = update;
|
_data.latestMessages = update;
|
||||||
_data.messagesById = {
|
if (_data.olderMessages.isEmpty) {
|
||||||
for (final message in update) message.messageId: message,
|
_hasMoreMessages = update.length == _messagePageSize;
|
||||||
};
|
}
|
||||||
await setMessages(update, _data.groupActions);
|
await _applyLoadedMessages(reportOpened: true);
|
||||||
_animationState.hasReceivedFirstBatch = 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(
|
final groupContacts = await twonlyDB.groupsDao.getGroupContact(
|
||||||
widget.groupId,
|
widget.groupId,
|
||||||
);
|
);
|
||||||
|
|
@ -208,6 +175,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
||||||
_receiverDeletedAccount =
|
_receiverDeletedAccount =
|
||||||
groupContacts.length == 1 && groupContacts.first.accountDeleted;
|
groupContacts.length == 1 && groupContacts.first.accountDeleted;
|
||||||
});
|
});
|
||||||
|
_watchRelevantContacts();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userService.currentUser.typingIndicators) {
|
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(
|
Future<void> setMessages(
|
||||||
List<Message> newMessages,
|
List<Message> newMessages,
|
||||||
List<GroupHistory> groupActions,
|
List<GroupHistory> groupActions, {
|
||||||
) async {
|
bool reportOpened = false,
|
||||||
if (_isViewActive()) {
|
}) async {
|
||||||
|
if (reportOpened && _isViewActive()) {
|
||||||
unawaited(flutterLocalNotificationsPlugin.cancelAll());
|
unawaited(flutterLocalNotificationsPlugin.cancelAll());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -241,6 +370,12 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
||||||
|
|
||||||
final chatItems = <ChatItem>[];
|
final chatItems = <ChatItem>[];
|
||||||
final storedMediaFiles = <Message>[];
|
final storedMediaFiles = <Message>[];
|
||||||
|
final oldestLoadedAt = newMessages.firstOrNull?.createdAt;
|
||||||
|
final visibleGroupActions = oldestLoadedAt == null
|
||||||
|
? groupActions
|
||||||
|
: groupActions
|
||||||
|
.where((action) => !action.actionAt.isBefore(oldestLoadedAt))
|
||||||
|
.toList();
|
||||||
|
|
||||||
DateTime? lastDate;
|
DateTime? lastDate;
|
||||||
|
|
||||||
|
|
@ -249,11 +384,17 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
||||||
var groupHistoryIndex = 0;
|
var groupHistoryIndex = 0;
|
||||||
|
|
||||||
for (final msg in newMessages) {
|
for (final msg in newMessages) {
|
||||||
if (groupHistoryIndex < groupActions.length) {
|
if (groupHistoryIndex < visibleGroupActions.length) {
|
||||||
for (; groupHistoryIndex < groupActions.length; groupHistoryIndex++) {
|
for (
|
||||||
if (msg.createdAt.isAfter(groupActions[groupHistoryIndex].actionAt)) {
|
;
|
||||||
|
groupHistoryIndex < visibleGroupActions.length;
|
||||||
|
groupHistoryIndex++
|
||||||
|
) {
|
||||||
|
if (msg.createdAt.isAfter(
|
||||||
|
visibleGroupActions[groupHistoryIndex].actionAt,
|
||||||
|
)) {
|
||||||
chatItems.add(
|
chatItems.add(
|
||||||
ChatItem.groupAction(groupActions[groupHistoryIndex]),
|
ChatItem.groupAction(visibleGroupActions[groupHistoryIndex]),
|
||||||
);
|
);
|
||||||
// groupHistoryIndex++;
|
// groupHistoryIndex++;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -263,7 +404,8 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
||||||
}
|
}
|
||||||
if (msg.type != MessageType.media.name &&
|
if (msg.type != MessageType.media.name &&
|
||||||
msg.senderId != null &&
|
msg.senderId != null &&
|
||||||
msg.openedAt == null) {
|
msg.openedAt == null &&
|
||||||
|
!_animationState.reportedOpenedMessageIds.contains(msg.messageId)) {
|
||||||
if (openedMessages[msg.senderId!] == null) {
|
if (openedMessages[msg.senderId!] == null) {
|
||||||
openedMessages[msg.senderId!] = [];
|
openedMessages[msg.senderId!] = [];
|
||||||
}
|
}
|
||||||
|
|
@ -283,14 +425,17 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
||||||
}
|
}
|
||||||
chatItems.add(ChatItem.message(msg));
|
chatItems.add(ChatItem.message(msg));
|
||||||
}
|
}
|
||||||
if (groupHistoryIndex < groupActions.length) {
|
if (groupHistoryIndex < visibleGroupActions.length) {
|
||||||
for (var i = groupHistoryIndex; i < groupActions.length; i++) {
|
for (var i = groupHistoryIndex; i < visibleGroupActions.length; i++) {
|
||||||
chatItems.add(ChatItem.groupAction(groupActions[i]));
|
chatItems.add(ChatItem.groupAction(visibleGroupActions[i]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_isViewActive()) {
|
if (reportOpened && _isViewActive()) {
|
||||||
for (final contactId in openedMessages.keys) {
|
for (final contactId in openedMessages.keys) {
|
||||||
|
_animationState.reportedOpenedMessageIds.addAll(
|
||||||
|
openedMessages[contactId]!,
|
||||||
|
);
|
||||||
unawaited(
|
unawaited(
|
||||||
notifyContactAboutOpeningMessage(
|
notifyContactAboutOpeningMessage(
|
||||||
contactId,
|
contactId,
|
||||||
|
|
@ -306,9 +451,8 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
||||||
newMessages.last.senderId == null;
|
newMessages.last.senderId == null;
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
_data.chatItems = chatItems.reversed.toList();
|
||||||
_data.chatItems = chatItems.reversed.toList();
|
_notifyMessageDataChanged();
|
||||||
});
|
|
||||||
|
|
||||||
if (wasSentByMe) {
|
if (wasSentByMe) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
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)
|
.map((message) => message.messageId)
|
||||||
.toSet();
|
.toSet();
|
||||||
if (setEquals(_data.galleryMessageIds, galleryMessageIds)) return;
|
if (!force && setEquals(_data.galleryMessageIds, messageIds)) return;
|
||||||
final items = await MemoryItem.convertFromMessages(storedMediaFiles);
|
|
||||||
|
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;
|
if (!mounted) return;
|
||||||
setState(() {
|
_data.galleryMessageIds = messageIds;
|
||||||
_data.galleryMessageIds = galleryMessageIds;
|
_data.galleryItems = items.values.toList();
|
||||||
_data.galleryItems = items.values.toList();
|
_notifyMessageDataChanged();
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> scrollToMessage(String messageId) async {
|
Future<void> scrollToMessage(String messageId) async {
|
||||||
final index = _data.chatItems.indexWhere(
|
var index = _data.chatItems.indexWhere(
|
||||||
(x) => x.isMessage && x.message!.messageId == messageId,
|
(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;
|
if (index == -1) return;
|
||||||
setState(() {
|
focusedScrollItem = index;
|
||||||
focusedScrollItem = index;
|
_notifyMessageDataChanged();
|
||||||
});
|
|
||||||
await itemScrollController.scrollTo(
|
await itemScrollController.scrollTo(
|
||||||
index: index,
|
index: index,
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
|
|
@ -353,9 +527,8 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
||||||
);
|
);
|
||||||
Future.delayed(const Duration(milliseconds: 300), () {
|
Future.delayed(const Duration(milliseconds: 300), () {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
setState(() {
|
focusedScrollItem = null;
|
||||||
focusedScrollItem = null;
|
_notifyMessageDataChanged();
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -411,20 +584,9 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
||||||
FlameCounterWidget(group: group),
|
FlameCounterWidget(group: group),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (group.isDirectChat)
|
if (group.isDirectChat && _groupContacts.isNotEmpty)
|
||||||
StreamBuilder<List<Contact>>(
|
ContactLabels(
|
||||||
stream: twonlyDB.groupsDao.watchGroupContact(
|
contactId: _groupContacts.first.userId,
|
||||||
group.groupId,
|
|
||||||
),
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
final contacts = snapshot.data ?? [];
|
|
||||||
if (contacts.isEmpty) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
return ContactLabels(
|
|
||||||
contactId: contacts.first.userId,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -438,85 +600,91 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Align(
|
child: ValueListenableBuilder<int>(
|
||||||
alignment: Alignment.topCenter,
|
valueListenable: _messageDataVersion,
|
||||||
child: ChatMessageActionScope(
|
builder: (context, _, _) => Align(
|
||||||
ackedMessageIds: _data.ackedMessageIds,
|
alignment: Alignment.topCenter,
|
||||||
child: ScrollablePositionedList.builder(
|
child: ChatMessageActionScope(
|
||||||
reverse: true,
|
ackedMessageIds: _data.ackedMessageIds,
|
||||||
itemCount: _data.chatItems.length + 1 + 1,
|
child: ScrollablePositionedList.builder(
|
||||||
itemScrollController: itemScrollController,
|
reverse: true,
|
||||||
itemBuilder: (context, i) {
|
itemCount: _data.chatItems.length + 1 + 1,
|
||||||
if (i == 0) {
|
itemScrollController: itemScrollController,
|
||||||
return userService.currentUser.typingIndicators
|
itemPositionsListener: itemPositionsListener,
|
||||||
? TypingIndicator(group: group)
|
itemBuilder: (context, i) {
|
||||||
: Container();
|
if (i == 0) {
|
||||||
}
|
return userService.currentUser.typingIndicators
|
||||||
i -= 1;
|
? TypingIndicator(group: group)
|
||||||
if (i == _data.chatItems.length) {
|
: Container();
|
||||||
return Padding(
|
}
|
||||||
key: Key('overview_${group.groupId}'),
|
i -= 1;
|
||||||
padding: const EdgeInsets.only(top: 10),
|
if (i == _data.chatItems.length) {
|
||||||
child: InChatGroupOverview(
|
return Padding(
|
||||||
group: group,
|
key: Key('overview_${group.groupId}'),
|
||||||
),
|
padding: const EdgeInsets.only(top: 10),
|
||||||
);
|
child: InChatGroupOverview(
|
||||||
}
|
|
||||||
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,
|
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,
|
message: widget.message,
|
||||||
borderRadius: borderRadius,
|
borderRadius: borderRadius,
|
||||||
info: info,
|
info: info,
|
||||||
|
contactsById: widget.useSharedData ? widget.userIdToContact : null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -184,6 +185,7 @@ class _ChatListEntryState extends State<ChatListEntry> {
|
||||||
message: widget.message,
|
message: widget.message,
|
||||||
borderRadius: borderRadius,
|
borderRadius: borderRadius,
|
||||||
info: info,
|
info: info,
|
||||||
|
contactsById: widget.useSharedData ? widget.userIdToContact : null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,14 @@ class ChatAskAFriendEntry extends StatefulWidget {
|
||||||
required this.message,
|
required this.message,
|
||||||
required this.borderRadius,
|
required this.borderRadius,
|
||||||
required this.info,
|
required this.info,
|
||||||
|
this.contactsById,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Message message;
|
final Message message;
|
||||||
final BorderRadiusGeometry borderRadius;
|
final BorderRadiusGeometry borderRadius;
|
||||||
final BubbleInfo info;
|
final BubbleInfo info;
|
||||||
|
final Map<int, Contact>? contactsById;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ChatAskAFriendEntry> createState() => _ChatAskAFriendEntryState();
|
State<ChatAskAFriendEntry> createState() => _ChatAskAFriendEntryState();
|
||||||
|
|
@ -56,6 +58,11 @@ class _ChatAskAFriendEntryState extends State<ChatAskAFriendEntry> {
|
||||||
Future<void> _loadUser() async {
|
Future<void> _loadUser() async {
|
||||||
if (_data == null || !_data!.hasAskAboutUserId()) return;
|
if (_data == null || !_data!.hasAskAboutUserId()) return;
|
||||||
final userId = _data!.askAboutUserId.toInt();
|
final userId = _data!.askAboutUserId.toInt();
|
||||||
|
final sharedContact = widget.contactsById?[userId];
|
||||||
|
if (sharedContact != null) {
|
||||||
|
_username = sharedContact.displayName ?? sharedContact.username;
|
||||||
|
return;
|
||||||
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
});
|
});
|
||||||
|
|
@ -162,7 +169,9 @@ class _ChatAskAFriendEntryState extends State<ChatAskAFriendEntry> {
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
StreamBuilder<Contact?>(
|
StreamBuilder<Contact?>(
|
||||||
stream: twonlyDB.contactsDao.watchContact(userId),
|
stream: widget.contactsById == null
|
||||||
|
? twonlyDB.contactsDao.watchContact(userId)
|
||||||
|
: Stream.value(widget.contactsById![userId]),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final contactInDb = snapshot.data;
|
final contactInDb = snapshot.data;
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
|
|
@ -181,6 +190,9 @@ class _ChatAskAFriendEntryState extends State<ChatAskAFriendEntry> {
|
||||||
children: [
|
children: [
|
||||||
AvatarIcon(
|
AvatarIcon(
|
||||||
contactId: userId,
|
contactId: userId,
|
||||||
|
contacts: widget.contactsById?[userId] == null
|
||||||
|
? null
|
||||||
|
: [widget.contactsById![userId]!],
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
|
@ -255,7 +267,9 @@ class _ChatAskAFriendEntryState extends State<ChatAskAFriendEntry> {
|
||||||
),
|
),
|
||||||
] else ...[
|
] else ...[
|
||||||
StreamBuilder<Contact?>(
|
StreamBuilder<Contact?>(
|
||||||
stream: twonlyDB.contactsDao.watchContact(userId),
|
stream: widget.contactsById == null
|
||||||
|
? twonlyDB.contactsDao.watchContact(userId)
|
||||||
|
: Stream.value(widget.contactsById![userId]),
|
||||||
builder: (context, contactSnapshot) {
|
builder: (context, contactSnapshot) {
|
||||||
final contactInDb = contactSnapshot.data;
|
final contactInDb = contactSnapshot.data;
|
||||||
if (contactInDb != null) {
|
if (contactInDb != null) {
|
||||||
|
|
@ -269,8 +283,9 @@ class _ChatAskAFriendEntryState extends State<ChatAskAFriendEntry> {
|
||||||
}
|
}
|
||||||
|
|
||||||
return StreamBuilder<UserDiscoveryAnnouncedUser?>(
|
return StreamBuilder<UserDiscoveryAnnouncedUser?>(
|
||||||
stream:
|
stream: twonlyDB.userDiscoveryDao.watchAnnouncedUser(
|
||||||
twonlyDB.userDiscoveryDao.watchAnnouncedUser(userId),
|
userId,
|
||||||
|
),
|
||||||
builder: (context, userSnapshot) {
|
builder: (context, userSnapshot) {
|
||||||
final announcedUser = userSnapshot.data;
|
final announcedUser = userSnapshot.data;
|
||||||
if (announcedUser != null && announcedUser.isHidden) {
|
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/entries/friendly_message_time.comp.dart';
|
||||||
import 'package:twonly/src/visual/views/chats/chat_messages_components/message_send_state_icon.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({
|
const ChatAudioEntry({
|
||||||
required this.message,
|
required this.message,
|
||||||
required this.mediaService,
|
required this.mediaService,
|
||||||
|
|
@ -24,10 +24,50 @@ class ChatAudioEntry extends StatelessWidget {
|
||||||
final BorderRadius borderRadius;
|
final BorderRadius borderRadius;
|
||||||
final BubbleInfo info;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (!mediaService.tempPath.existsSync() &&
|
if (_hasAudioFile != true) {
|
||||||
!mediaService.originalPath.existsSync()) {
|
|
||||||
return Container(); // media file was purged
|
return Container(); // media file was purged
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,16 +113,20 @@ class ChatAudioEntry extends StatelessWidget {
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
Expanded(
|
Expanded(
|
||||||
child: mediaService.mediaFile.downloadState ==
|
child:
|
||||||
|
mediaService.mediaFile.downloadState ==
|
||||||
DownloadState.ready ||
|
DownloadState.ready ||
|
||||||
mediaService.mediaFile.downloadState == null
|
mediaService.mediaFile.downloadState == null
|
||||||
? (mediaService.tempPath.existsSync()
|
? (_hasTempFile
|
||||||
? InChatAudioPlayer(
|
? InChatAudioPlayer(
|
||||||
path: mediaService.tempPath.path,
|
path: mediaService.tempPath.path,
|
||||||
message: message,
|
message: message,
|
||||||
)
|
)
|
||||||
: Container())
|
: Container())
|
||||||
: MessageSendStateIcon([message], [mediaService.mediaFile]),
|
: MessageSendStateIcon(
|
||||||
|
[message],
|
||||||
|
[mediaService.mediaFile],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (showTime) FriendlyMessageTime(message: message),
|
if (showTime) FriendlyMessageTime(message: message),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,14 @@ class ChatContactsEntry extends StatefulWidget {
|
||||||
required this.message,
|
required this.message,
|
||||||
required this.borderRadius,
|
required this.borderRadius,
|
||||||
required this.info,
|
required this.info,
|
||||||
|
this.contactsById,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Message message;
|
final Message message;
|
||||||
final BorderRadiusGeometry borderRadius;
|
final BorderRadiusGeometry borderRadius;
|
||||||
final BubbleInfo info;
|
final BubbleInfo info;
|
||||||
|
final Map<int, Contact>? contactsById;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ChatContactsEntry> createState() => _ChatContactsEntryState();
|
State<ChatContactsEntry> createState() => _ChatContactsEntryState();
|
||||||
|
|
@ -73,6 +75,7 @@ class _ChatContactsEntryState extends State<ChatContactsEntry> {
|
||||||
_ContactRow(
|
_ContactRow(
|
||||||
contact: data.contacts[i],
|
contact: data.contacts[i],
|
||||||
message: widget.message,
|
message: widget.message,
|
||||||
|
contactsById: widget.contactsById,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
@ -86,10 +89,12 @@ class _ContactRow extends StatefulWidget {
|
||||||
const _ContactRow({
|
const _ContactRow({
|
||||||
required this.contact,
|
required this.contact,
|
||||||
required this.message,
|
required this.message,
|
||||||
|
this.contactsById,
|
||||||
});
|
});
|
||||||
|
|
||||||
final SharedContact contact;
|
final SharedContact contact;
|
||||||
final Message message;
|
final Message message;
|
||||||
|
final Map<int, Contact>? contactsById;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_ContactRow> createState() => _ContactRowState();
|
State<_ContactRow> createState() => _ContactRowState();
|
||||||
|
|
@ -161,61 +166,68 @@ class _ContactRowState extends State<_ContactRow> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
if (widget.contactsById != null) {
|
||||||
|
return _buildContactRow(
|
||||||
|
widget.contactsById![widget.contact.userId.toInt()],
|
||||||
|
);
|
||||||
|
}
|
||||||
return StreamBuilder<Contact?>(
|
return StreamBuilder<Contact?>(
|
||||||
stream: twonlyDB.contactsDao.watchContact(widget.contact.userId.toInt()),
|
stream: twonlyDB.contactsDao.watchContact(widget.contact.userId.toInt()),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final contactInDb = snapshot.data;
|
return _buildContactRow(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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
widget.mediaService.mediaFile.displayLimitInMilliseconds != null) {
|
||||||
return;
|
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(() {
|
setState(() {
|
||||||
_canBeReopened = true;
|
_canBeReopened = true;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -234,20 +234,19 @@ class _ResponsePreviewState extends State<ResponsePreview> {
|
||||||
final pathToCheck = isVideo
|
final pathToCheck = isVideo
|
||||||
? _mediaService!.thumbnailPath
|
? _mediaService!.thumbnailPath
|
||||||
: _mediaService!.storedPath;
|
: _mediaService!.storedPath;
|
||||||
if (pathToCheck.existsSync() && pathToCheck.lengthSync() > 0) {
|
imageWidget = Container(
|
||||||
imageWidget = Container(
|
height: 40,
|
||||||
height: 40,
|
width: 40,
|
||||||
width: 40,
|
margin: const EdgeInsets.only(left: 8),
|
||||||
margin: const EdgeInsets.only(left: 8),
|
child: ClipRRect(
|
||||||
child: ClipRRect(
|
borderRadius: BorderRadius.circular(4),
|
||||||
borderRadius: BorderRadius.circular(4),
|
child: Image.file(
|
||||||
child: Image.file(
|
pathToCheck,
|
||||||
pathToCheck,
|
fit: BoxFit.cover,
|
||||||
fit: BoxFit.cover,
|
errorBuilder: (_, _, _) => const SizedBox.shrink(),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
import 'dart:ui' as ui;
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
import 'package:clock/clock.dart';
|
import 'package:clock/clock.dart';
|
||||||
|
|
@ -35,6 +36,7 @@ class DeveloperSettingsView extends StatefulWidget {
|
||||||
|
|
||||||
class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
|
class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
|
||||||
bool _isGeneratingMockImages = false;
|
bool _isGeneratingMockImages = false;
|
||||||
|
bool _isGeneratingMockMessages = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
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 {
|
Future<void> toggleDeveloperSettings() async {
|
||||||
await UserService.update((u) => u.isDeveloper = !u.isDeveloper);
|
await UserService.update((u) => u.isDeveloper = !u.isDeveloper);
|
||||||
}
|
}
|
||||||
|
|
@ -376,6 +477,23 @@ class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
|
||||||
onTap: () =>
|
onTap: () =>
|
||||||
context.push(Routes.settingsDeveloperAutomatedTesting),
|
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)
|
if (kDebugMode)
|
||||||
ListTile(
|
ListTile(
|
||||||
title: const Text('Generate 1000 Mock Images'),
|
title: const Text('Generate 1000 Mock Images'),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue