mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-04-18 14:22:53 +00:00
413 lines
14 KiB
Dart
413 lines
14 KiB
Dart
import 'dart:async';
|
|
import 'dart:collection';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:mutex/mutex.dart';
|
|
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
|
import 'package:twonly/globals.dart';
|
|
import 'package:twonly/src/constants/routes.keys.dart';
|
|
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/services/api/messages.dart';
|
|
import 'package:twonly/src/services/notifications/background.notifications.dart';
|
|
import 'package:twonly/src/themes/colors.dart';
|
|
import 'package:twonly/src/utils/misc.dart';
|
|
import 'package:twonly/src/views/chats/chat_messages_components/blink.component.dart';
|
|
import 'package:twonly/src/views/chats/chat_messages_components/chat_group_action.dart';
|
|
import 'package:twonly/src/views/chats/chat_messages_components/chat_list_entry.dart';
|
|
import 'package:twonly/src/views/chats/chat_messages_components/entries/chat_date_chip.dart';
|
|
import 'package:twonly/src/views/chats/chat_messages_components/message_input.dart';
|
|
import 'package:twonly/src/views/chats/chat_messages_components/response_container.dart';
|
|
import 'package:twonly/src/views/chats/chat_messages_components/typing_indicator.dart';
|
|
import 'package:twonly/src/views/components/avatar_icon.component.dart';
|
|
import 'package:twonly/src/views/components/flame.dart';
|
|
import 'package:twonly/src/views/components/verified_shield.dart';
|
|
|
|
class ChatMessagesView extends StatefulWidget {
|
|
const ChatMessagesView(this.groupId, {super.key});
|
|
|
|
final String groupId;
|
|
|
|
@override
|
|
State<ChatMessagesView> createState() => _ChatMessagesViewState();
|
|
}
|
|
|
|
class _ChatMessagesViewState extends State<ChatMessagesView> {
|
|
HashSet<int> alreadyReportedOpened = HashSet<int>();
|
|
late StreamSubscription<Group?> userSub;
|
|
late StreamSubscription<List<Message>> messageSub;
|
|
StreamSubscription<List<GroupHistory>>? groupActionsSub;
|
|
StreamSubscription<List<Contact>>? contactSub;
|
|
|
|
Group? _group;
|
|
|
|
Map<int, Contact> userIdToContact = {};
|
|
|
|
List<ChatItem> messages = [];
|
|
List<Message> allMessages = [];
|
|
List<GroupHistory> groupActions = [];
|
|
List<MemoryItem> galleryItems = [];
|
|
Message? quotesMessage;
|
|
GlobalKey verifyShieldKey = GlobalKey();
|
|
late FocusNode textFieldFocus;
|
|
final ItemScrollController itemScrollController = ItemScrollController();
|
|
int? focusedScrollItem;
|
|
bool _receiverDeletedAccount = false;
|
|
|
|
Timer? _nextTypingIndicator;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
textFieldFocus = FocusNode();
|
|
initStreams();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
userSub.cancel();
|
|
messageSub.cancel();
|
|
contactSub?.cancel();
|
|
groupActionsSub?.cancel();
|
|
_nextTypingIndicator?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
Mutex protectMessageUpdating = Mutex();
|
|
|
|
Future<void> initStreams() async {
|
|
final groupStream = twonlyDB.groupsDao.watchGroup(widget.groupId);
|
|
userSub = groupStream.listen((newGroup) {
|
|
if (newGroup == null) return;
|
|
setState(() {
|
|
_group = newGroup;
|
|
});
|
|
|
|
protectMessageUpdating.protect(() async {
|
|
if (groupActionsSub == null && !newGroup.isDirectChat) {
|
|
final actionsStream = twonlyDB.groupsDao.watchGroupActions(
|
|
newGroup.groupId,
|
|
);
|
|
groupActionsSub = actionsStream.listen((update) async {
|
|
groupActions = update;
|
|
await setMessages(allMessages, update);
|
|
});
|
|
|
|
final contactsStream = twonlyDB.contactsDao.watchAllContacts();
|
|
contactSub = contactsStream.listen((contacts) {
|
|
for (final contact in contacts) {
|
|
userIdToContact[contact.userId] = contact;
|
|
}
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
final msgStream = twonlyDB.messagesDao.watchByGroupId(widget.groupId);
|
|
messageSub = msgStream.listen((update) async {
|
|
allMessages = update;
|
|
await protectMessageUpdating.protect(() async {
|
|
await setMessages(update, groupActions);
|
|
});
|
|
});
|
|
|
|
final groupContacts = await twonlyDB.groupsDao.getGroupContact(
|
|
widget.groupId,
|
|
);
|
|
if (groupContacts.length == 1) {
|
|
_receiverDeletedAccount = groupContacts.first.accountDeleted;
|
|
}
|
|
|
|
if (gUser.typingIndicators) {
|
|
unawaited(sendTypingIndication(widget.groupId, false));
|
|
_nextTypingIndicator = Timer.periodic(const Duration(seconds: 5), (
|
|
_,
|
|
) async {
|
|
await sendTypingIndication(widget.groupId, false);
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> setMessages(
|
|
List<Message> newMessages,
|
|
List<GroupHistory> groupActions,
|
|
) async {
|
|
await flutterLocalNotificationsPlugin.cancelAll();
|
|
|
|
final chatItems = <ChatItem>[];
|
|
final storedMediaFiles = <Message>[];
|
|
|
|
DateTime? lastDate;
|
|
|
|
final openedMessages = <int, List<String>>{};
|
|
|
|
var groupHistoryIndex = 0;
|
|
|
|
for (final msg in newMessages) {
|
|
if (groupHistoryIndex < groupActions.length) {
|
|
for (; groupHistoryIndex < groupActions.length; groupHistoryIndex++) {
|
|
if (msg.createdAt.isAfter(groupActions[groupHistoryIndex].actionAt)) {
|
|
chatItems.add(
|
|
ChatItem.groupAction(groupActions[groupHistoryIndex]),
|
|
);
|
|
// groupHistoryIndex++;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (msg.type != MessageType.media.name &&
|
|
msg.senderId != null &&
|
|
msg.openedAt == null) {
|
|
if (openedMessages[msg.senderId!] == null) {
|
|
openedMessages[msg.senderId!] = [];
|
|
}
|
|
openedMessages[msg.senderId!]!.add(msg.messageId);
|
|
}
|
|
|
|
if (msg.type == MessageType.media.name && msg.mediaStored) {
|
|
storedMediaFiles.add(msg);
|
|
}
|
|
|
|
if (lastDate == null ||
|
|
msg.createdAt.day != lastDate.day ||
|
|
msg.createdAt.month != lastDate.month ||
|
|
msg.createdAt.year != lastDate.year) {
|
|
chatItems.add(ChatItem.date(msg.createdAt));
|
|
lastDate = msg.createdAt;
|
|
}
|
|
chatItems.add(ChatItem.message(msg));
|
|
}
|
|
if (groupHistoryIndex < groupActions.length) {
|
|
for (var i = groupHistoryIndex; i < groupActions.length; i++) {
|
|
chatItems.add(ChatItem.groupAction(groupActions[i]));
|
|
}
|
|
}
|
|
|
|
for (final contactId in openedMessages.keys) {
|
|
await notifyContactAboutOpeningMessage(
|
|
contactId,
|
|
openedMessages[contactId]!,
|
|
);
|
|
}
|
|
|
|
if (!mounted) return;
|
|
setState(() {
|
|
messages = chatItems.reversed.toList();
|
|
});
|
|
|
|
final items = await MemoryItem.convertFromMessages(storedMediaFiles);
|
|
if (!mounted) return;
|
|
galleryItems = items.values.toList();
|
|
setState(() {});
|
|
}
|
|
|
|
Future<void> scrollToMessage(String messageId) async {
|
|
final index = messages.indexWhere(
|
|
(x) => x.isMessage && x.message!.messageId == messageId,
|
|
);
|
|
if (index == -1) return;
|
|
setState(() {
|
|
focusedScrollItem = index;
|
|
});
|
|
await itemScrollController.scrollTo(
|
|
index: index,
|
|
duration: const Duration(milliseconds: 300),
|
|
alignment: 0.5,
|
|
);
|
|
Future.delayed(const Duration(milliseconds: 300), () {
|
|
if (!context.mounted) return;
|
|
setState(() {
|
|
focusedScrollItem = null;
|
|
});
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (_group == null) return Container();
|
|
final group = _group!;
|
|
return GestureDetector(
|
|
onTap: () => FocusScope.of(context).unfocus(),
|
|
child: Scaffold(
|
|
appBar: AppBar(
|
|
title: GestureDetector(
|
|
onTap: () async {
|
|
if (group.isDirectChat) {
|
|
final member = await twonlyDB.groupsDao.getAllGroupMembers(
|
|
group.groupId,
|
|
);
|
|
if (!context.mounted) return;
|
|
if (member.isEmpty) return;
|
|
await context.push(
|
|
Routes.profileContact(member.first.contactId),
|
|
);
|
|
} else {
|
|
await context.push(Routes.profileGroup(group.groupId));
|
|
}
|
|
},
|
|
child: Row(
|
|
children: [
|
|
AvatarIcon(
|
|
group: group,
|
|
fontSize: 19,
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: ColoredBox(
|
|
color: Colors.transparent,
|
|
child: Row(
|
|
children: [
|
|
Text(
|
|
substringBy(group.groupName, 20),
|
|
),
|
|
const SizedBox(width: 10),
|
|
VerifiedShield(key: verifyShieldKey, group: group),
|
|
const SizedBox(width: 10),
|
|
FlameCounterWidget(groupId: group.groupId),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
body: SafeArea(
|
|
child: Column(
|
|
children: [
|
|
Expanded(
|
|
child: ScrollablePositionedList.builder(
|
|
reverse: true,
|
|
itemCount: messages.length + 1 + 1,
|
|
itemScrollController: itemScrollController,
|
|
itemBuilder: (context, i) {
|
|
if (i == 0) {
|
|
return gUser.typingIndicators
|
|
? TypingIndicator(group: group)
|
|
: Container();
|
|
}
|
|
i -= 1;
|
|
if (i == messages.length) {
|
|
return const Padding(
|
|
padding: EdgeInsetsGeometry.only(top: 10),
|
|
);
|
|
}
|
|
if (messages[i].isDate) {
|
|
return ChatDateChip(
|
|
item: messages[i],
|
|
);
|
|
} else if (messages[i].isGroupAction) {
|
|
return ChatGroupAction(
|
|
key: Key(messages[i].groupAction!.groupHistoryId),
|
|
action: messages[i].groupAction!,
|
|
);
|
|
} else {
|
|
final chatMessage = messages[i].message!;
|
|
return BlinkWidget(
|
|
enabled: focusedScrollItem == i,
|
|
child: ChatListEntry(
|
|
key: Key(chatMessage.messageId),
|
|
message: messages[i].message!,
|
|
nextMessage: (i > 0) ? messages[i - 1].message : null,
|
|
prevMessage: ((i + 1) < messages.length)
|
|
? messages[i + 1].message
|
|
: null,
|
|
group: group,
|
|
galleryItems: galleryItems,
|
|
userIdToContact: userIdToContact,
|
|
scrollToMessage: scrollToMessage,
|
|
onResponseTriggered: () {
|
|
setState(() {
|
|
quotesMessage = chatMessage;
|
|
});
|
|
textFieldFocus.requestFocus();
|
|
},
|
|
),
|
|
);
|
|
}
|
|
},
|
|
),
|
|
),
|
|
if (quotesMessage != null)
|
|
Container(
|
|
padding: const EdgeInsets.only(
|
|
left: 20,
|
|
right: 20,
|
|
top: 10,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: ResponsePreview(
|
|
message: quotesMessage,
|
|
showBorder: true,
|
|
group: group,
|
|
),
|
|
),
|
|
IconButton(
|
|
onPressed: () {
|
|
setState(() {
|
|
quotesMessage = null;
|
|
});
|
|
},
|
|
icon: const FaIcon(
|
|
FontAwesomeIcons.xmark,
|
|
size: 16,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
if (!group.leftGroup && !_receiverDeletedAccount)
|
|
MessageInput(
|
|
group: group,
|
|
quotesMessage: quotesMessage,
|
|
textFieldFocus: textFieldFocus,
|
|
onMessageSend: () {
|
|
setState(() {
|
|
quotesMessage = null;
|
|
});
|
|
},
|
|
),
|
|
if (_receiverDeletedAccount)
|
|
Text(context.lang.userDeletedAccount),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Color getMessageColor(bool isOther) {
|
|
return isOther ? DefaultColors.messageSelf : DefaultColors.messageOther;
|
|
}
|
|
|
|
class ChatItem {
|
|
const ChatItem._({
|
|
this.message,
|
|
this.date,
|
|
this.groupAction,
|
|
});
|
|
factory ChatItem.date(DateTime date) {
|
|
return ChatItem._(date: date);
|
|
}
|
|
factory ChatItem.message(Message message) {
|
|
return ChatItem._(message: message);
|
|
}
|
|
factory ChatItem.groupAction(GroupHistory groupAction) {
|
|
return ChatItem._(groupAction: groupAction);
|
|
}
|
|
final GroupHistory? groupAction;
|
|
final Message? message;
|
|
final DateTime? date;
|
|
bool get isMessage => message != null;
|
|
bool get isDate => date != null;
|
|
bool get isGroupAction => groupAction != null;
|
|
}
|