mirror of
				https://gitlab.com/famedly/fluffychat.git
				synced 2025-10-31 03:57:27 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			452 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			452 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
| import 'dart:async';
 | |
| import 'dart:io';
 | |
| 
 | |
| import 'package:famedlysdk/famedlysdk.dart';
 | |
| import 'package:file_picker/file_picker.dart';
 | |
| import 'package:fluffychat/components/adaptive_page_layout.dart';
 | |
| import 'package:fluffychat/components/chat_settings_popup_menu.dart';
 | |
| import 'package:fluffychat/components/list_items/message.dart';
 | |
| import 'package:fluffychat/components/matrix.dart';
 | |
| import 'package:fluffychat/i18n/i18n.dart';
 | |
| import 'package:fluffychat/utils/app_route.dart';
 | |
| import 'package:fluffychat/utils/room_extension.dart';
 | |
| import 'package:fluffychat/views/chat_encryption_settings.dart';
 | |
| import 'package:flutter/foundation.dart';
 | |
| import 'package:flutter/material.dart';
 | |
| import 'package:image_picker/image_picker.dart';
 | |
| import 'package:toast/toast.dart';
 | |
| import 'package:pedantic/pedantic.dart';
 | |
| 
 | |
| import 'chat_list.dart';
 | |
| 
 | |
| class ChatView extends StatelessWidget {
 | |
|   final String id;
 | |
| 
 | |
|   const ChatView(this.id, {Key key}) : super(key: key);
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context) {
 | |
|     // TODO: implement build
 | |
|     return AdaptivePageLayout(
 | |
|       primaryPage: FocusPage.SECOND,
 | |
|       firstScaffold: ChatList(
 | |
|         activeChat: id,
 | |
|       ),
 | |
|       secondScaffold: _Chat(id),
 | |
|     );
 | |
|   }
 | |
| }
 | |
| 
 | |
| class _Chat extends StatefulWidget {
 | |
|   final String id;
 | |
| 
 | |
|   const _Chat(this.id, {Key key}) : super(key: key);
 | |
| 
 | |
|   @override
 | |
|   _ChatState createState() => _ChatState();
 | |
| }
 | |
| 
 | |
| class _ChatState extends State<_Chat> {
 | |
|   Room room;
 | |
| 
 | |
|   Timeline timeline;
 | |
| 
 | |
|   MatrixState matrix;
 | |
| 
 | |
|   String seenByText = "";
 | |
| 
 | |
|   final ScrollController _scrollController = ScrollController();
 | |
| 
 | |
|   FocusNode inputFocus = FocusNode();
 | |
| 
 | |
|   Timer typingCoolDown;
 | |
|   Timer typingTimeout;
 | |
|   bool currentlyTyping = false;
 | |
| 
 | |
|   @override
 | |
|   void initState() {
 | |
|     _scrollController.addListener(() async {
 | |
|       if (_scrollController.position.pixels ==
 | |
|               _scrollController.position.maxScrollExtent &&
 | |
|           timeline.events.isNotEmpty &&
 | |
|           timeline.events[timeline.events.length - 1].type !=
 | |
|               EventTypes.RoomCreate) {
 | |
|         await timeline.requestHistory(historyCount: 100);
 | |
|       }
 | |
|     });
 | |
| 
 | |
|     super.initState();
 | |
|   }
 | |
| 
 | |
|   void updateView() {
 | |
|     if (!mounted) return;
 | |
| 
 | |
|     String seenByText = "";
 | |
|     if (timeline.events.isNotEmpty) {
 | |
|       List lastReceipts = List.from(timeline.events.first.receipts);
 | |
|       lastReceipts.removeWhere((r) =>
 | |
|           r.user.id == room.client.userID ||
 | |
|           r.user.id == timeline.events.first.senderId);
 | |
|       if (lastReceipts.length == 1) {
 | |
|         seenByText = I18n.of(context)
 | |
|             .seenByUser(lastReceipts.first.user.calcDisplayname());
 | |
|       } else if (lastReceipts.length == 2) {
 | |
|         seenByText = seenByText = I18n.of(context).seenByUserAndUser(
 | |
|             lastReceipts.first.user.calcDisplayname(),
 | |
|             lastReceipts[1].user.calcDisplayname());
 | |
|       } else if (lastReceipts.length > 2) {
 | |
|         seenByText = I18n.of(context).seenByUserAndCountOthers(
 | |
|             lastReceipts.first.user.calcDisplayname(),
 | |
|             (lastReceipts.length - 1).toString());
 | |
|       }
 | |
|     }
 | |
|     if (timeline != null) {
 | |
|       setState(() {
 | |
|         this.seenByText = seenByText;
 | |
|       });
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   Future<bool> getTimeline() async {
 | |
|     if (timeline == null) {
 | |
|       timeline = await room.getTimeline(onUpdate: updateView);
 | |
|       if (timeline.events.isNotEmpty) {
 | |
|         unawaited(room.sendReadReceipt(timeline.events.first.eventId));
 | |
|       }
 | |
|     }
 | |
|     updateView();
 | |
|     return true;
 | |
|   }
 | |
| 
 | |
|   @override
 | |
|   void dispose() {
 | |
|     timeline?.sub?.cancel();
 | |
|     timeline = null;
 | |
|     matrix.activeRoomId = "";
 | |
|     super.dispose();
 | |
|   }
 | |
| 
 | |
|   TextEditingController sendController = TextEditingController();
 | |
| 
 | |
|   void send() {
 | |
|     if (sendController.text.isEmpty) return;
 | |
|     room.sendTextEvent(sendController.text);
 | |
|     sendController.text = "";
 | |
|   }
 | |
| 
 | |
|   void sendFileAction(BuildContext context) async {
 | |
|     if (kIsWeb) {
 | |
|       return Toast.show(I18n.of(context).notSupportedInWeb, context);
 | |
|     }
 | |
|     File file = await FilePicker.getFile();
 | |
|     if (file == null) return;
 | |
|     await matrix.tryRequestWithLoadingDialog(
 | |
|       room.sendFileEvent(
 | |
|         MatrixFile(bytes: await file.readAsBytes(), path: file.path),
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   void sendImageAction(BuildContext context) async {
 | |
|     if (kIsWeb) {
 | |
|       return Toast.show(I18n.of(context).notSupportedInWeb, context);
 | |
|     }
 | |
|     File file = await ImagePicker.pickImage(
 | |
|         source: ImageSource.gallery,
 | |
|         imageQuality: 50,
 | |
|         maxWidth: 1600,
 | |
|         maxHeight: 1600);
 | |
|     if (file == null) return;
 | |
|     await matrix.tryRequestWithLoadingDialog(
 | |
|       room.sendImageEvent(
 | |
|         MatrixFile(bytes: await file.readAsBytes(), path: file.path),
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   void openCameraAction(BuildContext context) async {
 | |
|     if (kIsWeb) {
 | |
|       return Toast.show(I18n.of(context).notSupportedInWeb, context);
 | |
|     }
 | |
|     File file = await ImagePicker.pickImage(
 | |
|         source: ImageSource.camera,
 | |
|         imageQuality: 50,
 | |
|         maxWidth: 1600,
 | |
|         maxHeight: 1600);
 | |
|     if (file == null) return;
 | |
|     await matrix.tryRequestWithLoadingDialog(
 | |
|       room.sendImageEvent(
 | |
|         MatrixFile(bytes: await file.readAsBytes(), path: file.path),
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context) {
 | |
|     matrix = Matrix.of(context);
 | |
|     Client client = matrix.client;
 | |
|     room ??= client.getRoomById(widget.id);
 | |
|     if (room == null) {
 | |
|       return Scaffold(
 | |
|         appBar: AppBar(
 | |
|           title: Text(I18n.of(context).oopsSomethingWentWrong),
 | |
|         ),
 | |
|         body: Center(
 | |
|           child: Text(I18n.of(context).youAreNoLongerParticipatingInThisChat),
 | |
|         ),
 | |
|       );
 | |
|     }
 | |
|     matrix.activeRoomId = widget.id;
 | |
| 
 | |
|     if (room.membership == Membership.invite) {
 | |
|       matrix.tryRequestWithLoadingDialog(room.join());
 | |
|     }
 | |
| 
 | |
|     String typingText = "";
 | |
|     List<User> typingUsers = room.typingUsers;
 | |
|     typingUsers.removeWhere((User u) => u.id == client.userID);
 | |
| 
 | |
|     if (typingUsers.length == 1) {
 | |
|       typingText = I18n.of(context).isTyping;
 | |
|       if (typingUsers.first.id != room.directChatMatrixID) {
 | |
|         typingText =
 | |
|             I18n.of(context).userIsTyping(typingUsers.first.calcDisplayname());
 | |
|       }
 | |
|     } else if (typingUsers.length == 2) {
 | |
|       typingText = I18n.of(context).userAndUserAreTyping(
 | |
|           typingUsers.first.calcDisplayname(),
 | |
|           typingUsers[1].calcDisplayname());
 | |
|     } else if (typingUsers.length > 2) {
 | |
|       typingText = I18n.of(context).userAndOthersAreTyping(
 | |
|           typingUsers.first.calcDisplayname(),
 | |
|           (typingUsers.length - 1).toString());
 | |
|     }
 | |
| 
 | |
|     return Scaffold(
 | |
|       appBar: AppBar(
 | |
|         title: Column(
 | |
|           mainAxisSize: MainAxisSize.min,
 | |
|           crossAxisAlignment: CrossAxisAlignment.start,
 | |
|           children: <Widget>[
 | |
|             Text(room.getLocalizedDisplayname(context)),
 | |
|             AnimatedContainer(
 | |
|               duration: Duration(milliseconds: 500),
 | |
|               height: typingText.isEmpty ? 0 : 20,
 | |
|               child: Row(
 | |
|                 children: <Widget>[
 | |
|                   typingText.isEmpty
 | |
|                       ? Container()
 | |
|                       : Icon(Icons.edit,
 | |
|                           color: Theme.of(context).primaryColor, size: 10),
 | |
|                   SizedBox(width: 4),
 | |
|                   Text(
 | |
|                     typingText,
 | |
|                     style: TextStyle(
 | |
|                       color: Theme.of(context).primaryColor,
 | |
|                       fontStyle: FontStyle.italic,
 | |
|                     ),
 | |
|                   ),
 | |
|                 ],
 | |
|               ),
 | |
|             ),
 | |
|           ],
 | |
|         ),
 | |
|         actions: <Widget>[ChatSettingsPopupMenu(room, !room.isDirectChat)],
 | |
|       ),
 | |
|       body: SafeArea(
 | |
|         child: Column(
 | |
|           children: <Widget>[
 | |
|             Expanded(
 | |
|               child: FutureBuilder<bool>(
 | |
|                 future: getTimeline(),
 | |
|                 builder: (BuildContext context, snapshot) {
 | |
|                   if (!snapshot.hasData) {
 | |
|                     return Center(
 | |
|                       child: CircularProgressIndicator(),
 | |
|                     );
 | |
|                   }
 | |
| 
 | |
|                   if (room.notificationCount != null &&
 | |
|                       room.notificationCount > 0 &&
 | |
|                       timeline != null &&
 | |
|                       timeline.events.isNotEmpty) {
 | |
|                     room.sendReadReceipt(timeline.events.first.eventId);
 | |
|                   }
 | |
| 
 | |
|                   if (timeline.events.isEmpty) return Container();
 | |
| 
 | |
|                   return ListView.builder(
 | |
|                       reverse: true,
 | |
|                       itemCount: timeline.events.length + 1,
 | |
|                       controller: _scrollController,
 | |
|                       itemBuilder: (BuildContext context, int i) {
 | |
|                         return i == 0
 | |
|                             ? AnimatedContainer(
 | |
|                                 height: seenByText.isEmpty ? 0 : 24,
 | |
|                                 duration: seenByText.isEmpty
 | |
|                                     ? Duration(milliseconds: 0)
 | |
|                                     : Duration(milliseconds: 500),
 | |
|                                 alignment: timeline.events.first.senderId ==
 | |
|                                         client.userID
 | |
|                                     ? Alignment.topRight
 | |
|                                     : Alignment.topLeft,
 | |
|                                 child: Text(
 | |
|                                   seenByText,
 | |
|                                   maxLines: 1,
 | |
|                                   overflow: TextOverflow.ellipsis,
 | |
|                                   style: TextStyle(
 | |
|                                     color: Theme.of(context).primaryColor,
 | |
|                                   ),
 | |
|                                 ),
 | |
|                                 padding: EdgeInsets.only(
 | |
|                                   left: 8,
 | |
|                                   right: 8,
 | |
|                                   bottom: 8,
 | |
|                                 ),
 | |
|                               )
 | |
|                             : Message(timeline.events[i - 1],
 | |
|                                 nextEvent:
 | |
|                                     i >= 2 ? timeline.events[i - 2] : null);
 | |
|                       });
 | |
|                 },
 | |
|               ),
 | |
|             ),
 | |
|             room.canSendDefaultMessages && room.membership == Membership.join
 | |
|                 ? Container(
 | |
|                     decoration: BoxDecoration(
 | |
|                       color: Colors.white,
 | |
|                       boxShadow: [
 | |
|                         BoxShadow(
 | |
|                           color: Colors.grey.withOpacity(0.2),
 | |
|                           spreadRadius: 1,
 | |
|                           blurRadius: 2,
 | |
|                           offset: Offset(0, -1), // changes position of shadow
 | |
|                         ),
 | |
|                       ],
 | |
|                     ),
 | |
|                     child: Row(
 | |
|                       crossAxisAlignment: CrossAxisAlignment.center,
 | |
|                       children: <Widget>[
 | |
|                         kIsWeb
 | |
|                             ? Container()
 | |
|                             : PopupMenuButton<String>(
 | |
|                                 icon: Icon(Icons.add),
 | |
|                                 onSelected: (String choice) async {
 | |
|                                   if (choice == "file") {
 | |
|                                     sendFileAction(context);
 | |
|                                   } else if (choice == "image") {
 | |
|                                     sendImageAction(context);
 | |
|                                   }
 | |
|                                   if (choice == "camera") {
 | |
|                                     openCameraAction(context);
 | |
|                                   }
 | |
|                                 },
 | |
|                                 itemBuilder: (BuildContext context) =>
 | |
|                                     <PopupMenuEntry<String>>[
 | |
|                                   PopupMenuItem<String>(
 | |
|                                     value: "file",
 | |
|                                     child: ListTile(
 | |
|                                       leading: CircleAvatar(
 | |
|                                         backgroundColor: Colors.green,
 | |
|                                         foregroundColor: Colors.white,
 | |
|                                         child: Icon(Icons.attachment),
 | |
|                                       ),
 | |
|                                       title: Text(I18n.of(context).sendFile),
 | |
|                                       contentPadding: EdgeInsets.all(0),
 | |
|                                     ),
 | |
|                                   ),
 | |
|                                   PopupMenuItem<String>(
 | |
|                                     value: "image",
 | |
|                                     child: ListTile(
 | |
|                                       leading: CircleAvatar(
 | |
|                                         backgroundColor: Colors.blue,
 | |
|                                         foregroundColor: Colors.white,
 | |
|                                         child: Icon(Icons.image),
 | |
|                                       ),
 | |
|                                       title: Text(I18n.of(context).sendImage),
 | |
|                                       contentPadding: EdgeInsets.all(0),
 | |
|                                     ),
 | |
|                                   ),
 | |
|                                   PopupMenuItem<String>(
 | |
|                                     value: "camera",
 | |
|                                     child: ListTile(
 | |
|                                       leading: CircleAvatar(
 | |
|                                         backgroundColor: Colors.purple,
 | |
|                                         foregroundColor: Colors.white,
 | |
|                                         child: Icon(Icons.camera),
 | |
|                                       ),
 | |
|                                       title: Text(I18n.of(context).openCamera),
 | |
|                                       contentPadding: EdgeInsets.all(0),
 | |
|                                     ),
 | |
|                                   ),
 | |
|                                 ],
 | |
|                               ),
 | |
|                         SizedBox(width: 8),
 | |
|                         Expanded(
 | |
|                             child: Padding(
 | |
|                           padding: const EdgeInsets.symmetric(vertical: 4.0),
 | |
|                           child: TextField(
 | |
|                             minLines: 1,
 | |
|                             maxLines: kIsWeb ? 1 : 8,
 | |
|                             keyboardType: kIsWeb
 | |
|                                 ? TextInputType.text
 | |
|                                 : TextInputType.multiline,
 | |
|                             onSubmitted: (String text) {
 | |
|                               send();
 | |
|                               FocusScope.of(context).requestFocus(inputFocus);
 | |
|                             },
 | |
|                             focusNode: inputFocus,
 | |
|                             controller: sendController,
 | |
|                             decoration: InputDecoration(
 | |
|                               hintText: I18n.of(context).writeAMessage,
 | |
|                               border: InputBorder.none,
 | |
|                             ),
 | |
|                             onChanged: (String text) {
 | |
|                               this.typingCoolDown?.cancel();
 | |
|                               this.typingCoolDown =
 | |
|                                   Timer(Duration(seconds: 2), () {
 | |
|                                 this.typingCoolDown = null;
 | |
|                                 this.currentlyTyping = false;
 | |
|                                 room.sendTypingInfo(false);
 | |
|                               });
 | |
|                               this.typingTimeout ??=
 | |
|                                   Timer(Duration(seconds: 30), () {
 | |
|                                 this.typingTimeout = null;
 | |
|                                 this.currentlyTyping = false;
 | |
|                               });
 | |
|                               if (!this.currentlyTyping) {
 | |
|                                 this.currentlyTyping = true;
 | |
|                                 room.sendTypingInfo(true,
 | |
|                                     timeout:
 | |
|                                         Duration(seconds: 30).inMilliseconds);
 | |
|                               }
 | |
|                             },
 | |
|                           ),
 | |
|                         )),
 | |
|                         SizedBox(width: 8),
 | |
|                         if (sendController.text.isEmpty)
 | |
|                           IconButton(
 | |
|                             icon: Icon(
 | |
|                                 room.encrypted ? Icons.lock : Icons.lock_open),
 | |
|                             onPressed: () => Navigator.of(context).push(
 | |
|                               AppRoute.defaultRoute(
 | |
|                                 context,
 | |
|                                 ChatEncryptionSettingsView(widget.id),
 | |
|                               ),
 | |
|                             ),
 | |
|                           ),
 | |
|                         IconButton(
 | |
|                           icon: Icon(Icons.send),
 | |
|                           onPressed: () => send(),
 | |
|                         ),
 | |
|                       ],
 | |
|                     ),
 | |
|                   )
 | |
|                 : Container(),
 | |
|           ],
 | |
|         ),
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| }
 | 
