import 'package:comunic/helpers/groups_helper.dart'; import 'package:comunic/helpers/serialization/conversation_message_serialization_helper.dart'; import 'package:comunic/helpers/serialization/conversations_serialization_helper.dart'; import 'package:comunic/helpers/users_helper.dart'; import 'package:comunic/helpers/websocket_helper.dart'; import 'package:comunic/lists/conversation_messages_list.dart'; import 'package:comunic/lists/conversations_list.dart'; import 'package:comunic/lists/unread_conversations_list.dart'; import 'package:comunic/lists/users_list.dart'; import 'package:comunic/models/api_request.dart'; import 'package:comunic/models/api_response.dart'; import 'package:comunic/models/conversation.dart'; import 'package:comunic/models/conversation_member.dart'; import 'package:comunic/models/conversation_message.dart'; import 'package:comunic/models/displayed_content.dart'; import 'package:comunic/models/new_conversation.dart'; import 'package:comunic/models/new_conversation_message.dart'; import 'package:comunic/models/new_conversation_settings.dart'; import 'package:comunic/models/unread_conversation.dart'; import 'package:comunic/utils/account_utils.dart'; import 'package:comunic/utils/color_utils.dart'; import 'package:comunic/utils/dart_color.dart'; import 'package:dio/dio.dart'; /// Conversation helper /// /// @author Pierre HUBERT enum SendMessageResult { SUCCESS, MESSAGE_REJECTED, FAILED } class ConversationsHelper { static final _registeredConversations = Map(); /// Create a new conversation /// /// Return the ID of the newly created conversation /// /// Throws in case of failure static Future createConversation(NewConversation settings) async { final response = await APIRequest.withLogin("conversations/create", args: { "name": settings.name, "follow": settings.follow ? "true" : "false", "users": settings.members.join(","), "color": colorToHex(settings.color) }) .addBool("canEveryoneAddMembers", settings.canEveryoneAddMembers) .execWithThrow(); return response.getObject()["conversationID"]; } /// Add a member to a conversation. /// /// Throws in case of failure static Future addMember(int? convID, int? userID) async => await APIRequest.withLogin("conversations/addMember") .addInt("convID", convID) .addInt("userID", userID) .execWithThrow(); /// Remove a member from a conversation. /// /// Throws in case of failure static Future removeMember(int? convID, int? userID) async => await APIRequest.withLogin("conversations/removeMember") .addInt("convID", convID) .addInt("userID", userID) .execWithThrow(); /// Update admin status of a user in a conversation /// /// Throws in case of failure static Future setAdmin(int convID, int userID, bool admin) async => await APIRequest.withLogin("conversations/setAdmin") .addInt("convID", convID) .addInt("userID", userID) .addBool("setAdmin", admin) .execWithThrow(); /// Update an existing conversation /// /// Throws in case of failure static Future updateConversation( NewConversationsSettings settings) async { final request = APIRequest.withLogin("conversations/updateSettings") .addInt("conversationID", settings.convID) .addBool("following", settings.following); // Update conversation settings if (settings.isComplete) request .addString("name", settings.name) .addBool("canEveryoneAddMembers", settings.canEveryoneAddMembers!) .addString("color", colorToHex(settings.color)); await request.execWithThrow(); // Delete old conversation entry from the database await ConversationsSerializationHelper() .removeElement((t) => t.id == settings.convID); } /// Set a new conversation logo /// /// Throws in case of failure static Future changeImage(int? convID, BytesFile file) async => await APIRequest.withLogin("conversations/change_image") .addInt("convID", convID) .addBytesFile("file", file) .execWithFilesAndThrow(); /// Remove conversation logo /// /// Throws in case of failure static Future removeLogo(int? convID) async => await APIRequest.withLogin("conversations/delete_image") .addInt("convID", convID) .execWithThrow(); /// Delete a conversation specified by its [id] Future deleteConversation(int? id) async => await APIRequest.withLogin("conversations/delete") .addInt("conversationID", id) .execWithThrow(); /// Download the list of conversations from the server /// /// Throws an exception in case of failure Future downloadList() async { final response = await APIRequest.withLogin("conversations/getList").execWithThrow(); ConversationsList list = ConversationsList(); response.getArray()!.forEach((f) => list.add(apiToConversation(f))); // Update the database await ConversationsSerializationHelper().setList(list); return list; } /// Get the local list of conversations Future getCachedList() async { final list = await ConversationsSerializationHelper().getList(); list.sort(); return list; } /// Get information about a single conversation specified by its [id] Future _downloadSingle(int? id) async { final response = await APIRequest( uri: "conversations/get_single", needLogin: true, args: {"conversationID": id.toString()}).execWithThrow(); final conversation = apiToConversation(response.getObject()); await ConversationsSerializationHelper() .insertOrReplaceElement((c) => c.id == conversation.id, conversation); return conversation; } /// Get information about a conversation. If [force] is set to false, a /// cached version of the conversation will be used, else it will always get /// the information from the server. The method throws an [Exception] in /// case of failure /// /// Return value of this method is never null. Future getSingle(int? id, {bool force = false}) async { if (force || !await ConversationsSerializationHelper().any((c) => c.id == id)) return await _downloadSingle(id); else return await ConversationsSerializationHelper().get(id); } /// Get the name of a [conversation]. This requires information /// about the users of this conversation static String getConversationName( Conversation conversation, UsersList? users) { if (conversation.hasName) return conversation.name!; String name = ""; int count = 0; for (int i = 0; i < 3 && i < conversation.members!.length; i++) if (conversation.members![i].userID != userID()) { name += (count > 0 ? ", " : "") + users!.getUser(conversation.members![i].userID).fullName; count++; } if (conversation.members!.length > 3) name += ", ..."; return name; } /// Search and return a private conversation with a given [userID]. If such /// conversation does not exists, it is created if [allowCreate] is set to /// true /// /// Throws an exception in case of failure Future getPrivate(int? userID, {bool allowCreate = true}) async { final response = await APIRequest( uri: "conversations/getPrivate", needLogin: true, args: { "otherUser": userID.toString(), "allowCreate": allowCreate.toString() }, ).execWithThrow(); // Get and return conversation ID return int.parse(response.getObject()["conversationsID"][0].toString()); } /// Asynchronously get the name of the conversation /// /// Unlike the synchronous method, this method does not need information /// about the members of the conversation /// /// Throws an exception in case of failure static Future getConversationNameAsync( Conversation conversation) async { if (conversation.hasName) return conversation.name!; //Get information about the members of the conversation final members = await UsersHelper().getList(conversation.membersID); return ConversationsHelper.getConversationName(conversation, members); } /// Turn an API entry into a [Conversation] object static Conversation apiToConversation(Map map) { return Conversation( id: map["id"], lastActivity: map["last_activity"], name: map["name"], color: map["color"] == null ? null : HexColor(map["color"]), logoURL: map["logo"], groupID: map["group_id"], groupMinMembershipLevel: APIGroupsMembershipLevelsMap[map["group_min_membership_level"]], members: map["members"] .cast>() .map(apiToConversationMember) .toList() .cast(), canEveryoneAddMembers: map["can_everyone_add_members"], callCapabilities: map["can_have_video_call"] ? CallCapabilities.VIDEO : (map["can_have_call"] ? CallCapabilities.AUDIO : CallCapabilities.NONE), isHavingCall: map["has_call_now"]); } static ConversationMember apiToConversationMember(Map map) => ConversationMember( userID: map["user_id"], lastMessageSeen: map["last_message_seen"], lastAccessTime: map["last_access"], following: map["following"], isAdmin: map["is_admin"], ); /// Parse a list of messages given by the server /// /// Throws an exception in case of failure Future _parseConversationMessageFromServer( int conversationID, APIResponse response) async { response.assertOk(); // Parse the response of the server ConversationMessagesList list = ConversationMessagesList(); response.getArray()!.forEach((f) { list.add( apiToConversationMessage(f), ); }); // Save messages in the cache await ConversationsMessagesSerializationHelper(conversationID) .insertOrReplaceAll(list); return list; } /// Refresh the list of messages of a conversation /// /// Set [lastMessageID] to 0 to specify that we do not have any message of the /// conversation yet or another value else /// /// Throws an exception in case of failure Future _downloadNewMessagesSingle( int conversationID, {int? lastMessageID = 0}) async { // Execute the request on the server final response = await APIRequest( uri: "conversations/refresh_single", needLogin: true, args: { "conversationID": conversationID.toString(), "last_message_id": lastMessageID.toString() }).execWithThrow(); return await _parseConversationMessageFromServer(conversationID, response); } /// Get older messages for a given conversation from an online source /// /// Throws in case of failure Future getOlderMessages({ required int conversationID, required int? oldestMessagesID, int limit = 15, }) async { // Perform the request online final response = await APIRequest.withLogin("conversations/get_older_messages", args: { "conversationID": conversationID.toString(), "oldest_message_id": oldestMessagesID.toString(), "limit": limit.toString() }).execWithThrow(); return await _parseConversationMessageFromServer(conversationID, response); } /// Get new messages for a given conversation /// /// If [lastMessageID] is set to 0 then we retrieve the last messages of /// the conversation. /// Otherwise [lastMessageID] contains the ID of the last known message /// /// Throws in case of failure Future getNewMessages( {required int conversationID, int? lastMessageID = 0, bool online = true}) async { if (online) return await _downloadNewMessagesSingle(conversationID, lastMessageID: lastMessageID); else return await ConversationsMessagesSerializationHelper(conversationID) .getList(); } /// Send a new message to the server Future sendMessage( NewConversationMessage message, { ProgressCallback? sendProgress, CancelToken? cancelToken, }) async { final request = APIRequest.withLogin("conversations/sendMessage") .addInt("conversationID", message.conversationID) .addString("message", message.hasMessage ? message.message : ""); request.progressCallback = sendProgress; request.cancelToken = cancelToken; // Check for file if (message.hasFile) request.addBytesFile("file", message.file); if (message.hasThumbnail) request.addBytesFile("thumbnail", message.thumbnail); //Send the message APIResponse response; if (!message.hasFile) response = await request.exec(); else response = await request.execWithFiles(); if (response.code == 401) return SendMessageResult.MESSAGE_REJECTED; else if (response.code != 200) return SendMessageResult.FAILED; return SendMessageResult.SUCCESS; } /// Save / Update a message into the database Future saveMessage(ConversationMessage msg) async => await ConversationsMessagesSerializationHelper(msg.convID) .insertOrReplace(msg); /// Remove a message from the database Future removeMessage(ConversationMessage msg) async => await ConversationsMessagesSerializationHelper(msg.convID).remove(msg); /// Update a message content Future updateMessage(int? id, String newContent) async { final response = await APIRequest( uri: "conversations/updateMessage", needLogin: true, args: {"messageID": id.toString(), "content": newContent}).exec(); if (response.code != 200) return false; return true; } /// Delete permanently a message specified by its [id] Future deleteMessage(int? id) async { // Delete the message online final response = await APIRequest( uri: "conversations/deleteMessage", needLogin: true, args: {"messageID": id.toString()}).exec(); if (response.code != 200) return false; return true; } /// Get the list of unread conversations /// /// Throws in case of failure static Future getListUnread() async { final list = (await APIRequest.withLogin("conversations/get_list_unread") .execWithThrow()) .getArray()!; return UnreadConversationsList() ..addAll(list.map((f) => UnreadConversation( conv: apiToConversation(f["conv"]), message: apiToConversationMessage(f["message"]), ))); } /// Register a conversation : ask the server to notify about updates to the /// conversation through WebSocket Future registerConversationEvents(int id) async { if (_registeredConversations.containsKey(id)) _registeredConversations.update(id, (value) => value + 1); else { _registeredConversations[id] = 1; await ws("\$main/register_conv", {"convID": id}); } } /// Un-register to conversation update events Future unregisterConversationEvents(int id) async { if (!_registeredConversations.containsKey(id)) return; _registeredConversations.update(id, (value) => value - 1); if (_registeredConversations[id]! <= 0) { _registeredConversations.remove(id); await ws("\$main/unregister_conv", {"convID": id}); } } /// Send a notification to inform that the user is writing a message static Future sendWritingEvent(int? convID) async => await ws("conversations/is_writing", {"convID": convID}); /// Turn an API response into a ConversationMessage object static ConversationMessage apiToConversationMessage( Map map, ) { var file; if (map["file"] != null) { final fileMap = map["file"]; file = ConversationMessageFile( url: fileMap["url"], size: fileMap["size"], name: fileMap["name"], thumbnail: fileMap["thumbnail"], type: fileMap["type"], ); } var serverMessage; if (map["server_message"] != null) { final srvMessageMap = map["server_message"]; var messageType; switch (srvMessageMap["type"]) { case "user_created_conv": messageType = ConversationServerMessageType.USER_CREATED_CONVERSATION; break; case "user_added_another": messageType = ConversationServerMessageType.USER_ADDED_ANOTHER_USER; break; case "user_left": messageType = ConversationServerMessageType.USER_LEFT_CONV; break; case "user_removed_another": messageType = ConversationServerMessageType.USER_REMOVED_ANOTHER_USER; break; default: throw Exception( "${srvMessageMap["type"]} is an unknown server message type!"); } serverMessage = ConversationServerMessage( type: messageType, userID: srvMessageMap["user_id"], userWhoAdded: srvMessageMap["user_who_added"], userAdded: srvMessageMap["user_added"], userWhoRemoved: srvMessageMap["user_who_removed"], userRemoved: srvMessageMap["user_removed"], ); } return ConversationMessage( id: map["id"], convID: map["conv_id"], userID: map["user_id"], timeSent: map["time_sent"], message: DisplayedString(map["message"] ?? ""), file: file, serverMessage: serverMessage); } }