import "dart:async"; import "dart:convert"; import "package:easy_localization/easy_localization.dart"; import "package:file_picker/file_picker.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:gap/gap.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:island/database/message.dart"; import "package:island/models/chat.dart"; import "package:island/models/file.dart"; import "package:island/pods/messages_notifier.dart"; import "package:island/pods/network.dart"; import "package:island/pods/websocket.dart"; import "package:island/screens/chat/chat.dart"; import "package:island/services/responsive.dart"; import "package:island/widgets/alert.dart"; import "package:island/widgets/app_scaffold.dart"; import "package:island/widgets/chat/call_overlay.dart"; import "package:island/widgets/chat/message_item.dart"; import "package:island/widgets/content/cloud_files.dart"; import "package:island/widgets/response.dart"; import "package:material_symbols_icons/material_symbols_icons.dart"; import "package:styled_widget/styled_widget.dart"; import "package:super_sliver_list/super_sliver_list.dart"; import "package:material_symbols_icons/symbols.dart"; import "package:island/widgets/chat/call_button.dart"; import "package:island/widgets/chat/chat_input.dart"; import "package:island/widgets/chat/public_room_preview.dart"; final isSyncingProvider = StateProvider.autoDispose((ref) => false); final flashingMessagesProvider = StateProvider>((ref) => {}); final appLifecycleStateProvider = StreamProvider((ref) { final controller = StreamController(); final observer = _AppLifecycleObserver((state) { if (controller.isClosed) return; controller.add(state); }); WidgetsBinding.instance.addObserver(observer); ref.onDispose(() { WidgetsBinding.instance.removeObserver(observer); controller.close(); }); return controller.stream; }); class _AppLifecycleObserver extends WidgetsBindingObserver { final ValueChanged onChange; _AppLifecycleObserver(this.onChange); @override void didChangeAppLifecycleState(AppLifecycleState state) { onChange(state); } } class ChatRoomScreen extends HookConsumerWidget { final String id; const ChatRoomScreen({super.key, required this.id}); @override Widget build(BuildContext context, WidgetRef ref) { final chatRoom = ref.watch(chatroomProvider(id)); final chatIdentity = ref.watch(chatroomIdentityProvider(id)); final isSyncing = ref.watch(isSyncingProvider); if (chatIdentity.isLoading || chatRoom.isLoading) { return AppScaffold( appBar: AppBar(leading: const PageBackButton()), body: CircularProgressIndicator().center(), ); } else if (chatIdentity.value == null) { // Identity was not found, user was not joined return chatRoom.when( data: (room) { if (room!.isPublic) { // Show public room preview with messages but no input return PublicRoomPreview(id: id, room: room); } else { // Show regular "not joined" screen for private rooms return AppScaffold( appBar: AppBar(leading: const PageBackButton()), body: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 280), child: Column( crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( room.isCommunity == true ? Symbols.person_add : Symbols.person_remove, size: 36, fill: 1, ).padding(bottom: 4), Text('chatNotJoined').tr(), if (room.isCommunity != true) Text( 'chatUnableJoin', textAlign: TextAlign.center, ).tr().bold() else FilledButton.tonalIcon( onPressed: () async { try { showLoadingModal(context); final apiClient = ref.read(apiClientProvider); await apiClient.post( '/sphere/chat/${room.id}/members/me', ); ref.invalidate(chatroomIdentityProvider(id)); } catch (err) { showErrorAlert(err); } finally { if (context.mounted) { hideLoadingModal(context); } } }, label: Text('chatJoin').tr(), icon: const Icon(Icons.add), ).padding(top: 8), ], ), ).center(), ), ); } }, loading: () => AppScaffold( appBar: AppBar(leading: const PageBackButton()), body: CircularProgressIndicator().center(), ), error: (error, _) => AppScaffold( appBar: AppBar(leading: const PageBackButton()), body: ResponseErrorWidget( error: error, onRetry: () => ref.refresh(chatroomProvider(id)), ), ), ); } final messages = ref.watch(messagesNotifierProvider(id)); final messagesNotifier = ref.read(messagesNotifierProvider(id).notifier); final ws = ref.watch(websocketProvider); final messageController = useTextEditingController(); final scrollController = useScrollController(); final messageReplyingTo = useState(null); final messageForwardingTo = useState(null); final messageEditingTo = useState(null); final attachments = useState>([]); final attachmentProgress = useState>>({}); // Function to send read receipt void sendReadReceipt() async { // Send websocket packet final wsState = ref.read(websocketStateProvider.notifier); wsState.sendMessage( jsonEncode( WebSocketPacket( type: 'messages.read', data: {'chat_room_id': id}, endpoint: 'DysonNetwork.Sphere', ), ), ); } // Members who are typing final typingStatuses = useState>([]); final typingDebouncer = useState(null); void sendTypingStatus() { // Don't send if we're already in a cooldown period if (typingDebouncer.value != null) return; // Send typing status immediately final wsState = ref.read(websocketStateProvider.notifier); wsState.sendMessage( jsonEncode( WebSocketPacket( type: 'messages.typing', data: {'chat_room_id': id}, endpoint: 'DysonNetwork.Sphere', ), ), ); typingDebouncer.value = Timer(const Duration(milliseconds: 850), () { typingDebouncer.value = null; }); } // Add timer to remove typing status after inactivity useEffect(() { final removeTypingTimer = Timer.periodic(const Duration(seconds: 5), (_) { if (typingStatuses.value.isNotEmpty) { // Remove typing statuses older than 5 seconds final now = DateTime.now(); typingStatuses.value = typingStatuses.value.where((member) { final lastTyped = member.lastTyped ?? DateTime.now().subtract(const Duration(milliseconds: 1350)); return now.difference(lastTyped).inSeconds < 5; }).toList(); } }); return () => removeTypingTimer.cancel(); }, []); var isLoading = false; final listController = useMemoized(() => ListController(), []); // Add scroll listener for pagination useEffect(() { void onScroll() { if (scrollController.position.pixels >= scrollController.position.maxScrollExtent - 200) { if (isLoading) return; isLoading = true; messagesNotifier.loadMore().then((_) => isLoading = false); } } scrollController.addListener(onScroll); return () => scrollController.removeListener(onScroll); }, [scrollController]); // Add websocket listener for new messages useEffect(() { void onMessage(WebSocketPacket pkt) { if (!pkt.type.startsWith('messages')) return; if (['messages.read'].contains(pkt.type)) return; if (pkt.type == 'messages.typing' && pkt.data?['sender'] != null) { if (pkt.data?['room_id'] != chatRoom.value?.id) return; if (pkt.data?['sender_id'] == chatIdentity.value?.id) return; final sender = SnChatMember.fromJson( pkt.data?['sender'], ).copyWith(lastTyped: DateTime.now()); // Check if the sender is already in the typing list final existingIndex = typingStatuses.value.indexWhere( (member) => member.id == sender.id, ); if (existingIndex >= 0) { // Update the existing entry with new timestamp final updatedList = [...typingStatuses.value]; updatedList[existingIndex] = sender; typingStatuses.value = updatedList; } else { // Add new typing status typingStatuses.value = [...typingStatuses.value, sender]; } return; } final message = SnChatMessage.fromJson(pkt.data!); if (message.chatRoomId != chatRoom.value?.id) return; switch (pkt.type) { case 'messages.new': if (message.type.startsWith('call')) { // Handle the ongoing call. ref.invalidate(ongoingCallProvider(message.chatRoomId)); } messagesNotifier.receiveMessage(message); // Send read receipt for new message sendReadReceipt(); case 'messages.update': messagesNotifier.receiveMessageUpdate(message).then((_) { messagesNotifier.receiveMessage(message); }); case 'messages.delete': messagesNotifier.receiveMessageDeletion(message.id).then((_) { messagesNotifier.receiveMessage(message); }); } } sendReadReceipt(); final subscription = ws.dataStream.listen(onMessage); return () => subscription.cancel(); }, [ws, chatRoom]); useEffect(() { final wsState = ref.read(websocketStateProvider.notifier); wsState.sendMessage( jsonEncode( WebSocketPacket( type: 'messages.subscribe', data: {'chat_room_id': id}, ), ), ); return () { wsState.sendMessage( jsonEncode( WebSocketPacket( type: 'messages.unsubscribe', data: {'chat_room_id': id}, ), ), ); }; }, [id]); Future pickPhotoMedia() async { final result = await FilePicker.platform.pickFiles( type: FileType.image, allowMultiple: true, allowCompression: false, ); if (result == null || result.count == 0) return; attachments.value = [ ...attachments.value, ...result.files.map( (e) => UniversalFile(data: e.xFile, type: UniversalFileType.image), ), ]; } Future pickVideoMedia() async { final result = await FilePicker.platform.pickFiles( type: FileType.video, allowMultiple: true, allowCompression: false, ); if (result == null || result.count == 0) return; attachments.value = [ ...attachments.value, ...result.files.map( (e) => UniversalFile(data: e.xFile, type: UniversalFileType.video), ), ]; } void sendMessage() { if (messageController.text.trim().isNotEmpty || attachments.value.isNotEmpty) { messagesNotifier .sendMessage( messageController.text.trim(), attachments.value, editingTo: messageEditingTo.value, forwardingTo: messageForwardingTo.value, replyingTo: messageReplyingTo.value, onProgress: (messageId, progress) { attachmentProgress.value = { ...attachmentProgress.value, messageId: progress, }; }, ) .then((_) => sendReadReceipt()); messageController.clear(); messageEditingTo.value = null; messageReplyingTo.value = null; messageForwardingTo.value = null; attachments.value = []; } } // Add listener to message controller for typing status useEffect(() { void onTextChange() { if (messageController.text.isNotEmpty) { sendTypingStatus(); } } messageController.addListener(onTextChange); return () => messageController.removeListener(onTextChange); }, [messageController]); final compactHeader = isWideScreen(context); Widget comfortHeaderWidget(SnChatRoom? room) => Column( spacing: 4, mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ SizedBox( height: 26, width: 26, child: (room!.type == 1 && room.picture?.id == null) ? SplitAvatarWidget( filesId: room.members! .map((e) => e.account.profile.picture?.id) .toList(), ) : room.picture?.id != null ? ProfilePictureWidget( fileId: room.picture?.id, fallbackIcon: Symbols.chat, ) : CircleAvatar( child: Text( room.name![0].toUpperCase(), style: const TextStyle(fontSize: 12), ), ), ), Text( (room.type == 1 && room.name == null) ? room.members!.map((e) => e.account.nick).join(', ') : room.name!, ).fontSize(15), ], ); Widget compactHeaderWidget(SnChatRoom? room) => Row( spacing: 8, crossAxisAlignment: CrossAxisAlignment.center, children: [ SizedBox( height: 26, width: 26, child: (room!.type == 1 && room.picture?.id == null) ? SplitAvatarWidget( filesId: room.members! .map((e) => e.account.profile.picture?.id) .toList(), ) : room.picture?.id != null ? ProfilePictureWidget( fileId: room.picture?.id, fallbackIcon: Symbols.chat, ) : CircleAvatar( child: Text( room.name![0].toUpperCase(), style: const TextStyle(fontSize: 12), ), ), ), Text( (room.type == 1 && room.name == null) ? room.members!.map((e) => e.account.nick).join(', ') : room.name!, ).fontSize(19), ], ); const messageKeyPrefix = 'message-'; Widget chatMessageListWidget(List messageList) => SuperListView.builder( listController: listController, padding: EdgeInsets.symmetric(vertical: 16), controller: scrollController, reverse: true, // Show newest messages at the bottom itemCount: messageList.length, findChildIndexCallback: (key) { final valueKey = key as ValueKey; final messageId = (valueKey.value as String).substring( messageKeyPrefix.length, ); return messageList.indexWhere((m) => m.id == messageId); }, extentEstimation: (_, _) => 40, itemBuilder: (context, index) { final message = messageList[index]; final nextMessage = index < messageList.length - 1 ? messageList[index + 1] : null; final isLastInGroup = nextMessage == null || nextMessage.senderId != message.senderId || nextMessage.createdAt .difference(message.createdAt) .inMinutes .abs() > 3; final key = ValueKey('$messageKeyPrefix${message.id}'); return chatIdentity.when( skipError: true, data: (identity) => MessageItem( key: key, message: message, isCurrentUser: identity?.id == message.senderId, onAction: (action) { switch (action) { case MessageItemAction.delete: messagesNotifier.deleteMessage(message.id); case MessageItemAction.edit: messageEditingTo.value = message.toRemoteMessage(); messageController.text = messageEditingTo.value?.content ?? ''; attachments.value = messageEditingTo.value!.attachments .map((e) => UniversalFile.fromAttachment(e)) .toList(); case MessageItemAction.forward: messageForwardingTo.value = message.toRemoteMessage(); case MessageItemAction.reply: messageReplyingTo.value = message.toRemoteMessage(); } }, onJump: (messageId) { final messageIndex = messageList.indexWhere( (m) => m.id == messageId, ); if (messageIndex == -1) { messagesNotifier.jumpToMessage(messageId).then((index) { if (index != -1) { WidgetsBinding.instance.addPostFrameCallback((_) { listController.animateToItem( index: index, scrollController: scrollController, alignment: 0.5, duration: (estimatedDistance) => Duration(milliseconds: 250), curve: (estimatedDistance) => Curves.easeInOut, ); }); ref .read(flashingMessagesProvider.notifier) .update((set) => set.union({messageId})); } }); return; } WidgetsBinding.instance.addPostFrameCallback((_) { listController.animateToItem( index: messageIndex, scrollController: scrollController, alignment: 0.5, duration: (estimatedDistance) => Duration(milliseconds: 250), curve: (estimatedDistance) => Curves.easeInOut, ); }); ref .read(flashingMessagesProvider.notifier) .update((set) => set.union({messageId})); }, progress: attachmentProgress.value[message.id], showAvatar: isLastInGroup, ), loading: () => MessageItem( key: key, message: message, isCurrentUser: false, onAction: null, progress: null, showAvatar: false, onJump: (_) {}, ), error: (_, _) => SizedBox.shrink(key: key), ); }, ); return AppScaffold( appBar: AppBar( leading: !compactHeader ? const Center(child: PageBackButton()) : null, automaticallyImplyLeading: false, toolbarHeight: compactHeader ? null : 64, title: chatRoom.when( data: (room) => compactHeader ? compactHeaderWidget(room) : comfortHeaderWidget(room), loading: () => const Text('Loading...'), error: (err, _) => ResponseErrorWidget( error: err, onRetry: () => messagesNotifier.loadInitial(), ), ), actions: [ AudioCallButton(roomId: id), IconButton( icon: const Icon(Icons.more_vert), onPressed: () async { final result = await context.pushNamed( 'chatDetail', pathParameters: {'id': id}, ); if (result is String && messages.valueOrNull != null) { // Jump to the message that was selected in search final messageList = messages.valueOrNull!; final messageIndex = messageList.indexWhere( (m) => m.id == result, ); if (messageIndex == -1) { messagesNotifier.jumpToMessage(result).then((index) { if (index != -1) { WidgetsBinding.instance.addPostFrameCallback((_) { listController.animateToItem( index: index, scrollController: scrollController, alignment: 0.5, duration: (estimatedDistance) => Duration(milliseconds: 250), curve: (estimatedDistance) => Curves.easeInOut, ); }); ref .read(flashingMessagesProvider.notifier) .update((set) => set.union({result})); } }); } else { WidgetsBinding.instance.addPostFrameCallback((_) { listController.animateToItem( index: messageIndex, scrollController: scrollController, alignment: 0.5, duration: (estimatedDistance) => Duration(milliseconds: 250), curve: (estimatedDistance) => Curves.easeInOut, ); }); ref .read(flashingMessagesProvider.notifier) .update((set) => set.union({result})); } } }, ), const Gap(8), ], bottom: isSyncing ? const PreferredSize( preferredSize: Size.fromHeight(2), child: LinearProgressIndicator( borderRadius: BorderRadius.zero, ), ) : null, ), body: Stack( children: [ Column( children: [ Expanded( child: messages.when( data: (messageList) => messageList.isEmpty ? Center(child: Text('No messages yet'.tr())) : chatMessageListWidget(messageList), loading: () => const Center(child: CircularProgressIndicator()), error: (error, _) => ResponseErrorWidget( error: error, onRetry: () => messagesNotifier.loadInitial(), ), ), ), chatRoom.when( data: (room) => Column( mainAxisSize: MainAxisSize.min, children: [ AnimatedSwitcher( duration: const Duration(milliseconds: 150), switchInCurve: Curves.fastEaseInToSlowEaseOut, switchOutCurve: Curves.fastEaseInToSlowEaseOut, transitionBuilder: ( Widget child, Animation animation, ) { return SlideTransition( position: Tween( begin: const Offset(0, -0.3), end: Offset.zero, ).animate( CurvedAnimation( parent: animation, curve: Curves.easeOutCubic, ), ), child: SizeTransition( sizeFactor: animation, axisAlignment: -1.0, child: FadeTransition( opacity: animation, child: child, ), ), ); }, child: typingStatuses.value.isNotEmpty ? Container( key: const ValueKey('typing-indicator'), width: double.infinity, padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 4, ), child: Row( children: [ const Icon( Symbols.more_horiz, size: 16, ).padding(horizontal: 8), const Gap(8), Expanded( child: Text( 'typingHint'.plural( typingStatuses.value.length, args: [ typingStatuses.value .map( (x) => x.nick ?? x.account.nick, ) .join(', '), ], ), style: Theme.of( context, ).textTheme.bodySmall, ), ), ], ), ) : const SizedBox.shrink( key: ValueKey('typing-indicator-none'), ), ), ChatInput( messageController: messageController, chatRoom: room!, onSend: sendMessage, onClear: () { if (messageEditingTo.value != null) { attachments.value.clear(); messageController.clear(); } messageEditingTo.value = null; messageReplyingTo.value = null; messageForwardingTo.value = null; }, messageEditingTo: messageEditingTo.value, messageReplyingTo: messageReplyingTo.value, messageForwardingTo: messageForwardingTo.value, onPickFile: (bool isPhoto) { if (isPhoto) { pickPhotoMedia(); } else { pickVideoMedia(); } }, attachments: attachments.value, onUploadAttachment: (_) { // not going to do anything, only upload when send the message }, onDeleteAttachment: (index) async { final attachment = attachments.value[index]; if (attachment.isOnCloud) { final client = ref.watch(apiClientProvider); await client.delete( '/drive/files/${attachment.data.id}', ); } final clone = List.of(attachments.value); clone.removeAt(index); attachments.value = clone; }, onMoveAttachment: (idx, delta) { if (idx + delta < 0 || idx + delta >= attachments.value.length) { return; } final clone = List.of(attachments.value); clone.insert(idx + delta, clone.removeAt(idx)); attachments.value = clone; }, onAttachmentsChanged: (newAttachments) { attachments.value = newAttachments; }, ), ], ), error: (_, _) => const SizedBox.shrink(), loading: () => const SizedBox.shrink(), ), ], ), Positioned( left: 0, right: 0, top: 0, child: CallOverlayBar().padding(horizontal: 8, top: 12), ), ], ), ); } }