#include "../../plugins/Matrix.hpp" #include "../../include/Storage.hpp" #include "../../include/StringUtils.hpp" #include "../../include/NetUtils.hpp" #include "../../include/Notification.hpp" #include "../../include/Program.hpp" #include "../../external/cppcodec/base64_url.hpp" #include "../../include/Json.hpp" #include "../../include/AsyncImageLoader.hpp" #include "../../include/Config.hpp" #include "../../include/Theme.hpp" #include "../../include/Scale.hpp" #include #include #include #include #include #include #include #include #include "../../include/QuickMedia.hpp" #include // TODO: Use string assign with string length instead of assigning to c string (which calls strlen) // Show images/videos inline. // TODO: Verify if buffer of size 512 is enough for endpoints // Remove older messages (outside screen) to save memory. Reload them when the selected body item is the top/bottom one. // TODO: Custom account data should be in im.qm.*, not qm.* constexpr int64_t timestamp_provisional_event = 16755030000LL * 1000LL; namespace QuickMedia { static const mgl::vec2i thumbnail_max_size(600, 337); static const mgl::vec2i custom_emoji_max_size(32, 32); static const char* SERVICE_NAME = "matrix"; static const char* OTHERS_ROOM_TAG = "tld.name.others"; // Filter without account data. TODO: We include pinned events but limit events to 1. That means if the last event is a pin, // then we cant see room message preview. TODO: Fix this somehow. // TODO: What about state events in initial sync in timeline? such as user display name change. static const char* INITIAL_FILTER = "{\"presence\":{\"limit\":0,\"types\":[\"\"]},\"account_data\":{\"types\":[\"qm.emoji\",\"m.direct\"]},\"room\":{\"state\":{\"not_types\":[\"m.room.related_groups\",\"m.room.power_levels\",\"m.room.join_rules\",\"m.room.history_visibility\",\"m.room.canonical_alias\",\"m.space.child\"],\"lazy_load_members\":true},\"timeline\":{\"types\":[\"m.room.message\"],\"limit\":1,\"lazy_load_members\":true},\"ephemeral\":{\"limit\":0,\"types\":[\"\"],\"lazy_load_members\":true},\"account_data\":{\"limit\":1,\"types\":[\"m.fully_read\",\"m.tag\",\"qm.last_read_message_timestamp\"],\"lazy_load_members\":true}}}"; static const char* CONTINUE_FILTER = "{\"presence\":{\"limit\":0,\"types\":[\"\"]},\"account_data\":{\"types\":[\"qm.emoji\",\"m.direct\"]},\"room\":{\"state\":{\"not_types\":[\"m.room.related_groups\",\"m.room.power_levels\",\"m.room.join_rules\",\"m.room.history_visibility\",\"m.room.canonical_alias\",\"m.space.child\"],\"lazy_load_members\":true},\"timeline\":{\"lazy_load_members\":true},\"ephemeral\":{\"limit\":0,\"types\":[\"\"],\"lazy_load_members\":true},\"account_data\":{\"types\":[\"m.fully_read\",\"m.tag\",\"qm.last_read_message_timestamp\"],\"lazy_load_members\":true}}}"; static bool is_gpg_installed = false; static bool gpg_installed_checked = false; static std::string matrix_decrypt_gpg_message_if_needed(std::string message, bool &success) { success = false; if(!gpg_installed_checked) { gpg_installed_checked = true; is_gpg_installed = is_program_executable_by_name("gpg"); } std::string result; if(!is_gpg_installed || get_config().matrix.gpg_user_id.empty()) { result = std::move(message); } else { size_t pgp_begin_index; size_t pgp_end_index; if((pgp_begin_index = message.find("-----BEGIN PGP MESSAGE-----")) != std::string::npos && (pgp_end_index = message.find("-----END PGP MESSAGE-----", pgp_begin_index + 27)) != std::string::npos) { std::string decrypted_string; const char *args[] = { "gpg", "-d", nullptr }; const std::string_view pgp_message(message.data() + pgp_begin_index, (pgp_end_index + 25) - pgp_begin_index); if(exec_program_write_stdin(args, pgp_message.data(), pgp_message.size(), accumulate_string, &decrypted_string) != 0) { result = "🔒 Failed to decrypt message:\n" + std::move(message); } else { decrypted_string.insert(0, "🔒 ", strlen("🔒 ")); result = std::move(message); result.replace(result.begin() + pgp_begin_index, result.begin() + pgp_begin_index + pgp_message.size(), std::move(decrypted_string)); success = true; } } else { result = std::move(message); } } return result; } enum class GpgLineType { UID, FPR, SUB, UNKNOWN }; static std::string user_id_to_email_format(const std::string_view user_id) { if(user_id.empty() || user_id[0] != '@') return ""; size_t colon_index = user_id.find(':'); if(colon_index == std::string::npos) return ""; return std::string(user_id.substr(1, colon_index - 1)) + "@" + std::string(user_id.substr(colon_index + 1)); } static std::string_view gpg_display_name_extract_email(const std::string_view display_name) { if(display_name.empty() || display_name.back() != '>') return std::string_view(); size_t email_start_index = display_name.rfind('<'); if(email_start_index == std::string::npos) return std::string_view(); email_start_index += 1; return display_name.substr(email_start_index, display_name.size() - 1 - email_start_index); } static bool for_each_user_with_matching_gpg_key(const std::vector> &users, std::function callback) { std::string output; const char *args[] = { "gpg", "--list-keys", "--with-colons", nullptr }; if(exec_program(args, accumulate_string, &output) != 0) return false; std::unordered_set users_by_email; for(auto &user : users) { std::string user_email = user_id_to_email_format(user->user_id); if(!user_email.empty()) users_by_email.insert(std::move(user_email)); } std::string_view fpr; std::string_view name_and_email; string_split(output, '\n', [&](const char *str, size_t size) { GpgLineType line_type = GpgLineType::UNKNOWN; int column = 0; bool invalid_signature = false; string_split_view(std::string_view(str, size), ':', [&](const char *str, size_t size) { std::string_view section(str, size); if(column == 0) { if(section == "uid") line_type = GpgLineType::UID; else if(section == "fpr") line_type = GpgLineType::FPR; else if(section == "sub") line_type = GpgLineType::SUB; } else if(column == 1) { if(line_type == GpgLineType::UID || line_type == GpgLineType::SUB) invalid_signature |= (section == "e" || section == "r"); // Expired or revoked if(line_type == GpgLineType::SUB && !invalid_signature) { const std::string user_email = std::string(gpg_display_name_extract_email(name_and_email)); if(!user_email.empty() && users_by_email.find(user_email) != users_by_email.end()) { fprintf(stderr, "found public key %.*s for user %s\n", (int)fpr.size(), fpr.data(), user_email.c_str()); callback(fpr); } } } else if(column == 9) { if(line_type == GpgLineType::UID) name_and_email = section; else if(line_type == GpgLineType::FPR) fpr = section; } ++column; return true; }); return true; }); return true; } bool matrix_gpg_encrypt_for_each_user_in_room(Matrix *matrix, RoomData *room, const std::string &my_gpg_user_id, const std::string &str, std::string &encrypted_str) { auto me = matrix->get_me(room); if(!me) { fprintf(stderr, "Error: matrix->get_me failed\n"); return false; } std::vector user_public_keys; const bool found_users = for_each_user_with_matching_gpg_key(room->get_users_excluding_me(me->user_id), [&](std::string_view pub_key) { user_public_keys.emplace_back(pub_key); }); if(!found_users) { fprintf(stderr, "Error: gpg --list-keys failed\n"); return false; } std::vector args = { "gpg", "-e", "-r", my_gpg_user_id.c_str(), "-u", my_gpg_user_id.c_str(), "--armor", "--trust-model", "always", "--comment", "This message requires you to use QuickMedia to view it and the user that sent it needs to have your key imported in gpg" }; for(const std::string &pub_key : user_public_keys) { args.push_back("-r"); args.push_back(pub_key.c_str()); } args.push_back(nullptr); return exec_program_write_stdin(args.data(), str.c_str(), str.size(), accumulate_string, &encrypted_str) == 0; } static std::string capitalize(const std::string &str) { if(str.size() >= 1) return to_upper(str[0]) + str.substr(1); else return ""; } // TODO: According to spec: "Any tag in the tld.name.* form but not matching the namespace of the current client should be ignored", // should we follow this? static std::string tag_get_name(const std::string &tag) { if(tag.size() >= 2 && memcmp(tag.data(), "m.", 2) == 0) { if(strcmp(tag.c_str() + 2, "favourite") == 0) return "Favorites"; else if(strcmp(tag.c_str() + 2, "direct") == 0) return "Direct messages"; else if(strcmp(tag.c_str() + 2, "lowpriority") == 0) return "Low priority"; else if(strcmp(tag.c_str() + 2, "server_notice") == 0) return "Server notice"; else return capitalize(tag.substr(2)); } else if(tag.size() >= 2 && memcmp(tag.data(), "u.", 2) == 0) { return capitalize(tag.substr(2)); } else if(tag.size() >= 9 && memcmp(tag.data(), "tld.name.", 9) == 0) { return capitalize(tag.substr(9)); } else { return ""; } } std::string extract_first_line_remove_newline_elipses(const std::string &str, size_t max_length) { std::string result = str; string_replace_all(result, '\n', ' '); string_replace_all(result, '\t', ' '); string_replace_all(result, '\v', ' '); string_replace_all(result, '\r', ' '); size_t index = result.find('\n'); if(index == std::string::npos) { if(result.size() > max_length) return result.substr(0, max_length) + "..."; return result; } else if(index == 0) { return ""; } else { return result.substr(0, std::min(index, max_length)) + "..."; } } static bool remove_body_item_by_url(BodyItems &body_items, const std::string &url) { for(auto it = body_items.begin(); it != body_items.end(); ++it) { if((*it)->url == url) { body_items.erase(it); return true; } } return false; } static int color_hash_code(const std::string &str) { int hash = 0; if(str.empty()) return hash; for (char chr : str) { hash = ((hash << 5) - hash) + (unsigned char)chr; } return std::abs(hash); } static bool generate_random_string_readable(char *buffer, int buffer_size) { return generate_random_characters(buffer, buffer_size, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", 62); } mgl::Color user_id_to_color(const std::string &user_id) { const int num_colors = 8; const mgl::Color colors[num_colors] = { mgl::Color(54, 139, 214), mgl::Color(172, 59, 168), mgl::Color(3, 179, 129), mgl::Color(230, 79, 122), mgl::Color(255, 129, 45), mgl::Color(45, 194, 197), mgl::Color(92, 86, 245), mgl::Color(116, 209, 44) }; return colors[color_hash_code(user_id) % num_colors]; } static std::string formatted_text_remove_reply(const std::string &str) { size_t last_mx_reply_end = str.rfind(""); if(last_mx_reply_end == std::string::npos) return str; else return str.substr(last_mx_reply_end + 11); } static std::string remove_reply_formatting(Matrix *matrix, const std::string &str) { if(strncmp(str.c_str(), "> <@", 4) == 0) { size_t index = str.find("> ", 4); if(index != std::string::npos) { size_t msg_begin = str.find("\n\n", index + 2); if(msg_begin != std::string::npos) return str.substr(msg_begin + 2); } } else { return formatted_text_to_qm_text(matrix, str.c_str(), str.size(), false); } return str; } std::string remove_reply_formatting(Matrix *matrix, const Message *message, bool keep_formatted) { if(!message->body_is_formatted && strncmp(message->body.c_str(), "> <@", 4) == 0) { size_t index = message->body.find("> ", 4); if(index != std::string::npos) { size_t msg_begin = message->body.find("\n\n", index + 2); if(msg_begin != std::string::npos) return message->body.substr(msg_begin + 2); } return message->body; } else { if(keep_formatted) return formatted_text_remove_reply(message->body); else return formatted_text_to_qm_text(matrix, message->body.c_str(), message->body.size(), false); } } MatrixChatBodyItemData::~MatrixChatBodyItemData() { if(decrypt_job) decrypt_job->cancel = true; } void MatrixChatBodyItemData::draw_overlay(mgl::Window&, const Widgets &widgets) { switch(decrypt_state) { case DecryptState::NOT_DECRYPTED: { decrypt_state = DecryptState::DECRYPTING; decrypt_job = std::make_shared(); decrypt_job->text = text_to_decrypt; decrypt_job->decrypt_state = MatrixChatBodyDecryptJob::DecryptState::DECRYPTING; matrix->async_decrypt_message(decrypt_job); break; } case DecryptState::DECRYPTING: { if(decrypt_job->decrypt_state == MatrixChatBodyDecryptJob::DecryptState::DECRYPTED || decrypt_job->decrypt_state == MatrixChatBodyDecryptJob::DecryptState::FAILED_TO_DECRYPT) { decrypt_state = DecryptState::DECRYPTED; if(!room || room->gpg_decrypt_message_id == gpg_decrypt_message_id) widgets.body_item->set_description(std::move(decrypt_job->text)); decrypt_job.reset(); } break; } case DecryptState::DECRYPTED: { break; } } } bool TimestampedDisplayData::set_data_if_newer(std::string new_data, time_t new_timestamp) { if(new_timestamp == 0) { data = std::move(new_data); return true; } if(new_timestamp < timestamp) return false; data = std::move(new_data); timestamp = new_timestamp; return true; } UserInfo::UserInfo(RoomData *room, std::string user_id) : room(room), display_name_color(user_id_to_color(user_id)), user_id(user_id) { display_name.set_data_if_newer(user_id, 0); } UserInfo::UserInfo(RoomData *room, std::string _user_id, std::string _display_name, std::string _avatar_url, time_t update_timestamp_ms) : room(room), display_name_color(user_id_to_color(user_id)), user_id(std::move(_user_id)) { display_name.set_data_if_newer(std::move(_display_name), update_timestamp_ms); avatar_url.set_data_if_newer(std::move(_avatar_url), update_timestamp_ms); } // TODO: Remove this when images are embedded inside the text instead of using the same space as the author bool is_visual_media_message_type(MessageType message_type) { return message_type == MessageType::VIDEO || message_type == MessageType::IMAGE; } bool is_system_message_type(MessageType message_type) { return message_type >= MessageType::MEMBERSHIP && message_type <= MessageType::SYSTEM; } std::shared_ptr RoomData::get_user_by_id(const std::string &user_id) { std::lock_guard lock(room_mutex); auto user_it = user_info_by_user_id.find(user_id); if(user_it == user_info_by_user_id.end()) return nullptr; return user_it->second; } void RoomData::add_user(std::shared_ptr user) { std::lock_guard lock(room_mutex); user_info_by_user_id.insert(std::make_pair(user->user_id, user)); } void RoomData::set_user_read_marker(std::shared_ptr &user, const std::string &event_id) { std::lock_guard lock(user_mutex); user->read_marker_event_id = event_id; } std::string RoomData::get_user_read_marker(const std::shared_ptr &user) { std::lock_guard lock(user_mutex); return user->read_marker_event_id; } std::string RoomData::get_user_display_name(const std::shared_ptr &user) { std::lock_guard lock(user_mutex); return user->display_name.data; } std::string RoomData::get_user_avatar_url(const std::shared_ptr &user) { std::lock_guard lock(user_mutex); return user->avatar_url.data; } bool RoomData::set_user_display_name(std::shared_ptr &user, std::string display_name, time_t update_timestamp_ms) { std::lock_guard lock(user_mutex); if(display_name.empty()) { user->display_name.data = user->user_id; return true; } else { return user->display_name.set_data_if_newer(std::move(display_name), update_timestamp_ms); } } bool RoomData::set_user_avatar_url(std::shared_ptr &user, std::string avatar_url, time_t update_timestamp_ms) { std::lock_guard lock(user_mutex); return user->avatar_url.set_data_if_newer(std::move(avatar_url), update_timestamp_ms); } size_t RoomData::prepend_messages_reverse(const Messages &new_messages) { std::lock_guard lock(room_mutex); int64_t last_new_message_timestamp = last_message_timestamp; size_t num_new_messages = 0; for(auto it = new_messages.begin(); it != new_messages.end(); ++it) { if((*it)->event_id.empty()) { messages.insert(messages.begin(), std::move(*it)); ++num_new_messages; } else if(message_by_event_id.find((*it)->event_id) == message_by_event_id.end()) { if(message_is_timeline((*it).get())) last_new_message_timestamp = std::max(last_new_message_timestamp, (*it)->timestamp); message_by_event_id.insert(std::make_pair((*it)->event_id, *it)); messages.insert(messages.begin(), std::move(*it)); ++num_new_messages; } } last_message_timestamp = last_new_message_timestamp; return num_new_messages; } size_t RoomData::append_messages(const Messages &new_messages) { std::lock_guard lock(room_mutex); int64_t last_new_message_timestamp = last_message_timestamp; size_t num_new_messages = 0; for(auto it = new_messages.begin(); it != new_messages.end(); ++it) { if((*it)->event_id.empty()) { messages.push_back(std::move(*it)); ++num_new_messages; } else if(message_by_event_id.find((*it)->event_id) == message_by_event_id.end()) { if(message_is_timeline((*it).get())) last_new_message_timestamp = std::max(last_new_message_timestamp, (*it)->timestamp); message_by_event_id.insert(std::make_pair((*it)->event_id, *it)); messages.push_back(std::move(*it)); ++num_new_messages; } } last_message_timestamp = last_new_message_timestamp; return num_new_messages; } std::shared_ptr RoomData::get_message_by_id(const std::string &id) { std::lock_guard lock(room_mutex); auto message_it = message_by_event_id.find(id); if(message_it == message_by_event_id.end()) return nullptr; return message_it->second; } std::vector> RoomData::get_users() { std::lock_guard lock(user_mutex); std::vector> users(user_info_by_user_id.size()); size_t i = 0; for(auto &[user_id, user] : user_info_by_user_id) { users[i++] = user; } return users; } std::vector> RoomData::get_users_excluding_me(const std::string &my_user_id) { std::lock_guard lock(user_mutex); std::vector> users_excluding_me; // TODO: Optimize for(auto &[user_id, user] : user_info_by_user_id) { if(user->user_id != my_user_id) { users_excluding_me.push_back(user); } } return users_excluding_me; } void RoomData::acquire_room_lock() { room_mutex.lock(); } void RoomData::release_room_lock() { room_mutex.unlock(); } const Messages& RoomData::get_messages_thread_unsafe() const { return messages; } void RoomData::clear_messages() { std::lock_guard lock(room_mutex); messages.clear(); message_by_event_id.clear(); messages_read_index = 0; } const std::vector& RoomData::get_pinned_events_thread_unsafe() const { return pinned_events; } bool RoomData::has_prev_batch() { std::lock_guard lock(room_mutex); return !prev_batch.empty(); } void RoomData::set_prev_batch(const std::string &new_prev_batch) { std::lock_guard lock(room_mutex); prev_batch = new_prev_batch; } std::string RoomData::get_prev_batch() { std::lock_guard lock(room_mutex); return prev_batch; } bool RoomData::has_name() { std::lock_guard lock(room_mutex); return !name.data.empty(); } bool RoomData::set_name(const std::string &new_name, time_t update_timestamp_ms) { std::lock_guard lock(room_mutex); return name.set_data_if_newer(new_name, update_timestamp_ms); } std::string RoomData::get_name() { std::lock_guard lock(room_mutex); return name.data; } bool RoomData::set_topic(const std::string &new_topic, time_t update_timestamp_ms) { std::lock_guard lock(room_mutex); return topic.set_data_if_newer(new_topic, update_timestamp_ms); } std::string RoomData::get_topic() { std::lock_guard lock(room_mutex); return topic.data; } bool RoomData::has_avatar_url() { std::lock_guard lock(room_mutex); return !avatar_url.data.empty(); } bool RoomData::set_avatar_url(const std::string &new_avatar_url, time_t update_timestamp_ms) { std::lock_guard lock(room_mutex); return avatar_url.set_data_if_newer(new_avatar_url, update_timestamp_ms); } std::string RoomData::get_avatar_url() { std::lock_guard lock(room_mutex); return avatar_url.data; } void RoomData::set_pinned_events(std::vector new_pinned_events) { std::lock_guard lock(room_mutex); pinned_events = std::move(new_pinned_events); pinned_events_updated = true; } std::set& RoomData::get_tags_thread_unsafe() { return tags; } void RoomData::clear_data() { std::lock_guard room_lock(room_mutex); std::lock_guard user_lock(user_mutex); //fetched_messages_by_event_id.clear(); //userdata = nullptr; //user_info_by_user_id.clear(); //messages.clear(); //messages_read_index = 0; //message_by_event_id.clear(); pinned_events.clear(); tags.clear(); // TODO: Do this? what if its being used in another thread? //body_item.reset(); } MatrixQuickMedia::MatrixQuickMedia(Program *program, Matrix *matrix, MatrixRoomsPage *rooms_page, MatrixRoomTagsPage *room_tags_page, MatrixInvitesPage *invites_page, MatrixNotificationsPage *notifications_page) : program(program), matrix(matrix), chat_page(nullptr), rooms_page(rooms_page), room_tags_page(room_tags_page), invites_page(invites_page), notifications_page(notifications_page) { rooms_page->matrix_delegate = this; room_tags_page->matrix_delegate = this; } void MatrixQuickMedia::join_room(RoomData *room) { if(room_body_item_by_room.find(room) != room_body_item_by_room.end()) return; std::string room_name = room->get_name(); if(room_name.empty()) room_name = room->id; string_replace_all(room_name, '\n', ' '); auto body_item = BodyItem::create(std::move(room_name)); body_item->url = room->id; body_item->thumbnail_url = room->get_avatar_url(); body_item->userdata = room; // Note: this has to be valid as long as the room list is valid! body_item->thumbnail_mask_type = ThumbnailMaskType::CIRCLE; body_item->thumbnail_size = mgl::vec2i(32, 32); room->body_item = body_item; room_body_item_by_room[room] = body_item; rooms_page->add_body_item(body_item); } void MatrixQuickMedia::leave_room(RoomData *room, LeaveType leave_type, const std::string &reason) { room_body_item_by_room.erase(room); rooms_page->remove_body_item_by_room_id(room->id); room_tags_page->remove_body_item_by_room_id(room->id); if(leave_type != LeaveType::LEAVE) show_notification("QuickMedia", reason); } void MatrixQuickMedia::room_add_tag(RoomData *room, const std::string &tag) { auto it = room_body_item_by_room.find(room); if(it == room_body_item_by_room.end()) return; room_tags_page->add_room_body_item_to_tag(it->second, tag); } void MatrixQuickMedia::room_remove_tag(RoomData *room, const std::string &tag) { auto it = room_body_item_by_room.find(room); if(it == room_body_item_by_room.end()) return; room_tags_page->remove_room_body_item_from_tag(it->second, tag); } void MatrixQuickMedia::room_add_new_messages(RoomData *room, const Messages &messages, bool is_initial_sync, MessageDirection message_dir) { bool is_window_focused = program->is_window_focused(); RoomData *current_room = program->get_current_chat_room(); if(message_dir == MessageDirection::AFTER) { for(auto &message : messages) { if(message->notification_mentions_me) { std::string body = remove_reply_formatting(matrix, message->body); bool read = true; bool count_unread = true; // TODO: What if the message or username begins with "-"? also make the notification image be the avatar of the user if((!is_window_focused || room != current_room) && message->related_event_type != RelatedEventType::EDIT && message->related_event_type != RelatedEventType::REDACTION) { if(notifications_shown.insert(message->event_id).second) { show_notification("QuickMedia matrix - " + extract_first_line_remove_newline_elipses(matrix->message_get_author_displayname(message.get()), AUTHOR_MAX_LENGTH) + " (" + room->get_name() + ")", body); } else { count_unread = false; } read = false; } if(count_unread) ++unread_mention_count_by_room[room]; MatrixNotification notification; notification.room = room; notification.event_id = message->event_id; notification.sender_user_id = message->user->user_id; notification.body = std::move(body); notification.timestamp = message->timestamp; notification.read = read; notifications_page->add_notification(std::move(notification)); } } } update_room_description(room, messages, is_initial_sync); } void MatrixQuickMedia::room_clear_messages(RoomData *room) { RoomData *current_room = program->get_current_chat_room(); if(current_room == room && chat_page && chat_page->is_regular_navigation && chat_page->chat_body) { chat_page->chat_body->clear_items(); } } void MatrixQuickMedia::add_invite(const std::string &room_id, const Invite &invite) { std::string invited_by_display_name = extract_first_line_remove_newline_elipses(invite.invited_by->room->get_user_display_name(invite.invited_by), AUTHOR_MAX_LENGTH); auto body_item = BodyItem::create(invite.room_name); body_item->set_description("Invited by " + invited_by_display_name + " (" + invite.invited_by->user_id + ")"); body_item->url = room_id; body_item->thumbnail_url = invite.room_avatar_url; body_item->thumbnail_mask_type = ThumbnailMaskType::CIRCLE; body_item->thumbnail_size = mgl::vec2i(32, 32); body_item->set_timestamp(invite.timestamp); body_item->userdata = body_item.get(); const bool silenced_invite = matrix->is_invite_silenced(room_id, invite.timestamp); if(silenced_invite) body_item->add_reaction("Silenced", nullptr); invites_page->add_body_item(std::move(body_item)); if(!silenced_invite) { show_notification("QuickMedia matrix - " + invite.room_name, "You were invited to " + invite.room_name + " by " + invited_by_display_name + " (" + invite.invited_by->user_id + ")"); } } void MatrixQuickMedia::remove_invite(const std::string &room_id) { invites_page->remove_body_item_by_room_id(room_id); } void MatrixQuickMedia::add_unread_notification(MatrixNotification notification) { if(notifications_shown.insert(notification.event_id).second) { show_notification("QuickMedia matrix - " + notification.sender_user_id + " (" + notification.room->get_name() + ")", notification.body); ++unread_mention_count_by_room[notification.room]; update_room_description(notification.room, {}, false); } } void MatrixQuickMedia::add_user(MatrixEventUserInfo user_info) { auto &users = users_by_room[user_info.room]; const bool new_user = users.insert(std::make_pair(user_info.user_id, user_info)).second; if(!new_user) return; if(chat_page) chat_page->add_user(std::move(user_info)); } void MatrixQuickMedia::remove_user(MatrixEventUserInfo user_info) { auto &users = users_by_room[user_info.room]; if(users.erase(user_info.user_id) == 0) return; if(chat_page) chat_page->remove_user(std::move(user_info)); } void MatrixQuickMedia::set_user_info(MatrixEventUserInfo user_info) { auto &users = users_by_room[user_info.room]; auto it = users.find(user_info.user_id); if(it == users.end()) return; it->second = user_info; if(chat_page) chat_page->set_user_info(std::move(user_info)); } void MatrixQuickMedia::set_room_info(MatrixEventRoomInfo room_info) { auto it = room_body_item_by_room.find(room_info.room); if(it == room_body_item_by_room.end()) return; if(room_info.name) { std::string room_name = room_info.name.value_or(room_info.room->id); string_replace_all(room_name, '\n', ' '); it->second->set_title(std::move(room_name)); //body_item->thumbnail_url = room->get_avatar_url(); } if(room_info.avatar_url) it->second->thumbnail_url = room_info.avatar_url.value(); if(chat_page) chat_page->set_room_info(std::move(room_info)); } void MatrixQuickMedia::for_each_user_in_room(RoomData *room, std::function callback) { auto &users = users_by_room[room]; for(const auto &user : users) { callback(user.second); } } void MatrixQuickMedia::set_room_as_read(RoomData *room) { notifications_page->set_room_as_read(room); int &unread_mention_count = unread_mention_count_by_room[room]; if(unread_mention_count > 0) { unread_mention_count = 0; update_room_description(room, {}, false); } } static void sort_room_body_items(std::vector> &room_body_items) { std::sort(room_body_items.begin(), room_body_items.end(), [](const std::shared_ptr &body_item1, const std::shared_ptr &body_item2) { RoomData *room1 = static_cast(body_item1->userdata); RoomData *room2 = static_cast(body_item2->userdata); int64_t room1_focus_sum = room1->last_message_timestamp; int64_t room2_focus_sum = room2->last_message_timestamp; return room1_focus_sum > room2_focus_sum; }); } static void insert_room_body_item_by_timestamp(Body *body, std::shared_ptr new_body_item) { RoomData *new_room = static_cast(new_body_item->userdata); const int insert_index = body->find_item_index([new_room](std::shared_ptr &body_item) { RoomData *room = static_cast(body_item->userdata); return new_room->last_message_timestamp >= room->last_message_timestamp; }); if(insert_index == -1) body->append_item(std::move(new_body_item)); else body->insert_item(std::move(new_body_item), insert_index); } // TODO: Optimize void body_set_selected_item_by_url(Body *body, const std::string &url) { const int found_item_index = body->find_item_index([&url](std::shared_ptr &body_item) { return body_item->url == url; }); if(found_item_index != -1) body->set_selected_item(found_item_index, false); } static std::shared_ptr get_last_message_by_timestamp(const Messages &messages) { if(messages.empty()) return nullptr; size_t last_message_index = 0; for(size_t i = 1; i < messages.size(); ++i) { if(message_is_timeline(messages[i].get()) && messages[i]->timestamp >= messages[last_message_index]->timestamp) last_message_index = i; } if(message_is_timeline(messages[last_message_index].get())) return messages[last_message_index]; return nullptr; } std::string message_to_qm_text(Matrix *matrix, const Message *message, bool allow_formatted_text, mgl::vec2i image_max_size) { if(message->body_is_formatted) return formatted_text_to_qm_text(matrix, message->body.c_str(), message->body.size(), allow_formatted_text, image_max_size); else return message->body; // TODO: Remove > <@ formatting? } std::string pantalaimon_url_to_homeserver_url(Matrix *matrix, const std::string &url) { std::string remote_homeserver_url = matrix->get_remote_homeserver_url(); if(!remote_homeserver_url.empty() && remote_homeserver_url.back() == '/') remote_homeserver_url.pop_back(); std::string result_url = url; if(string_starts_with(result_url, "http://")) result_url.erase(result_url.begin(), result_url.begin() + 7); else if(string_starts_with(result_url, "https://")) result_url.erase(result_url.begin(), result_url.begin() + 8); size_t path_index = result_url.find('/'); if(path_index == std::string::npos) return remote_homeserver_url; result_url.replace(0, path_index, remote_homeserver_url); return result_url; } static std::string message_to_room_description_text(Matrix *matrix, Message *message, mgl::vec2i image_max_size = mgl::vec2i(0, 0)) { std::string body = strip(message_to_qm_text(matrix, message, true, image_max_size)); if(message->type == MessageType::REACTION) return "Reacted with: " + body; else if(message->related_event_type == RelatedEventType::REPLY) return body; else if(message->related_event_type == RelatedEventType::EDIT) return "Edited: " + body; else return body; } static bool should_message_by_decrypted(const std::string &text) { return !get_config().matrix.gpg_user_id.empty() && text.find("-----BEGIN PGP MESSAGE-----") != std::string::npos && text.find("-----END PGP MESSAGE-----") != std::string::npos; } void MatrixQuickMedia::update_room_description(RoomData *room, const Messages &new_messages, bool is_initial_sync) { int64_t read_marker_message_timestamp = 0; std::shared_ptr me = matrix->get_me(room); std::string my_user_read_marker; if(me) { my_user_read_marker = room->get_user_read_marker(me); auto read_marker_message = room->get_message_by_id(my_user_read_marker); if(read_marker_message) read_marker_message_timestamp = read_marker_message->timestamp; } const int64_t qm_read_marker = room->read_marker_event_timestamp; if(read_marker_message_timestamp == 0 || read_marker_message_timestamp < qm_read_marker) read_marker_message_timestamp = qm_read_marker; std::shared_ptr last_new_message = get_last_message_by_timestamp(new_messages); auto last_message_it = last_message_by_room.find(room); if(last_message_it != last_message_by_room.end()) { if(last_new_message && last_new_message->timestamp > last_message_it->second->timestamp) last_message_it->second = last_new_message; else last_new_message = last_message_it->second; } else if(last_new_message) { last_message_by_room[room] = last_new_message; } // The event id in encrypted rooms contain the timestamp. Sort by that if possible. Such messages contain a colon. // TODO: Test if this also works with construct and other homeservers Message *last_unread_message = nullptr; if(last_new_message) { if(read_marker_message_timestamp != 0 && last_new_message->timestamp > read_marker_message_timestamp) last_unread_message = last_new_message.get(); else if(read_marker_message_timestamp == 0 && !my_user_read_marker.empty() && last_new_message->event_id.find(':') != std::string::npos && last_new_message->event_id > my_user_read_marker) last_unread_message = last_new_message.get(); if(!last_unread_message && read_marker_message_timestamp == 0) last_unread_message = last_new_message.get(); } //assert(room_body_item); if(!room->body_item) return; if(last_unread_message) { ++room->gpg_decrypt_message_id; bool is_window_focused = program->is_window_focused(); RoomData *current_room = program->get_current_chat_room(); Body *chat_body = chat_page ? chat_page->chat_body : nullptr; bool set_room_as_unread = !is_window_focused || room != current_room || (!chat_body || chat_body->is_bottom_cut_off()) || (chat_page && !chat_page->messages_tab_visible); bool unread_mentions = false; std::string room_desc; const int unread_notification_count = unread_mention_count_by_room[room]; if(unread_notification_count > 0 && set_room_as_unread) { unread_mentions = true; room_desc += "** " + std::to_string(unread_notification_count) + " unread mention(s) **"; // TODO: Better notification? room->body_item->set_description_color(get_theme().attention_alert_text_color, true); } else { room->body_item->set_description_color(get_theme().faded_text_color); } if(!room_desc.empty()) room_desc += '\n'; if(!unread_mentions && set_room_as_unread) room_desc += "Unread: "; room->offset_to_latest_message_text = room_desc.size(); room_desc += extract_first_line_remove_newline_elipses(matrix->message_get_author_displayname(last_unread_message), AUTHOR_MAX_LENGTH) + ": " + message_to_room_description_text(matrix, last_unread_message, custom_emoji_max_size); room->body_item->set_description(std::move(room_desc)); room->body_item->set_description_max_lines(3); if(should_message_by_decrypted(room->body_item->get_description())) room->body_item->extra = std::make_shared(matrix, room->body_item->get_description(), room, room->gpg_decrypt_message_id); if(set_room_as_unread) room->body_item->set_title_color(get_theme().attention_alert_text_color, true); room->last_message_read = false; rooms_page->move_room_to_top(room); room_tags_page->move_room_to_top(room); } else if(last_new_message) { ++room->gpg_decrypt_message_id; room->offset_to_latest_message_text = 0; room->body_item->set_description(extract_first_line_remove_newline_elipses(matrix->message_get_author_displayname(last_new_message.get()), AUTHOR_MAX_LENGTH) + ": " + message_to_room_description_text(matrix, last_new_message.get(), custom_emoji_max_size)); room->body_item->set_description_color(get_theme().faded_text_color); room->body_item->set_description_max_lines(3); if(should_message_by_decrypted(room->body_item->get_description())) room->body_item->extra = std::make_shared(matrix, room->body_item->get_description(), room, room->gpg_decrypt_message_id); rooms_page->move_room_to_top(room); room_tags_page->move_room_to_top(room); } } MatrixRoomsPage::MatrixRoomsPage(Program *program, Body *body, std::string title, MatrixRoomTagsPage *room_tags_page, SearchBar *search_bar) : Page(program), body(body), title(std::move(title)), room_tags_page(room_tags_page), search_bar(search_bar) { if(room_tags_page) room_tags_page->set_current_rooms_page(this); } MatrixRoomsPage::~MatrixRoomsPage() { if(room_tags_page) room_tags_page->set_current_rooms_page(nullptr); } PluginResult MatrixRoomsPage::submit(const SubmitArgs &args, std::vector &result_tabs) { result_tabs.push_back(Tab{nullptr, std::make_unique(program, args.url, this), nullptr}); return PluginResult::OK; } void MatrixRoomsPage::add_body_item(std::shared_ptr body_item) { insert_room_body_item_by_timestamp(body, body_item); } void MatrixRoomsPage::move_room_to_top(RoomData *room) { // Swap order of rooms in body list to put rooms with mentions at the top and then unread messages and then all the other rooms // TODO: Optimize with binary search of linear search? or cache the index int room_body_index = body->get_index_by_body_item(room->body_item.get()); if(room_body_index == -1) return; //sort_on_update = true; int selected_item = body->get_selected_item(); if(room_body_index == selected_item) return; for(size_t i = 0; i < body->get_num_items(); ++i) { RoomData *room_i = static_cast(body->get_item_by_index(i)->userdata); if((int)i == room_body_index) return; if((int)i != selected_item && room_i && room->last_message_timestamp >= room_i->last_message_timestamp) { body->move_item(room_body_index, i); if((int)i < selected_item && room_body_index > selected_item && body->get_num_items() > 1 && i != body->get_num_items() - 1) body->select_next_item(); return; } } } void MatrixRoomsPage::remove_body_item_by_room_id(const std::string &room_id) { body->erase_item([&room_id](std::shared_ptr &body_item) { return body_item->url == room_id; }); if(current_chat_page && current_chat_page->room_id == room_id) { program->set_go_to_previous_page(); body->select_first_item(); current_chat_page = nullptr; } } void MatrixRoomsPage::set_current_chat_page(MatrixChatPage *chat_page) { current_chat_page = chat_page; } void MatrixRoomsPage::set_room_as_read(RoomData *room) { matrix_delegate->set_room_as_read(room); } void MatrixRoomsPage::clear_search() { search_bar->clear(); body->filter_search_fuzzy(""); body->select_first_item(); } PluginResult MatrixRoomTagsPage::submit(const SubmitArgs &args, std::vector &result_tabs) { auto body = create_body(true); Body *body_ptr = body.get(); TagData &tag_data = tag_body_items_by_name[args.url]; BodyItems room_body_items = tag_data.room_body_items; sort_room_body_items(room_body_items); body->set_items(std::move(room_body_items)); //BodyItem *selected_item = body->get_selected(); //body_set_selected_item(body.get(), selected_item); auto search_bar = create_search_bar("Search...", SEARCH_DELAY_FILTER); auto rooms_page = std::make_unique(program, body_ptr, tag_data.tag_item->get_title(), this, search_bar.get()); rooms_page->matrix_delegate = matrix_delegate; result_tabs.push_back(Tab{std::move(body), std::move(rooms_page), std::move(search_bar)}); return PluginResult::OK; } void MatrixRoomTagsPage::add_room_body_item_to_tag(std::shared_ptr body_item, const std::string &tag) { TagData *tag_data; auto tag_body_it = tag_body_items_by_name.find(tag); if(tag_body_it == tag_body_items_by_name.end()) { std::string tag_name = tag_get_name(tag); if(tag_name.empty()) { return; } else { auto tag_body_item = BodyItem::create(std::move(tag_name)); tag_body_item->url = tag; tag_body_items_by_name.insert(std::make_pair(tag, TagData{tag_body_item, {}})); body->append_item(tag_body_item); tag_data = &tag_body_items_by_name[tag]; tag_data->tag_item = tag_body_item; } } else { tag_data = &tag_body_it->second; } bool already_exists = false; for(auto &body_it : tag_data->room_body_items) { if(body_it->userdata == body_item->userdata) { already_exists = true; break; } } if(!already_exists) tag_data->room_body_items.push_back(body_item); } void MatrixRoomTagsPage::remove_room_body_item_from_tag(std::shared_ptr body_item, const std::string &tag) { auto tag_body_it = tag_body_items_by_name.find(tag); if(tag_body_it == tag_body_items_by_name.end()) return; auto room_body_item_it = std::find(tag_body_it->second.room_body_items.begin(), tag_body_it->second.room_body_items.end(), body_item); if(room_body_item_it != tag_body_it->second.room_body_items.end()) tag_body_it->second.room_body_items.erase(room_body_item_it); if(tag_body_it->second.room_body_items.empty()) { const auto &tag_item = tag_body_it->second.tag_item; body->erase_item([&tag_item](std::shared_ptr &body_item) { return body_item == tag_item; }); tag_body_items_by_name.erase(tag_body_it); } } void MatrixRoomTagsPage::move_room_to_top(RoomData *room) { if(current_rooms_page) current_rooms_page->move_room_to_top(room); } void MatrixRoomTagsPage::remove_body_item_by_room_id(const std::string &room_id) { for(auto it = tag_body_items_by_name.begin(); it != tag_body_items_by_name.end();) { remove_body_item_by_url(it->second.room_body_items, room_id); if(it->second.room_body_items.empty()) it = tag_body_items_by_name.erase(it); else ++it; } if(current_rooms_page) current_rooms_page->remove_body_item_by_room_id(room_id); } void MatrixRoomTagsPage::set_current_rooms_page(MatrixRoomsPage *rooms_page) { current_rooms_page = rooms_page; } MatrixInvitesPage::MatrixInvitesPage(Program *program, Matrix *matrix, Body *body) : Page(program), matrix(matrix), body(body) { } PluginResult MatrixInvitesPage::submit(const SubmitArgs &args, std::vector &result_tabs) { auto body = create_body(); body->append_item(BodyItem::create("Accept")); body->append_item(BodyItem::create("Decline")); body->append_item(BodyItem::create("Silence")); body->for_each_item([&](auto &body_item) { body_item->userdata = args.userdata; }); result_tabs.push_back(Tab{std::move(body), std::make_unique(program, matrix, this, args.url, "Invite to " + title), nullptr}); return PluginResult::OK; } PluginResult MatrixInviteDetailsPage::submit(const SubmitArgs &args, std::vector&) { if(args.title == "Accept") { if(matrix->join_room(room_id) == PluginResult::OK) { // TODO: Wait for room invite list change from the server instead of removing room here. // Then the invite list can be updated when accepting/declining an invite in another client. invites_page->remove_body_item_by_room_id(room_id); } else { show_notification("QuickMedia", "Failed to accept the room invite", Urgency::CRITICAL); } } else if(args.title == "Decline") { if(matrix->leave_room(room_id) == PluginResult::OK) { // TODO: Wait for room invite list change from the server instead of removing room here. // Then the invite list can be updated when accepting/declining an invite in another client. invites_page->remove_body_item_by_room_id(room_id); } else { show_notification("QuickMedia", "Failed to decline the room invite", Urgency::CRITICAL); } } else if(args.title == "Silence") { BodyItem *body_item = (BodyItem*)args.userdata; matrix->silence_invite(room_id, body_item->get_timestamp()); } program->set_go_to_previous_page(); return PluginResult::OK; } void MatrixInvitesPage::add_body_item(std::shared_ptr body_item) { body->insert_item_by_timestamp_reverse(std::move(body_item)); if(body->get_num_items() != prev_invite_count) { prev_invite_count = body->get_num_items(); title = "Invites (" + std::to_string(body->get_num_items()) + ")"; } } void MatrixInvitesPage::remove_body_item_by_room_id(const std::string &room_id) { const bool item_removed = body->erase_item([&room_id](std::shared_ptr &body_item) { return body_item->url == room_id; }); if(item_removed) { prev_invite_count = body->get_num_items(); title = "Invites (" + std::to_string(body->get_num_items()) + ")"; } } PluginResult MatrixSettingsPage::submit(const SubmitArgs &args, std::vector &result_tabs) { if(args.url == "join") { result_tabs.push_back(Tab{create_body(), std::make_unique(program, matrix), create_search_bar("Enter room id...", SEARCH_DELAY_FILTER)}); return PluginResult::OK; } else if(args.url == "logout") { matrix->logout(); program->set_go_to_previous_page(); return PluginResult::OK; } else if(args.url == "emoji") { result_tabs.push_back(Tab{create_body(), std::make_unique(program, matrix), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); return PluginResult::OK; } else { return PluginResult::ERR; } } PluginResult MatrixRoomInputPage::submit(const SubmitArgs &args, std::vector&) { if(args.title.empty()) { show_notification("QuickMedia", "Room id can't be empty", Urgency::CRITICAL); return PluginResult::OK; } if(matrix->join_room(args.title) == PluginResult::OK) { show_notification("QuickMedia", "You joined " + args.title, Urgency::NORMAL); program->set_go_to_previous_page(); } else { show_notification("QuickMedia", "Failed to join " + args.title, Urgency::CRITICAL); } return PluginResult::OK; } static const char* file_get_filename(const std::string &filepath) { size_t index = filepath.rfind('/'); if(index == std::string::npos) return filepath.c_str(); return filepath.c_str() + index + 1; } PluginResult MatrixCustomEmojiPage::submit(const SubmitArgs &args, std::vector &result_tabs) { if(args.url == "add") { auto submit_handler = [this](FileManagerPage*, const std::filesystem::path &filepath) { program->run_task_with_loading_screen([this, filepath] { std::string key = filepath.filename().string(); if(!key.empty()) { size_t ext_index = key.rfind('.'); if(ext_index != std::string::npos) key = key.substr(0, ext_index); } if(key.empty()) { key.resize(10); if(!generate_random_string_readable(key.data(), key.size())) { show_notification("QuickMedia", "Failed to generate random string", Urgency::CRITICAL); return false; } } if(matrix->does_custom_emoji_with_name_exist(key)) { show_notification("QuickMedia", "Failed to upload custom emoji. You already have a custom emoji with the name " + key, Urgency::CRITICAL); return false; } std::string mxc_url; std::string err_msg; if(matrix->upload_custom_emoji(filepath, key, mxc_url, err_msg) != PluginResult::OK) { show_notification("QuickMedia", "Failed to upload custom emoji, error: " + err_msg, Urgency::CRITICAL); return false; } return true; }); return std::vector{}; }; auto file_manager_body = create_body(false, get_config().file_manager.grid_view); auto file_manager_page = std::make_unique(program, FILE_MANAGER_MIME_TYPE_IMAGE, std::move(submit_handler)); file_manager_page->set_current_directory(get_home_dir().data); BodyItems body_items; file_manager_page->get_files_in_directory(body_items); file_manager_body->set_items(std::move(body_items)); result_tabs.push_back(Tab{std::move(file_manager_body), std::move(file_manager_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); return PluginResult::OK; } else if(args.url == "rename") { result_tabs.push_back(Tab{create_body(false, true), std::make_unique(program, matrix), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); return PluginResult::OK; } else if(args.url == "delete") { auto body = create_body(false, true); BodyItems body_items; for(auto &emoji : matrix->get_custom_emojis()) { auto emoji_item = BodyItem::create(":" + emoji.first + ":"); emoji_item->url = emoji.first; emoji_item->thumbnail_url = matrix->get_media_url(emoji.second.url); emoji_item->thumbnail_size = emoji.second.size; body_items.push_back(std::move(emoji_item)); } body->set_items(std::move(body_items)); Body *body_p = body.get(); result_tabs.push_back(Tab{std::move(body), std::make_unique(program, matrix, body_p), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); return PluginResult::OK; } else { return PluginResult::ERR; } } PluginResult MatrixCustomEmojiPage::lazy_fetch(BodyItems &result_items) { auto add_emoji_item = BodyItem::create("Add emoji"); add_emoji_item->url = "add"; result_items.push_back(std::move(add_emoji_item)); auto rename_emoji_item = BodyItem::create("Rename emoji"); rename_emoji_item->url = "rename"; result_items.push_back(std::move(rename_emoji_item)); auto delete_emoji_item = BodyItem::create("Delete emoji"); delete_emoji_item->set_title_color(mgl::Color(255, 45, 47)); delete_emoji_item->url = "delete"; result_items.push_back(std::move(delete_emoji_item)); return PluginResult::OK; } bool MatrixCustomEmojiPage::is_ready() { return matrix->is_initial_sync_finished(); } PluginResult MatrixCustomEmojiRenameSelectPage::submit(const SubmitArgs &args, std::vector &result_tabs) { result_tabs.push_back(Tab{create_body(), std::make_unique(program, matrix, args.url), create_search_bar("Enter a new name for the emoji...", SEARCH_DELAY_FILTER)}); return PluginResult::OK; } PluginResult MatrixCustomEmojiRenameSelectPage::lazy_fetch(BodyItems &result_items) { for(auto &emoji : matrix->get_custom_emojis()) { auto emoji_item = BodyItem::create(":" + emoji.first + ":"); emoji_item->url = emoji.first; emoji_item->thumbnail_url = matrix->get_media_url(emoji.second.url); emoji_item->thumbnail_size = emoji.second.size; result_items.push_back(std::move(emoji_item)); } return PluginResult::OK; } PluginResult MatrixCustomEmojiRenamePage::submit(const SubmitArgs &args, std::vector&) { if(matrix->rename_custom_emoji(emoji_key, args.title)) { program->set_go_to_previous_page(); return PluginResult::OK; } else { show_notification("QuickMedia", "Failed to rename emoji " + emoji_key + " to " + args.title, Urgency::CRITICAL); return PluginResult::OK; } } PluginResult MatrixCustomEmojiDeletePage::submit(const SubmitArgs &args, std::vector&) { if(matrix->delete_custom_emoji(args.url)) { body->erase_item([&args](std::shared_ptr &item) { return item->url == args.url; }); return PluginResult::OK; } else { show_notification("QuickMedia", "Failed to delete emoji: " + args.url, Urgency::CRITICAL); return PluginResult::OK; } } MatrixChatPage::MatrixChatPage(Program *program, std::string room_id, MatrixRoomsPage *rooms_page, std::string jump_to_event_id) : Page(program), room_id(std::move(room_id)), rooms_page(rooms_page), jump_to_event_id(std::move(jump_to_event_id)) { assert(rooms_page); rooms_page->set_current_chat_page(this); rooms_page->matrix_delegate->chat_page = this; } MatrixChatPage::~MatrixChatPage() { rooms_page->set_current_chat_page(nullptr); rooms_page->matrix_delegate->chat_page = nullptr; } void MatrixChatPage::add_user_to_body_by_user_info(const MatrixEventUserInfo &user_info) { std::string display_name = user_info.display_name.value_or(user_info.user_id); auto body_item = BodyItem::create(""); body_item->url = user_info.user_id; body_item->set_author(extract_first_line_remove_newline_elipses(display_name, AUTHOR_MAX_LENGTH)); body_item->set_author_color(user_id_to_color(user_info.user_id)); body_item->set_description(user_info.user_id); body_item->set_description_color(get_theme().faded_text_color); if(user_info.avatar_url) body_item->thumbnail_url = user_info.avatar_url.value(); body_item->thumbnail_mask_type = ThumbnailMaskType::CIRCLE; body_item->thumbnail_size = mgl::vec2i(32, 32); user_body_item_by_user_id[user_info.user_id] = body_item.get(); users_body->append_item(std::move(body_item)); } void MatrixChatPage::add_user(MatrixEventUserInfo user_info) { if(!current_room || !users_body || user_info.room != current_room) return; add_user_to_body_by_user_info(user_info); } void MatrixChatPage::remove_user(MatrixEventUserInfo user_info) { if(!current_room || !users_body || user_info.room != current_room) return; user_body_item_by_user_id.erase(user_info.user_id); // TODO: Optimize users_body->erase_item([&user_info](std::shared_ptr &it) { return it->url == user_info.user_id; }); } void MatrixChatPage::set_user_info(MatrixEventUserInfo user_info) { if(!current_room || !users_body || user_info.room != current_room) return; auto it = user_body_item_by_user_id.find(user_info.user_id); if(it == user_body_item_by_user_id.end()) return; if(user_info.avatar_url) it->second->thumbnail_url = user_info.avatar_url.value(); if(user_info.display_name) { const std::string *display_name; if(user_info.display_name.value().empty()) display_name = &user_info.user_id; else display_name = &user_info.display_name.value(); it->second->set_author(extract_first_line_remove_newline_elipses(*display_name, AUTHOR_MAX_LENGTH)); users_body->apply_search_filter_for_item(it->second); } } void MatrixChatPage::set_room_info(MatrixEventRoomInfo room_info) { if(!current_room || !room_info_update_callback || room_info.room != current_room) return; room_info_update_callback(room_info); } void MatrixChatPage::set_current_room(RoomData *room, Body *users_body, MatrixRoomInfoUpdateCallback room_info_update_callback) { this->current_room = room; this->users_body = users_body; this->room_info_update_callback = std::move(room_info_update_callback); user_body_item_by_user_id.clear(); if(!room || !users_body) return; rooms_page->matrix_delegate->for_each_user_in_room(room, [this](const MatrixEventUserInfo &user_info) { add_user_to_body_by_user_info(user_info); }); } size_t MatrixChatPage::get_num_users_in_current_room() const { return users_body ? users_body->get_num_items() : 0; } void MatrixChatPage::set_room_as_read(RoomData *room) { rooms_page->set_room_as_read(room); } PluginResult MatrixRoomDirectoryPage::submit(const SubmitArgs &args, std::vector &result_tabs) { std::string server_name = args.title; if(strncmp(server_name.c_str(), "http://", 7) == 0) server_name.erase(0, 7); else if(strncmp(server_name.c_str(), "https://", 8) == 0) server_name.erase(0, 8); if(strncmp(server_name.c_str(), "www.", 4) == 0) server_name.erase(0, 4); result_tabs.push_back(Tab{create_body(), std::make_unique(program, matrix, server_name), create_search_bar("Search...", 400)}); return PluginResult::OK; } PluginResult MatrixServerRoomListPage::lazy_fetch(BodyItems &result_items) { return matrix->get_public_rooms(server_name, search_term, next_batch, result_items, next_batch); } PluginResult MatrixServerRoomListPage::get_page(const std::string&, int page, BodyItems &result_items) { while(current_page < page && !next_batch.empty()) { PluginResult plugin_result = lazy_fetch(result_items); if(plugin_result != PluginResult::OK) return plugin_result; ++current_page; } return PluginResult::OK; } SearchResult MatrixServerRoomListPage::search(const std::string &str, BodyItems &result_items) { next_batch.clear(); current_page = 0; search_term = str; return plugin_result_to_search_result(lazy_fetch(result_items)); } PluginResult MatrixServerRoomListPage::submit(const SubmitArgs &args, std::vector&) { if(matrix->join_room(args.url) == PluginResult::OK) { show_notification("QuickMedia", "You joined " + args.title, Urgency::NORMAL); program->set_go_to_previous_page(); } else { show_notification("QuickMedia", "Failed to join " + args.title, Urgency::CRITICAL); } return PluginResult::OK; } class NotificationsExtraData : public BodyItemExtra { public: RoomData *room; bool read; }; static std::shared_ptr notification_to_body_item(const MatrixNotification ¬ification) { auto body_item = BodyItem::create(""); body_item->set_author(notification.room->get_name()); body_item->set_description(notification.sender_user_id + ":\n" + notification.body); body_item->set_timestamp(notification.timestamp); body_item->url = notification.event_id; if(!notification.read) { body_item->set_author_color(get_theme().attention_alert_text_color, true); body_item->set_description_color(get_theme().attention_alert_text_color, true); } body_item->thumbnail_mask_type = ThumbnailMaskType::CIRCLE; body_item->thumbnail_size = mgl::vec2i(32, 32); body_item->thumbnail_url = notification.room->get_avatar_url(); auto extra_data = std::make_shared(); extra_data->room = notification.room; extra_data->read = notification.read; body_item->extra = std::move(extra_data); return body_item; } MatrixNotificationsPage::MatrixNotificationsPage(Program *program, Matrix *matrix, Body *notifications_body, MatrixRoomsPage *all_rooms_page) : LazyFetchPage(program), matrix(matrix), notifications_body(notifications_body), all_rooms_page(all_rooms_page) {} PluginResult MatrixNotificationsPage::submit(const SubmitArgs&, std::vector &result_tabs) { BodyItem *selected_item = notifications_body->get_selected(); if(!selected_item) return PluginResult::OK; NotificationsExtraData *extra_data = static_cast(selected_item->extra.get()); auto chat_page = std::make_unique(program, extra_data->room->id, all_rooms_page, selected_item->url); chat_page->is_regular_navigation = false; result_tabs.push_back(Tab{nullptr, std::move(chat_page), nullptr}); return PluginResult::OK; } PluginResult MatrixNotificationsPage::get_page(const std::string&, int, BodyItems &result_items) { return matrix->get_previous_notifications([this, &result_items](const MatrixNotification ¬ification) { auto body_item = notification_to_body_item(notification); if(room_notifications[notification.room->id].insert(std::make_pair(notification.event_id, body_item)).second) result_items.push_back(body_item); }); } PluginResult MatrixNotificationsPage::lazy_fetch(BodyItems &result_items) { BodyItems new_body_items; for(const auto &pending_room_notifications : pending_room_notifications) { for(const auto ¬ification : pending_room_notifications.second) { auto body_item = notification_to_body_item(notification.second); if(room_notifications[notification.second.room->id].insert(std::make_pair(notification.second.event_id, body_item)).second) new_body_items.push_back(std::move(body_item)); } } pending_room_notifications.clear(); matrix->get_cached_notifications([this, &new_body_items](const MatrixNotification ¬ification) { auto body_item = notification_to_body_item(notification); if(room_notifications[notification.room->id].insert(std::make_pair(notification.event_id, body_item)).second) new_body_items.push_back(body_item); }); std::sort(new_body_items.begin(), new_body_items.end(), [](const std::shared_ptr &body_item1, const std::shared_ptr &body_item2) { return body_item1->get_timestamp() > body_item2->get_timestamp(); }); result_items = std::move(new_body_items); has_fetched = true; return PluginResult::OK; } bool MatrixNotificationsPage::is_ready() { return matrix->has_finished_fetching_notifications(); } void MatrixNotificationsPage::add_notification(MatrixNotification notification) { if(!has_fetched) { pending_room_notifications[notification.room->id][notification.event_id] = std::move(notification); return; } auto body_item = notification_to_body_item(notification); if(room_notifications[notification.room->id].insert(std::make_pair(notification.event_id, body_item)).second) notifications_body->insert_item_by_timestamp_reverse(std::move(body_item)); } // TODO: Only loop unread items void MatrixNotificationsPage::set_room_as_read(RoomData *room) { auto pending_it = pending_room_notifications.find(room->id); if(pending_it != pending_room_notifications.end()) { for(auto &room_notification : pending_it->second) { room_notification.second.read = true; } } auto it = room_notifications.find(room->id); if(it != room_notifications.end()) { for(const auto &room_notification : it->second) { NotificationsExtraData *extra_data = static_cast(room_notification.second->extra.get()); if(!extra_data->read) { extra_data->read = true; room_notification.second->set_author_color(get_theme().text_color); room_notification.second->set_description_color(get_theme().text_color); } } } } SearchResult MatrixInviteUserPage::search(const std::string &str, BodyItems &result_items) { return plugin_result_to_search_result(matrix->search_user(str, 20, result_items)); } PluginResult MatrixInviteUserPage::submit(const SubmitArgs &args, std::vector&) { PluginResult result = matrix->invite_user(room_id, args.url); if(result != PluginResult::OK) return result; program->set_go_to_previous_page(); return PluginResult::OK; } static std::array sync_fail_error_codes = { "M_FORBIDDEN", "M_UNKNOWN_TOKEN", "M_MISSING_TOKEN", "M_UNAUTHORIZED", "M_USER_DEACTIVATED", "M_CAPTCHA_NEEDED", "M_MISSING_PARAM" }; Matrix::Matrix(bool matrix_instance_already_running) : matrix_instance_already_running(matrix_instance_already_running) {} bool Matrix::start_sync(MatrixDelegate *delegate, bool &cached) { cached = true; if(sync_running) return true; assert(!this->delegate); assert(!access_token.empty()); // Need to be logged in assert(delegate); this->delegate = delegate; Path matrix_cache_dir = get_cache_dir().join("matrix"); if(create_directory_recursive(matrix_cache_dir) != 0) { fprintf(stderr, "Failed to create matrix cache directory\n"); return false; } matrix_cache_dir.join("sync_data.json"); cached = (get_file_type(matrix_cache_dir) == FileType::REGULAR); sync_running = true; load_silenced_invites(); load_custom_emoji_from_cache(); sync_thread = std::thread([this, matrix_cache_dir]() { FILE *sync_cache_file; const rapidjson::Value *next_batch_json = nullptr; if(!load_qm_read_markers_from_account_data()) { // TODO: Remove when https://github.com/matrix-org/synapse/issues/14444 is fixed, if ever. show_notification("QuickMedia", "Failed to connect to matrix homeserver. Is your internet down?", Urgency::CRITICAL); sync_running = false; return; } const char *update_cache_file_name = "updated-cache-version2"; const bool cache_updated = get_file_type(get_cache_dir().join("matrix").join(update_cache_file_name)) == FileType::REGULAR; bool overwrite_cache = !cache_updated; std::ifstream sync_cache_file_stream; sync_cache_file_stream.open(matrix_cache_dir.data.c_str(), std::ifstream::in | std::ifstream::binary); if(sync_cache_file_stream.good() && cache_updated) { rapidjson::Document doc; std::string line; while(std::getline(sync_cache_file_stream, line)) { rapidjson::ParseResult parse_result = doc.Parse(line.c_str(), line.size()); if(parse_result.IsError()) continue; // This should NEVER happen. Do initial sync if it does and remove cache? :( TODO if(parse_sync_response(doc, false) != PluginResult::OK) { fprintf(stderr, "Failed to parse cached sync response\n"); continue; } next_batch_json = &GetMember(doc, "next_batch"); if(next_batch_json->IsString()) { set_next_batch(next_batch_json->GetString(), false); //fprintf(stderr, "Matrix: next batch: %s\n", next_batch.c_str()); } } malloc_trim(0); } sync_cache_file_stream.close(); // Filter with account data // {"presence":{"limit":0,"types":[""]},"account_data":{"not_types":["im.vector.setting.breadcrumbs","m.push_rules","im.vector.setting.allowed_widgets","io.element.recent_emoji"]},"room":{"state":{"limit":1,"not_types":["m.room.related_groups","m.room.power_levels","m.room.join_rules","m.room.history_visibility"],"lazy_load_members":true},"timeline":{"limit":3,"lazy_load_members":true},"ephemeral":{"limit":0,"types":[""],"lazy_load_members":true},"account_data":{"limit":1,"types":["m.fully_read"],"lazy_load_members":true}}} #if 0 bool filter_cached = false; Path filter_path = get_storage_dir().join("matrix").join("filter"); if(get_file_type(filter_path) == FileType::REGULAR) filter_cached = true; // We dont want to POST filter for initial login sync and instead do it after sync. // This should improve initial sync by around 600ms std::string filter; if(filter_cached) filter = get_filter_cached(); else filter = FILTER; #endif std::vector additional_args = { { "-H", "Authorization: Bearer " + access_token }, { "-m", "35" } }; next_batch_json = nullptr; PluginResult result; bool initial_sync = next_batch.empty(); bool first_sync = true; std::string filter_encoded; if(initial_sync) filter_encoded = url_param_encode(INITIAL_FILTER); else filter_encoded = url_param_encode(CONTINUE_FILTER); const char *presence = ::QuickMedia::get_config().matrix.appear_online ? "online" : "offline"; while(sync_running) { char url[2048]; if(next_batch.empty()) snprintf(url, sizeof(url), "%s/_matrix/client/r0/sync?filter=%s&timeout=0&set_presence=%s", homeserver.c_str(), filter_encoded.c_str(), presence); else if(first_sync) snprintf(url, sizeof(url), "%s/_matrix/client/r0/sync?filter=%s&timeout=0&since=%s&set_presence=%s", homeserver.c_str(), filter_encoded.c_str(), next_batch.c_str(), presence); else snprintf(url, sizeof(url), "%s/_matrix/client/r0/sync?filter=%s&timeout=30000&since=%s&set_presence=%s", homeserver.c_str(), filter_encoded.c_str(), next_batch.c_str(), presence); rapidjson::Document json_root; std::string err_msg; DownloadResult download_result = download_json(json_root, url, additional_args, true, &err_msg); if(download_result != DownloadResult::OK) { fprintf(stderr, "/sync failed\n"); goto sync_end; } if(first_sync && json_root.IsObject()) { const rapidjson::Value &errcode_json = GetMember(json_root, "errcode"); if(errcode_json.IsString()) { for(const char *sync_fail_error_code : sync_fail_error_codes) { if(strcmp(errcode_json.GetString(), sync_fail_error_code) == 0) { sync_fail_reason = sync_fail_error_code; const rapidjson::Value &error_json = GetMember(json_root, "error"); if(error_json.IsString()) sync_fail_reason = error_json.GetString(); sync_failed = true; sync_running = false; break; } } fprintf(stderr, "/sync failed\n"); goto sync_end; } } result = parse_sync_response(json_root, initial_sync); if(result != PluginResult::OK) { fprintf(stderr, "Failed to parse sync response\n"); initial_sync = false; goto sync_end; } add_new_invites(); next_batch_json = &GetMember(json_root, "next_batch"); if(next_batch_json->IsString()) { set_next_batch(next_batch_json->GetString(), true); fprintf(stderr, "Matrix: next batch: %s\n", next_batch.c_str()); } else { //set_next_batch("Invalid"); fprintf(stderr, "Matrix: missing next batch\n"); initial_sync = false; goto sync_end; } if(first_sync) { bool initial_sync_show_notifications = initial_sync; first_sync = false; initial_sync = false; filter_encoded = url_param_encode(CONTINUE_FILTER); // TODO: limit messages in this continue filter? // TODO: This ignores new rooms that are not part of the previous sync message. Fix this. // This is needed for system notifications for new messages because on initial sync // there might be messages that were sent 200 messages ago that mentioned us and such // messages wont be part of the intial /sync, so we want to see such messages anyways. notification_thread = std::thread([this, initial_sync_show_notifications]() { get_previous_notifications([this, initial_sync_show_notifications](const MatrixNotification ¬ification) { if(!initial_sync_show_notifications || notification.read) return; MatrixDelegate *delegate = this->delegate; ui_thread_tasks.push([delegate, notification] { delegate->add_unread_notification(std::move(notification)); }); }); finished_fetching_notifications = true; }); } #if 0 if(!filter_cached) { filter_cached = true; filter_encoded = url_param_encode(get_filter_cached()); } #endif // TODO: Use a NoSQL database. // TODO: Remove very old cache. // TODO: Find a way to remove this? this makes sync work like other clients but we dont want that! // If the last sync was long ago then it has to sync ALL messages again. Maybe check if sync file // is XX days old and then ignore it? // TODO: Remove this matrix_instance_already_running check when the matrix sync is moved to a daemon. // Then the daemon will do the sync and matrix processes will ask that daemon for the cached data // and fetch previous messages etc themselves. if(!matrix_instance_already_running) { sync_cache_file = fopen(matrix_cache_dir.data.c_str(), overwrite_cache ? "wb" : "ab"); overwrite_cache = false; if(sync_cache_file) { if(json_root.IsObject()) { rapidjson::StringBuffer buffer; rapidjson::Writer writer(buffer); json_root.Accept(writer); std::string json_data(buffer.GetString(), buffer.GetSize()); string_replace_all(json_data, '\n', ' '); json_data += '\n'; fwrite(json_data.data(), 1, json_data.size(), sync_cache_file); file_overwrite(get_cache_dir().join("matrix").join(update_cache_file_name), "1"); // To make sure the cache format is up to date malloc_trim(0); } fclose(sync_cache_file); } } sync_end: first_sync = false; if(sync_running) std::this_thread::sleep_for(std::chrono::milliseconds(500)); } }); return true; } void Matrix::stop_sync() { sync_running = false; if(sync_thread.joinable()) { program_kill_in_thread(sync_thread.get_id()); sync_thread.join(); } if(notification_thread.joinable()) { program_kill_in_thread(notification_thread.get_id()); notification_thread.join(); } std::lock_guard lock(room_data_mutex); delegate = nullptr; sync_failed = false; sync_fail_reason.clear(); set_next_batch("", false); next_notifications_token.clear(); invites.clear(); filter_cached.reset(); finished_fetching_notifications = false; initial_sync_finished = false; custom_emoji_by_key.clear(); silenced_invites.clear(); qm_read_markers_by_room_cache.clear(); if(decrypt_thread.joinable()) { decrypt_task.close(); decrypt_thread.join(); } } bool Matrix::is_initial_sync_finished() { return initial_sync_finished; } bool Matrix::did_initial_sync_fail(std::string &err_msg) { if(sync_failed) { err_msg = sync_fail_reason; return true; } else { return false; } } bool Matrix::has_finished_fetching_notifications() const { return finished_fetching_notifications; } void Matrix::get_room_sync_data(RoomData *room, SyncData &sync_data) { room->acquire_room_lock(); auto &room_messages = room->get_messages_thread_unsafe(); if(room->messages_read_index <= room_messages.size()) { sync_data.messages.insert(sync_data.messages.end(), room_messages.begin() + room->messages_read_index, room_messages.end()); room->messages_read_index = room_messages.size(); } else { // TODO: BUG fprintf(stderr, "Unexpected behavior!!!! get_room_sync_data said read index is %zu but we only have %zu messages\n", room->messages_read_index, room_messages.size()); room->messages_read_index = room_messages.size(); } if(room->pinned_events_updated) { sync_data.pinned_events = room->get_pinned_events_thread_unsafe(); room->pinned_events_updated = false; } room->release_room_lock(); } void Matrix::get_all_synced_room_messages(RoomData *room, Messages &messages) { room->acquire_room_lock(); messages = room->get_messages_thread_unsafe(); room->messages_read_index = messages.size(); room->release_room_lock(); } void Matrix::get_all_pinned_events(RoomData *room, std::vector &events) { room->acquire_room_lock(); events = room->get_pinned_events_thread_unsafe(); room->pinned_events_updated = false; room->release_room_lock(); } PluginResult Matrix::get_messages_in_direction(RoomData *room, const std::string &token, MessageDirection message_dir, Messages &messages, std::string &new_token) { // TODO: Retry on failure (after a timeout) instead of setting new token to an empty string new_token.clear(); rapidjson::Document request_data(rapidjson::kObjectType); request_data.AddMember("lazy_load_members", true, request_data.GetAllocator()); rapidjson::StringBuffer buffer; rapidjson::Writer writer(buffer); request_data.Accept(writer); std::vector additional_args = { { "-H", "Authorization: Bearer " + access_token } }; std::string filter = url_param_encode(buffer.GetString()); char url[512]; snprintf(url, sizeof(url), "%s/_matrix/client/r0/rooms/%s/messages?from=%s&limit=20&dir=%s&filter=%s", homeserver.c_str(), room->id.c_str(), token.c_str(), message_dir == MessageDirection::BEFORE ? "b" : "f", filter.c_str()); rapidjson::Document json_root; DownloadResult download_result = download_json(json_root, url, std::move(additional_args), true); if(download_result != DownloadResult::OK) return download_result_to_plugin_result(download_result); if(!json_root.IsObject()) return PluginResult::ERR; const rapidjson::Value &state_json = GetMember(json_root, "state"); events_add_user_info(state_json, room, 0); //events_set_room_info(state_json, room_data, 0); const rapidjson::Value &chunk_json = GetMember(json_root, "chunk"); if(chunk_json.IsArray()) { for(const rapidjson::Value &event_item_json : chunk_json.GetArray()) { std::shared_ptr new_message = parse_message_event(event_item_json, room); if(new_message) messages.push_back(std::move(new_message)); } } const rapidjson::Value &end_json = GetMember(json_root, "end"); if(end_json.IsString()) new_token.assign(end_json.GetString(), end_json.GetStringLength()); if(new_token == token) new_token.clear(); return PluginResult::OK; } PluginResult Matrix::get_previous_room_messages(RoomData *room, Messages &messages, bool latest_messages, bool *reached_end) { size_t num_new_messages = 0; PluginResult result = get_previous_room_messages(room, latest_messages, num_new_messages, reached_end); if(result != PluginResult::OK) return result; room->acquire_room_lock(); messages.insert(messages.end(), room->get_messages_thread_unsafe().begin(), room->get_messages_thread_unsafe().begin() + num_new_messages); room->messages_read_index += num_new_messages; //assert(room->messages_read_index <= num_messages_after); // TODO: BUG if(room->messages_read_index >= room->get_messages_thread_unsafe().size()) room->messages_read_index = room->get_messages_thread_unsafe().size(); room->release_room_lock(); return PluginResult::OK; } PluginResult Matrix::get_previous_notifications(std::function callback_func) { std::vector additional_args = { { "-H", "Authorization: Bearer " + access_token } }; std::string from = get_next_notifications_token(); // TODO: Instead of guessing notification limit with 100, accumulate rooms unread_notifications count and use that as the limit // (and take into account that notification response may have notifications after call to sync above). char url[512]; if(from.empty()) snprintf(url, sizeof(url), "%s/_matrix/client/r0/notifications?limit=100&only=highlight", homeserver.c_str()); else snprintf(url, sizeof(url), "%s/_matrix/client/r0/notifications?limit=100&only=highlight&from=%s", homeserver.c_str(), from.c_str()); rapidjson::Document json_root; DownloadResult download_result = download_json(json_root, url, std::move(additional_args), true); if(download_result != DownloadResult::OK || !json_root.IsObject()) { fprintf(stderr, "Fetching notifications failed!\n"); return PluginResult::ERR; } const rapidjson::Value ¬ification_json = GetMember(json_root, "notifications"); parse_notifications(notification_json, std::move(callback_func)); const rapidjson::Value &next_token_json = GetMember(json_root, "next_token"); if(next_token_json.IsString()) set_next_notifications_token(next_token_json.GetString()); else set_next_notifications_token("invalid"); return PluginResult::OK; } void Matrix::get_cached_notifications(std::function callback_func) { std::lock_guard lock(notifications_mutex); for(const auto ¬ification : notifications) { callback_func(notification); } } void Matrix::add_new_invites() { std::lock_guard lock(invite_mutex); for(auto &[room_id, invite] : invites) { ui_thread_tasks.push([this, room_id{std::move(room_id)}, invite{std::move(invite)}]{ delegate->add_invite(room_id, std::move(invite)); }); } invites.clear(); } PluginResult Matrix::parse_sync_response(const rapidjson::Document &root, bool initial_sync) { if(!root.IsObject()) return PluginResult::ERR; const rapidjson::Value &rooms_json = GetMember(root, "rooms"); parse_sync_room_data(rooms_json, initial_sync); const rapidjson::Value &account_data_json = GetMember(root, "account_data"); parse_sync_account_data(account_data_json); return PluginResult::OK; } PluginResult Matrix::parse_notifications(const rapidjson::Value ¬ifications_json, std::function callback_func) { if(!notifications_json.IsArray()) return PluginResult::ERR; std::lock_guard lock(notifications_mutex); for(const rapidjson::Value ¬ification_json : notifications_json.GetArray()) { if(!notification_json.IsObject()) continue; const rapidjson::Value &read_json = GetMember(notification_json, "read"); if(!read_json.IsBool()) continue; const rapidjson::Value &room_id_json = GetMember(notification_json, "room_id"); if(!room_id_json.IsString()) continue; const rapidjson::Value &event_json = GetMember(notification_json, "event"); if(!event_json.IsObject()) continue; const rapidjson::Value &event_id_json = GetMember(event_json, "event_id"); if(!event_id_json.IsString()) continue; int64_t timestamp = 0; const rapidjson::Value &origin_server_ts_json = GetMember(event_json, "origin_server_ts"); if(origin_server_ts_json.IsInt64()) timestamp = origin_server_ts_json.GetInt64(); const rapidjson::Value &sender_json = GetMember(event_json, "sender"); if(!sender_json.IsString()) continue; const rapidjson::Value &content_json = GetMember(event_json, "content"); if(!content_json.IsObject()) continue; const rapidjson::Value &body_json = GetMember(content_json, "body"); if(!body_json.IsString()) continue; std::string room_id(room_id_json.GetString(), room_id_json.GetStringLength()); RoomData *room = get_room_by_id(room_id); if(!room) { //fprintf(stderr, "Warning: got notification in unknown room %s\n", room_id.c_str()); // TODO: continue; } std::string event_id(event_id_json.GetString(), event_id_json.GetStringLength()); if(notifications_by_event_id.insert(event_id).second) { auto me = get_me(room); int64_t read_marker_message_timestamp = 0; if(me) { auto read_marker_message = room->get_message_by_id(room->get_user_read_marker(me)); if(read_marker_message) read_marker_message_timestamp = read_marker_message->timestamp; } bool actually_read = read_json.GetBool(); // TODO: Make sure |events_set_user_read_marker| is called before |events_add_messages| so this is set const int64_t qm_read_marker = room->read_marker_event_timestamp; if(read_marker_message_timestamp == 0 || read_marker_message_timestamp < qm_read_marker) read_marker_message_timestamp = qm_read_marker; if(read_marker_message_timestamp > 0) actually_read = read_marker_message_timestamp >= timestamp; MatrixNotification notification; notification.room = room; notification.event_id = std::move(event_id); notification.sender_user_id.assign(sender_json.GetString(), sender_json.GetStringLength()); notification.body = remove_reply_formatting(this, body_json.GetString()); notification.timestamp = timestamp; notification.read = actually_read; //read_json.GetBool(); // Intentionally ignore servers read_json value because it's invalid because of synapse cache bug callback_func(notification); notifications.push_back(std::move(notification)); } } return PluginResult::OK; } void Matrix::parse_custom_emoji(const rapidjson::Value &custom_emoji_json) { if(!custom_emoji_json.IsObject()) return; std::lock_guard lock(room_data_mutex); custom_emoji_by_key.clear(); for(auto const &emoji_json : custom_emoji_json.GetObject()) { if(!emoji_json.name.IsString() || !emoji_json.value.IsObject()) continue; const rapidjson::Value &url_json = GetMember(emoji_json.value, "url"); const rapidjson::Value &width_json = GetMember(emoji_json.value, "width"); const rapidjson::Value &height_json = GetMember(emoji_json.value, "height"); if(!url_json.IsString()) continue; CustomEmoji custom_emoji; custom_emoji.url = url_json.GetString(); if(width_json.IsInt() && height_json.IsInt()) { custom_emoji.size.x = width_json.GetInt(); custom_emoji.size.y = height_json.GetInt(); } custom_emoji_by_key[emoji_json.name.GetString()] = std::move(custom_emoji); } } void Matrix::load_custom_emoji_from_cache() { std::string custom_emoji_file_content; if(file_get_content(get_cache_dir().join("matrix").join("custom_emoji.json"), custom_emoji_file_content) != 0) return; rapidjson::Document json_root; rapidjson::ParseResult parse_result = json_root.Parse(custom_emoji_file_content.c_str(), custom_emoji_file_content.size()); if(parse_result.IsError()) { fprintf(stderr, "Warning: failed to parse custom_emoji.json, error: %d\n", parse_result.Code()); return; } parse_custom_emoji(json_root); } PluginResult Matrix::parse_sync_account_data(const rapidjson::Value &account_data_json) { if(!account_data_json.IsObject()) return PluginResult::OK; const rapidjson::Value &events_json = GetMember(account_data_json, "events"); if(!events_json.IsArray()) return PluginResult::OK; for(const rapidjson::Value &event_item_json : events_json.GetArray()) { if(!event_item_json.IsObject()) continue; const rapidjson::Value &type_json = GetMember(event_item_json, "type"); if(!type_json.IsString()) continue; const rapidjson::Value &content_json = GetMember(event_item_json, "content"); if(!content_json.IsObject()) continue; if(strcmp(type_json.GetString(), "m.direct") == 0) { for(auto const &it : content_json.GetObject()) { if(!it.name.IsString()) continue; if(!it.value.IsArray()) continue; for(const rapidjson::Value &room_id_json : it.value.GetArray()) { if(!room_id_json.IsString()) continue; RoomData *room = get_room_by_id(std::string(room_id_json.GetString(), room_id_json.GetStringLength())); if(!room) { //fprintf(stderr, "Warning: got m.direct for room %s that we haven't created yet\n", room_id_json.GetString()); continue; } auto user = get_user_by_id(room, std::string(it.name.GetString(), it.name.GetStringLength()), nullptr, false); if(!user) { //fprintf(stderr, "Warning: got m.direct for user %s that doesn't exist in the room %s yet\n", it.name.GetString(), room_id_json.GetString()); continue; } room->acquire_room_lock(); std::set &room_tags = room->get_tags_thread_unsafe(); auto room_tag_it = room_tags.find("m.direct"); if(room_tag_it == room_tags.end()) { room_tags.insert("m.direct"); ui_thread_tasks.push([this, room]{ delegate->room_add_tag(room, "m.direct"); }); } room->release_room_lock(); } } } else if(strcmp(type_json.GetString(), "qm.emoji") == 0) { parse_custom_emoji(content_json); rapidjson::StringBuffer buffer; rapidjson::Writer writer(buffer); content_json.Accept(writer); Path matrix_dir = get_cache_dir().join("matrix"); create_directory_recursive(matrix_dir); file_overwrite_atomic(matrix_dir.join("custom_emoji.json"), std::string(buffer.GetString(), buffer.GetSize())); } } return PluginResult::OK; } PluginResult Matrix::parse_sync_room_data(const rapidjson::Value &rooms_json, bool initial_sync) { if(!rooms_json.IsObject()) return PluginResult::OK; const rapidjson::Value &join_json = GetMember(rooms_json, "join"); if(join_json.IsObject()) { for(auto const &it : join_json.GetObject()) { if(!it.value.IsObject()) continue; const rapidjson::Value &room_id = it.name; if(!room_id.IsString()) continue; std::string room_id_str = room_id.GetString(); bool is_new_room = false; RoomData *room = get_room_by_id(room_id_str); if(!room) { auto new_room = std::make_unique(); new_room->id = room_id_str; room = new_room.get(); add_room(std::move(new_room)); is_new_room = true; } const rapidjson::Value &state_json = GetMember(it.value, "state"); if(state_json.IsObject()) { const rapidjson::Value &events_json = GetMember(state_json, "events"); events_add_user_info(events_json, room, 0); events_set_room_info(events_json, room, 0); events_add_pinned_events(events_json, room); } const rapidjson::Value &account_data_json = GetMember(it.value, "account_data"); const rapidjson::Value &timeline_json = GetMember(it.value, "timeline"); if(timeline_json.IsObject()) { bool has_unread_notifications = false; const rapidjson::Value &unread_notification_json = GetMember(it.value, "unread_notifications"); if(unread_notification_json.IsObject()) { const rapidjson::Value &highlight_count_json = GetMember(unread_notification_json, "highlight_count"); if(highlight_count_json.IsInt64() && highlight_count_json.GetInt64() > 0) has_unread_notifications = true; } const rapidjson::Value &prev_batch_json = GetMember(timeline_json, "prev_batch"); const rapidjson::Value &limited_json = GetMember(timeline_json, "limited"); if(limited_json.IsBool() && limited_json.GetBool() == true) { if(prev_batch_json.IsString()) room->set_prev_batch(prev_batch_json.GetString()); room->clear_messages(); ui_thread_tasks.push([this, room]{ delegate->room_clear_messages(room); }); } if(prev_batch_json.IsString() && !room->has_prev_batch()) room->set_prev_batch(prev_batch_json.GetString()); const rapidjson::Value &events_json = GetMember(timeline_json, "events"); events_add_user_info(events_json, room, 0); events_set_room_info(events_json, room, 0); set_room_info_to_users_if_empty(room, my_user_id); if(account_data_json.IsObject()) { const rapidjson::Value &events_json = GetMember(account_data_json, "events"); auto me = get_me(room); events_set_user_read_marker(events_json, room, me); } if(is_new_room) ui_thread_tasks.push([this, room]{ delegate->join_room(room); }); events_add_messages(events_json, room, MessageDirection::AFTER, has_unread_notifications); events_add_pinned_events(events_json, room); } else { set_room_info_to_users_if_empty(room, my_user_id); if(account_data_json.IsObject()) { const rapidjson::Value &events_json = GetMember(account_data_json, "events"); auto me = get_me(room); events_set_user_read_marker(events_json, room, me); } if(is_new_room) ui_thread_tasks.push([this, room]{ delegate->join_room(room); }); } if(remove_invite(room_id_str)) { // TODO: Show leave type and reason and who caused the invite to be removed ui_thread_tasks.push([this, room_id_str{std::move(room_id_str)}]{ delegate->remove_invite(room_id_str); }); } if(account_data_json.IsObject()) { const rapidjson::Value &events_json = GetMember(account_data_json, "events"); events_add_room_to_tags(events_json, room); } if(is_new_room) { room->acquire_room_lock(); std::set &room_tags = room->get_tags_thread_unsafe(); if(room_tags.empty()) { room_tags.insert(OTHERS_ROOM_TAG); ui_thread_tasks.push([this, room]{ delegate->room_add_tag(room, OTHERS_ROOM_TAG); }); } room->release_room_lock(); } } } const rapidjson::Value &leave_json = GetMember(rooms_json, "leave"); remove_rooms(leave_json); const rapidjson::Value &invite_json = GetMember(rooms_json, "invite"); add_invites(invite_json); return PluginResult::OK; } void Matrix::events_add_user_info(const rapidjson::Value &events_json, RoomData *room_data, int64_t timestamp) { if(!events_json.IsArray()) return; for(const rapidjson::Value &event_item_json : events_json.GetArray()) { if(!event_item_json.IsObject()) continue; const rapidjson::Value &type_json = GetMember(event_item_json, "type"); if(!type_json.IsString() || strcmp(type_json.GetString(), "m.room.member") != 0) continue; const rapidjson::Value *sender_json = &GetMember(event_item_json, "sender"); if(!sender_json->IsString()) continue; const rapidjson::Value *state_key_json = &GetMember(event_item_json, "state_key"); if(state_key_json->IsString() && state_key_json->GetStringLength() != 0) sender_json = state_key_json; const rapidjson::Value &content_json = GetMember(event_item_json, "content"); if(!content_json.IsObject()) continue; int64_t item_timestamp = timestamp; if(item_timestamp == 0) { const rapidjson::Value &origin_server_ts = GetMember(event_item_json, "origin_server_ts"); if(origin_server_ts.IsInt64()) item_timestamp = origin_server_ts.GetInt64(); } const rapidjson::Value &is_direct_json = GetMember(content_json, "is_direct"); if(is_direct_json.IsBool() && is_direct_json.GetBool()) { room_data->acquire_room_lock(); std::set &room_tags = room_data->get_tags_thread_unsafe(); auto room_tag_it = room_tags.find("m.direct"); if(room_tag_it == room_tags.end()) { room_tags.insert("m.direct"); ui_thread_tasks.push([this, room_data]{ delegate->room_add_tag(room_data, "m.direct"); }); } room_data->release_room_lock(); } parse_user_info(content_json, sender_json->GetString(), room_data, item_timestamp); } } static std::string thumbnail_url_extract_media_id(const std::string &media_url) { size_t start = 0; size_t end = media_url.size(); if(strncmp(media_url.c_str(), "mxc://", 6) != 0) return ""; start = 6; if(media_url.size() >= start + 5 && strncmp(media_url.c_str() + media_url.size() - 5, "#auto", 5) == 0) end = media_url.size() - 5; return media_url.substr(start, end - start); } static std::string get_avatar_thumbnail_url(const std::string &homeserver, const std::string &mxc_id) { if(mxc_id.empty()) return ""; std::string size = std::to_string(int(32 * get_config().scale)); return homeserver + "/_matrix/media/r0/thumbnail/" + mxc_id + "?width=" + size + "&height=" + size + "&method=crop"; } std::string Matrix::get_media_url(const std::string &mxc_id) { return homeserver + "/_matrix/media/r0/download/" + thumbnail_url_extract_media_id(mxc_id); } std::shared_ptr Matrix::parse_user_info(const rapidjson::Value &json, const std::string &user_id, RoomData *room_data, int64_t timestamp) { assert(json.IsObject()); std::string avatar_url_str; const rapidjson::Value &avatar_url_json = GetMember(json, "avatar_url"); if(avatar_url_json.IsString()) avatar_url_str = std::string(avatar_url_json.GetString(), avatar_url_json.GetStringLength()); const rapidjson::Value &display_name_json = GetMember(json, "displayname"); std::string display_name = display_name_json.IsString() ? display_name_json.GetString() : user_id; std::string avatar_url = thumbnail_url_extract_media_id(avatar_url_str); if(!avatar_url.empty()) avatar_url = get_avatar_thumbnail_url(homeserver, avatar_url); // TODO: Remove the constant strings around to reduce memory usage (6.3mb) //auto user_info = std::make_shared(room_data, user_id, std::move(display_name), std::move(avatar_url)); // Overwrites user data //room_data->add_user(user_info); bool is_new_user; auto user_info = get_user_by_id(room_data, user_id, &is_new_user); bool update_display_name = room_data->set_user_display_name(user_info, display_name, timestamp); bool update_avatar_url = room_data->set_user_avatar_url(user_info, avatar_url, timestamp); MatrixEventUserInfo event_user_info; event_user_info.user_id = user_id; event_user_info.display_name = std::move(display_name); event_user_info.avatar_url = std::move(avatar_url); if(!update_display_name) event_user_info.display_name = std::nullopt; if(!update_avatar_url) event_user_info.avatar_url = std::nullopt; if(update_display_name || update_avatar_url) { if(is_new_user) { trigger_event(room_data, MatrixEventType::ADD_USER, std::move(event_user_info)); } else { trigger_event(room_data, MatrixEventType::USER_INFO, std::move(event_user_info)); } } return user_info; } void Matrix::events_set_user_read_marker(const rapidjson::Value &events_json, RoomData *room_data, std::shared_ptr &me) { assert(me); // TODO: Remove read marker from user and set it for the room instead. We need that in the matrix pages also if(!events_json.IsArray() || !me) return; for(const rapidjson::Value &event_json : events_json.GetArray()) { if(!event_json.IsObject()) continue; const rapidjson::Value &type_json = GetMember(event_json, "type"); if(!type_json.IsString()) continue; if(strcmp(type_json.GetString(), "m.fully_read") == 0) { const rapidjson::Value &content_json = GetMember(event_json, "content"); if(!content_json.IsObject()) continue; const rapidjson::Value &event_id_json = GetMember(content_json, "event_id"); if(!event_id_json.IsString()) continue; room_data->set_user_read_marker(me, std::string(event_id_json.GetString(), event_id_json.GetStringLength())); } else if(strcmp(type_json.GetString(), "qm.last_read_message_timestamp") == 0) { // TODO: Remove qm.last_read_message_timestamp in room level eventually when everybody has data in global level const rapidjson::Value &content_json = GetMember(event_json, "content"); if(!content_json.IsObject()) continue; const rapidjson::Value ×tamp_json = GetMember(content_json, "timestamp"); if(!timestamp_json.IsInt64()) continue; if(timestamp_json.GetInt64() != timestamp_provisional_event && timestamp_json.GetInt64() > room_data->read_marker_event_timestamp) room_data->read_marker_event_timestamp = timestamp_json.GetInt64(); } } } static bool message_content_extract_thumbnail_size(const rapidjson::Value &content_json, mgl::vec2i &thumbnail_size) { const rapidjson::Value &info_json = GetMember(content_json, "info"); if(!info_json.IsObject()) return false; bool found_resolution = false; const rapidjson::Value &w_json = GetMember(info_json, "w"); const rapidjson::Value &h_json = GetMember(info_json, "h"); if(w_json.IsInt() && h_json.IsInt()) { thumbnail_size.x = w_json.GetInt(); thumbnail_size.y = h_json.GetInt(); found_resolution = true; } const rapidjson::Value &thumbnail_info_json = GetMember(info_json, "thumbnail_info"); if(thumbnail_info_json.IsObject()) { const rapidjson::Value &w_json = GetMember(thumbnail_info_json, "w"); const rapidjson::Value &h_json = GetMember(thumbnail_info_json, "h"); if(w_json.IsInt() && h_json.IsInt()) { thumbnail_size.x = w_json.GetInt(); thumbnail_size.y = h_json.GetInt(); found_resolution = true; } } return found_resolution; } static std::string message_content_extract_thumbnail_url(const rapidjson::Value &content_json, const std::string &homeserver) { const rapidjson::Value &info_json = GetMember(content_json, "info"); if(info_json.IsObject()) { const rapidjson::Value &thumbnail_url_json = GetMember(info_json, "thumbnail_url"); if(thumbnail_url_json.IsString()) { std::string thumbnail_str = thumbnail_url_json.GetString(); if(strncmp(thumbnail_str.c_str(), "mxc://", 6) != 0) return ""; thumbnail_str.erase(thumbnail_str.begin(), thumbnail_str.begin() + 6); return homeserver + "/_matrix/media/r0/download/" + std::move(thumbnail_str); } } return ""; } // TODO: Custom names for power levels static std::string power_level_to_name(int power_level) { switch(power_level) { case 0: return "Default"; case 50: return "Moderator"; case 100: return "Administrator"; default: return "Custom (" + std::to_string(power_level) + ")"; } } static bool power_level_is_at_least_admin(int power_level, RoomData *room) { return power_level >= room->notification_power_level; } // TODO: Is this really the proper way to check for username mentions? static bool is_username_seperating_character(char c) { switch(c) { case ' ': case '\n': case '\t': case '\v': case '.': case ',': case '@': case ':': case ';': case '?': case '!': case '<': case '>': case '{': case '}': case '[': case ']': case '\'': case '"': case '#': case '\0': return true; default: return false; } return false; } // TODO: Do not show notification if mention is a reply to somebody else that replies to me? also dont show notification everytime a mention is edited static bool message_contains_user_mention(const std::string &msg, const std::string &username) { if(msg.empty() || username.empty()) return false; size_t index = 0; while(index < msg.size()) { size_t found_index = str_find_case_insensitive(msg, index, username.c_str(), username.size()); if(found_index == std::string::npos) break; char prev_char = ' '; if(found_index > 0) prev_char = msg[found_index - 1]; char next_char = '\0'; if(found_index + username.size() < msg.size() - 1) next_char = msg[found_index + username.size()]; if(is_username_seperating_character(prev_char) && is_username_seperating_character(next_char)) return true; index = found_index + username.size(); } return false; } bool message_contains_user_mention(Matrix *matrix, const Message *message, const std::string &username, const std::string &user_id) { if(message->body_is_formatted) { if(message->body.find("href=\"https://matrix.to/#/" + user_id + "\"") != std::string::npos) return true; } else { const std::string mention_str = "> <" + user_id + ">"; if(string_starts_with(message->body, mention_str.c_str())) return true; } const std::string formatted_text = message_to_qm_text(matrix, message, false); return message_contains_user_mention(formatted_text, username) || message_contains_user_mention(formatted_text, user_id) || (power_level_is_at_least_admin(message->user->power_level, message->user->room) && message_contains_user_mention(formatted_text, "@room")); } bool message_contains_user_mention(const BodyItem *body_item, const std::string &username, const std::string &user_id) { const std::string formatted_text = Text::to_printable_string(body_item->get_description()); return message_contains_user_mention(formatted_text, username) || message_contains_user_mention(formatted_text, user_id) || (body_item->userdata && power_level_is_at_least_admin(static_cast(body_item->userdata)->user->power_level, static_cast(body_item->userdata)->user->room) && message_contains_user_mention(formatted_text, "®room")); } bool message_is_timeline(Message *message) { return message->type >= MessageType::TEXT && message->type <= MessageType::FILE; } void Matrix::append_system_message(RoomData *room_data, std::shared_ptr message) { Messages new_messages; new_messages.push_back(std::move(message)); room_data->append_messages(new_messages); ui_thread_tasks.push([this, room_data, new_messages{std::move(new_messages)}]{ delegate->room_add_new_messages(room_data, new_messages, false, MessageDirection::AFTER); }); } // Note: has_unread_notifications cant really be trusted because it's from synapse and synapse can return old cached data... // But its better than nothing when its the first time we launch quickmedia (or the first time we see a room) and have no read markers in a room. size_t Matrix::events_add_messages(const rapidjson::Value &events_json, RoomData *room_data, MessageDirection message_dir, bool has_unread_notifications) { if(!events_json.IsArray()) return 0; // TODO: Preallocate Messages new_messages; auto me = get_me(room_data); std::string my_display_name = room_data->get_user_display_name(me); for(const rapidjson::Value &event_item_json : events_json.GetArray()) { std::shared_ptr new_message = parse_message_event(event_item_json, room_data); if(new_message) new_messages.push_back(std::move(new_message)); } if(new_messages.empty()) return 0; size_t num_new_messages = 0; if(message_dir == MessageDirection::BEFORE) { num_new_messages = room_data->prepend_messages_reverse(new_messages); } else if(message_dir == MessageDirection::AFTER) { num_new_messages = room_data->append_messages(new_messages); } int64_t read_marker_message_timestamp = 0; if(me) { auto read_marker_message = room_data->get_message_by_id(room_data->get_user_read_marker(me)); if(read_marker_message) read_marker_message_timestamp = read_marker_message->timestamp; } // TODO: Make sure |events_set_user_read_marker| is called before |events_add_messages| so this is set const int64_t qm_read_marker = room_data->read_marker_event_timestamp; if(read_marker_message_timestamp == 0 || read_marker_message_timestamp < qm_read_marker) read_marker_message_timestamp = qm_read_marker; const bool may_have_unread_notifications = read_marker_message_timestamp > 0 || has_unread_notifications; std::lock_guard lock(notifications_mutex); for(auto &message : new_messages) { // TODO: Is @room ok? shouldn't we also check if the user has permission to do @room? (only when notifications are limited to @mentions) // TODO: Is comparing against read marker timestamp ok enough? if(message_is_timeline(message.get()) && me && message->timestamp > read_marker_message_timestamp && may_have_unread_notifications) { const std::string message_str = message_to_qm_text(this, message.get(), false); message->notification_mentions_me = message_contains_user_mention(this, message.get(), my_display_name, me->user_id) || (has_unread_notifications && message_contains_user_mention(message_str, "@room")); // TODO: ... //|| (power_level_is_at_least_admin(message->user->power_level, room_data) && message_contains_user_mention(message_str, "@room")); } } bool is_initial_sync = next_batch.empty(); ui_thread_tasks.push([this, room_data, new_messages{std::move(new_messages)}, is_initial_sync, message_dir]{ delegate->room_add_new_messages(room_data, new_messages, is_initial_sync, message_dir); }); return num_new_messages; } struct UserPowerLevelChange { int new_power_level = 0; int old_power_level = 0; }; // TODO: Use user display names instead of id. Also update display names after retrieving them static std::string power_levels_change_to_string(RoomData *room, const std::shared_ptr &changed_by, const std::map &power_levels_change) { const std::string changed_by_name = room->get_user_display_name(changed_by); std::string result; for(const auto &change : power_levels_change) { if(change.second.new_power_level == change.second.old_power_level) continue; if(!result.empty()) result += '\n'; result += changed_by_name + " changed the power level of " + change.first + " from " + power_level_to_name(change.second.old_power_level) + " to " + power_level_to_name(change.second.new_power_level) + "."; } return result; } // Returns -1 if its not a hex value static int get_hex_value(char c) { if(c >= '0' && c <= '9') return c - '0'; else if(c >= 'a' && c <= 'f') return 10 + (c - 'a'); else if(c >= 'A' && c <= 'F') return 10 + (c - 'A'); else return -1; } // Parses hex colors in the format #RRGGBB(AA) static bool parse_hex_set_color(const char *str, int size, mgl::Color &color) { if(size == 0) return false; // #RRGGBB(AA), case insensitive hex if(str[0] != '#') return false; if(size - 1 != 6 && size - 1 != 8) return false; mgl::Color new_color; for(int i = 1; i < size; i += 2) { const int c1 = get_hex_value(str[i + 0]); const int c2 = get_hex_value(str[i + 1]); if(c1 == -1 || c2 == -1) return false; (&new_color.r)[(i - 1)/2] = (c1 << 4) | c2; } color = new_color; return true; } struct FormattedTextParseUserdata { std::string result; int mx_reply_depth = 0; bool inside_font_tag = false; bool font_tag_has_custom_color = false; bool inside_code_tag = false; std::string_view code_tag_language; bool allow_formatted_text = false; bool inside_source_highlight = false; bool supports_syntax_highlight = false; bool inside_img_tag = false; std::string_view img_src; std::string_view img_alt; mgl::vec2i img_size; mgl::Color font_color = mgl::Color(255, 255, 255, 255); Matrix *matrix = nullptr; mgl::vec2i image_max_size; }; // TODO: Full proper parsing with tag depth static int formattext_text_parser_callback(HtmlParser *html_parser, HtmlParseType parse_type, void *userdata) { FormattedTextParseUserdata &parse_userdata = *(FormattedTextParseUserdata*)userdata; switch(parse_type) { case HTML_PARSE_TAG_START: { if(html_parser->tag_name.size == 2 && memcmp(html_parser->tag_name.data, "br", 2) == 0 && parse_userdata.mx_reply_depth == 0) parse_userdata.result += '\n'; else if(html_parser->tag_name.size == 4 && memcmp(html_parser->tag_name.data, "font", 4) == 0) parse_userdata.inside_font_tag = true; else if(html_parser->tag_name.size == 8 && memcmp(html_parser->tag_name.data, "mx-reply", 8) == 0) ++parse_userdata.mx_reply_depth; else if(html_parser->tag_name.size == 4 && memcmp(html_parser->tag_name.data, "code", 4) == 0) { parse_userdata.inside_code_tag = true; parse_userdata.code_tag_language = std::string_view(); } else if(html_parser->tag_name.size == 3 && memcmp(html_parser->tag_name.data, "img", 3) == 0) { parse_userdata.inside_img_tag = true; parse_userdata.img_src = std::string_view(); parse_userdata.img_alt = std::string_view(); parse_userdata.img_size = { 0, 0 }; } break; } case HTML_PARSE_TAG_END: { /*if(html_parser->tag_name.size == 2 && memcmp(html_parser->tag_name.data, "br", 2) == 0 && parse_userdata.mx_reply_depth == 0) { parse_userdata.result += '\n'; } else */if(html_parser->tag_name.size == 4 && memcmp(html_parser->tag_name.data, "font", 4) == 0) { parse_userdata.inside_font_tag = false; parse_userdata.font_tag_has_custom_color = false; } else if(html_parser->tag_name.size == 8 && memcmp(html_parser->tag_name.data, "mx-reply", 8) == 0) { parse_userdata.mx_reply_depth = std::max(0, parse_userdata.mx_reply_depth - 1); } else if(html_parser->tag_name.size == 4 && memcmp(html_parser->tag_name.data, "code", 4) == 0) { parse_userdata.inside_code_tag = false; } else if(html_parser->tag_name.size == 3 && memcmp(html_parser->tag_name.data, "img", 3) == 0) { if(parse_userdata.allow_formatted_text && parse_userdata.matrix && parse_userdata.mx_reply_depth == 0 && parse_userdata.inside_img_tag && parse_userdata.img_src.size() > 0) { std::string image_url(parse_userdata.img_src); html_unescape_sequences(image_url); mgl::vec2i img_size = parse_userdata.img_size; // TODO: Better solution when size not given? if(img_size.x == 0 || img_size.y == 0) img_size = custom_emoji_max_size; if(parse_userdata.image_max_size.x > 0 && parse_userdata.image_max_size.y > 0) img_size = clamp_to_size(img_size, parse_userdata.image_max_size); parse_userdata.result += Text::formatted_image(parse_userdata.matrix->get_media_url(image_url), false, img_size, std::string(parse_userdata.img_alt)); } parse_userdata.inside_img_tag = false; } break; } case HTML_PARSE_ATTRIBUTE: { if(parse_userdata.inside_font_tag && html_parser->attribute_key.size == 5 && memcmp(html_parser->attribute_key.data, "color", 5) == 0) { if(parse_hex_set_color(html_parser->attribute_value.data, html_parser->attribute_value.size, parse_userdata.font_color)) parse_userdata.font_tag_has_custom_color = true; } else if(parse_userdata.inside_code_tag && html_parser->attribute_key.size == 5 && memcmp(html_parser->attribute_key.data, "class", 5) == 0) { if(html_parser->attribute_value.size > 9 && memcmp(html_parser->attribute_value.data, "language-", 9) == 0) parse_userdata.code_tag_language = std::string_view(html_parser->attribute_value.data + 9, html_parser->attribute_value.size - 9); } else if(parse_userdata.allow_formatted_text && parse_userdata.inside_img_tag) { if(html_parser->attribute_key.size == 3 && memcmp(html_parser->attribute_key.data, "src", 3) == 0) { parse_userdata.img_src = std::string_view(html_parser->attribute_value.data, html_parser->attribute_value.size); } else if(html_parser->attribute_key.size == 5 && memcmp(html_parser->attribute_key.data, "width", 5) == 0) { const std::string width(html_parser->attribute_value.data, html_parser->attribute_value.size); parse_userdata.img_size.x = atoi(width.c_str()); } else if(html_parser->attribute_key.size == 6 && memcmp(html_parser->attribute_key.data, "height", 6) == 0) { const std::string height(html_parser->attribute_value.data, html_parser->attribute_value.size); parse_userdata.img_size.y = atoi(height.c_str()); } else if(html_parser->attribute_key.size == 3 && memcmp(html_parser->attribute_key.data, "alt", 3) == 0) { parse_userdata.img_alt = std::string_view(html_parser->attribute_value.data, html_parser->attribute_value.size); } } else if(!parse_userdata.allow_formatted_text && parse_userdata.inside_img_tag && parse_userdata.mx_reply_depth == 0 && html_parser->attribute_key.size == 3 && memcmp(html_parser->attribute_key.data, "alt", 3) == 0) { std::string text_to_add(html_parser->attribute_value.data, html_parser->attribute_value.size); html_unescape_sequences(text_to_add); parse_userdata.result += std::move(text_to_add); } break; } case HTML_PARSE_TEXT: case HTML_PARSE_JAVASCRIPT_CODE: { if(parse_userdata.mx_reply_depth == 0) { std::string text_to_add(html_parser->text.data, html_parser->text.size); html_unescape_sequences(text_to_add); uint8_t formatted_text_flags = FORMATTED_TEXT_FLAG_NONE; if(parse_userdata.allow_formatted_text) { if(parse_userdata.font_tag_has_custom_color) formatted_text_flags |= FORMATTED_TEXT_FLAG_COLOR; if(parse_userdata.inside_source_highlight || (parse_userdata.inside_code_tag && (parse_userdata.code_tag_language.size() == 0 || !parse_userdata.supports_syntax_highlight))) { formatted_text_flags |= FORMATTED_TEXT_FLAG_CODE; } else if(parse_userdata.inside_code_tag) { formatted_text_flags |= FORMATTED_TEXT_FLAG_CODE; // TODO: guess language from code if no language is set. // TODO: Allow the user to choose style in config file. const std::string code_language(parse_userdata.code_tag_language); const char *args[] = { "source-highlight", "-f", "html", "-s", code_language.c_str(), "--style-file=esc256.style", "-o", "STDOUT", nullptr }; std::string output; if(exec_program_write_stdin(args, text_to_add.c_str(), text_to_add.size(), accumulate_string, &output) == 0) { FormattedTextParseUserdata code_parse_userdata; code_parse_userdata.allow_formatted_text = true; code_parse_userdata.inside_source_highlight = true; html_parser_parse(output.c_str(), output.size(), formattext_text_parser_callback, &code_parse_userdata); if(!code_parse_userdata.result.empty()) { text_to_add = std::move(code_parse_userdata.result); formatted_text_flags = FORMATTED_TEXT_FLAG_NONE; } } } } if(formatted_text_flags != FORMATTED_TEXT_FLAG_NONE) parse_userdata.result += Text::formatted_text(text_to_add, parse_userdata.font_color, formatted_text_flags); else parse_userdata.result += std::move(text_to_add); } break; } } return 0; } std::string formatted_text_to_qm_text(Matrix *matrix, const char *str, size_t size, bool allow_formatted_text, mgl::vec2i image_max_size) { FormattedTextParseUserdata parse_userdata; parse_userdata.allow_formatted_text = allow_formatted_text; parse_userdata.supports_syntax_highlight = is_program_executable_by_name("source-highlight"); parse_userdata.matrix = matrix; parse_userdata.image_max_size = image_max_size; html_parser_parse(str, size, formattext_text_parser_callback, &parse_userdata); return std::move(parse_userdata.result); } std::shared_ptr Matrix::parse_message_event(const rapidjson::Value &event_item_json, RoomData *room_data) { if(!event_item_json.IsObject()) return nullptr; const rapidjson::Value *sender_json = &GetMember(event_item_json, "sender"); const rapidjson::Value *sender_json_orig = sender_json; if(!sender_json->IsString()) return nullptr; const rapidjson::Value &type_json = GetMember(event_item_json, "type"); if(!type_json.IsString()) return nullptr; bool sent_by_somebody_else = false; const rapidjson::Value *state_key_json = &GetMember(event_item_json, "state_key"); if(state_key_json->IsString() && state_key_json->GetStringLength() != 0) { if(strcmp(type_json.GetString(), "m.room.member") != 0) { fprintf(stderr, "Matrix: received state key %s but event type is %s, expected m.room.member\n", type_json.GetString(), state_key_json->GetString()); return nullptr; } if(strcmp(sender_json->GetString(), state_key_json->GetString()) != 0) sent_by_somebody_else = true; sender_json = state_key_json; } std::string sender_json_str = sender_json->GetString(); const rapidjson::Value &event_id_json = GetMember(event_item_json, "event_id"); if(!event_id_json.IsString()) return nullptr; std::string event_id_str = event_id_json.GetString(); const rapidjson::Value *content_json = &GetMember(event_item_json, "content"); if(!content_json->IsObject()) return nullptr; bool is_new_user; auto user = get_user_by_id(room_data, sender_json_str, &is_new_user); if(is_new_user) { MatrixEventUserInfo user_info; user_info.user_id = user->user_id; trigger_event(room_data, MatrixEventType::ADD_USER, std::move(user_info)); } auto user_sender = user; if(sent_by_somebody_else) { bool is_new_user; user_sender = get_user_by_id(room_data, sender_json_orig->GetString(), &is_new_user); if(is_new_user) { MatrixEventUserInfo user_info; user_info.user_id = user_sender->user_id; trigger_event(room_data, MatrixEventType::ADD_USER, std::move(user_info)); } } time_t timestamp = 0; const rapidjson::Value &origin_server_ts = GetMember(event_item_json, "origin_server_ts"); if(origin_server_ts.IsInt64()) timestamp = origin_server_ts.GetInt64(); std::string transaction_id; const rapidjson::Value &unsigned_json = GetMember(event_item_json, "unsigned"); if(unsigned_json.IsObject()) { const rapidjson::Value &transaction_id_json = GetMember(unsigned_json, "transaction_id"); if(transaction_id_json.IsString()) transaction_id = std::string(transaction_id_json.GetString(), transaction_id_json.GetStringLength()); } RelatedEventType related_event_type = RelatedEventType::NONE; std::string related_event_id; const rapidjson::Value &relates_to_json = GetMember(*content_json, "m.relates_to"); if(relates_to_json.IsObject()) { const rapidjson::Value &relates_to_event_id_json = GetMember(relates_to_json, "event_id"); const rapidjson::Value &rel_type_json = GetMember(relates_to_json, "rel_type"); const rapidjson::Value &in_reply_to_json = GetMember(relates_to_json, "m.in_reply_to"); const rapidjson::Value &key_json = GetMember(relates_to_json, "key"); if(relates_to_event_id_json.IsString() && rel_type_json.IsString() && strcmp(rel_type_json.GetString(), "m.replace") == 0) { related_event_id = relates_to_event_id_json.GetString(); related_event_type = RelatedEventType::EDIT; } else if(in_reply_to_json.IsObject()) { const rapidjson::Value &in_reply_to_event_id = GetMember(in_reply_to_json, "event_id"); if(in_reply_to_event_id.IsString()) { related_event_id = in_reply_to_event_id.GetString(); related_event_type = RelatedEventType::REPLY; } } else if(relates_to_event_id_json.IsString() && rel_type_json.IsString() && strcmp(rel_type_json.GetString(), "m.annotation") == 0 && key_json.IsString()) { if(strcmp(type_json.GetString(), "m.reaction") == 0) { auto message = std::make_shared(); message->type = MessageType::REACTION; message->user = user; message->event_id = event_id_str; message->body = key_json.GetString(); message->related_event_id = relates_to_event_id_json.GetString(); message->related_event_type = RelatedEventType::REACTION; message->timestamp = timestamp; message->transaction_id = std::move(transaction_id); return message; } } } /* if(related_event_id.empty() && unsigned_json.IsObject()) { const rapidjson::Value &relations_json = GetMember(unsigned_json, "m.relations"); if(relations_json.IsObject()) { const rapidjson::Value &replace_json = GetMember(relations_json, "m.replace"); if(replace_json.IsObject()) { const rapidjson::Value &event_id_json = GetMember(replace_json, "event_id"); const rapidjson::Value &origin_server_ts = GetMember(replace_json, "origin_server_ts"); if(origin_server_ts.IsInt64()) timestamp = origin_server_ts.GetInt64(); if(event_id_json.IsString()) { related_event_id = event_id_json.GetString(); related_event_type = RelatedEventType::EDIT; } } } } */ const rapidjson::Value &new_content_json = GetMember(*content_json, "m.new_content"); if(new_content_json.IsObject()) content_json = &new_content_json; const rapidjson::Value &content_type = GetMember(*content_json, "msgtype"); if(strcmp(type_json.GetString(), "m.room.redaction") == 0) { auto message = std::make_shared(); message->type = MessageType::REDACTION; message->user = user; message->event_id = event_id_str; message->body = "Message deleted"; message->timestamp = timestamp; message->related_event_type = RelatedEventType::REDACTION; message->transaction_id = std::move(transaction_id); if(sent_by_somebody_else) { std::string sender_display_name = extract_first_line_remove_newline_elipses(room_data->get_user_display_name(user_sender), AUTHOR_MAX_LENGTH); message->body += " by " + sender_display_name; } const rapidjson::Value &reason_json = GetMember(*content_json, "reason"); if(reason_json.IsString()) { message->body += ", reason: "; message->body += reason_json.GetString(); } const rapidjson::Value &redacts_json = GetMember(event_item_json, "redacts"); if(redacts_json.IsString()) message->related_event_id = redacts_json.GetString(); return message; } if(strcmp(type_json.GetString(), "m.room.message") == 0 || strcmp(type_json.GetString(), "m.sticker") == 0) { } else if(strcmp(type_json.GetString(), "m.reaction") == 0) { // An old reaction that has been removed. New reactions are removed with m.redact return nullptr; } else if(strcmp(type_json.GetString(), "m.room.member") == 0) { std::string user_display_name = extract_first_line_remove_newline_elipses(room_data->get_user_display_name(user), AUTHOR_MAX_LENGTH); std::string sender_display_name = extract_first_line_remove_newline_elipses(room_data->get_user_display_name(user_sender), AUTHOR_MAX_LENGTH); std::string body; std::string reason_str; const rapidjson::Value &reason_json = GetMember(*content_json, "reason"); if(reason_json.IsString()) reason_str = std::string(reason_json.GetString(), reason_json.GetStringLength()); const rapidjson::Value &membership_json = GetMember(*content_json, "membership"); if(strcmp(membership_json.GetString(), "join") == 0) { if(unsigned_json.IsObject()) { const rapidjson::Value &prev_sender = GetMember(unsigned_json, "prev_sender"); const rapidjson::Value &prev_content_json = GetMember(unsigned_json, "prev_content"); if(prev_content_json.IsObject() && (!prev_sender.IsString() || strcmp(prev_sender.GetString(), user->user_id.c_str()) == 0)) { const rapidjson::Value &prev_displayname_json = GetMember(prev_content_json, "displayname"); const rapidjson::Value &prev_avatar_url_json = GetMember(prev_content_json, "avatar_url"); const rapidjson::Value &new_displayname_json = GetMember(*content_json, "displayname"); const rapidjson::Value &new_avatar_url_json = GetMember(*content_json, "avatar_url"); const rapidjson::Value &prev_membership_json = GetMember(prev_content_json, "membership"); std::optional new_display_name; std::optional new_avatar_url; bool update_user_display_info = false; if(prev_membership_json.IsString() && strcmp(prev_membership_json.GetString(), "leave") == 0) { body = user_display_name + " joined the room"; } else if(new_displayname_json.IsString() && new_displayname_json.GetStringLength() > 0 && (!prev_displayname_json.IsString() || strcmp(new_displayname_json.GetString(), prev_displayname_json.GetString()) != 0)) { std::string new_displayname_str = std::string(new_displayname_json.GetString()); std::string prev_displayname_str; if(prev_displayname_json.IsString()) prev_displayname_str = std::string(prev_displayname_json.GetString(), prev_displayname_json.GetStringLength()); else prev_displayname_str = sender_json_str; body = extract_first_line_remove_newline_elipses(prev_displayname_str, AUTHOR_MAX_LENGTH) + " changed his display name to " + extract_first_line_remove_newline_elipses(new_displayname_str, AUTHOR_MAX_LENGTH); new_display_name = new_displayname_str; update_user_display_info = room_data->set_user_display_name(user, std::move(new_displayname_str), timestamp); } else if((!new_displayname_json.IsString() || new_displayname_json.GetStringLength() == 0) && prev_displayname_json.IsString()) { body = user_display_name + " removed his display name"; new_display_name = ""; update_user_display_info = room_data->set_user_display_name(user, "", timestamp); } else if(new_avatar_url_json.IsString() && new_avatar_url_json.GetStringLength() > 0 && (!prev_avatar_url_json.IsString() || strcmp(new_avatar_url_json.GetString(), prev_avatar_url_json.GetString()) != 0)) { body = user_display_name + " changed his profile picture"; std::string new_avatar_url_str = thumbnail_url_extract_media_id(new_avatar_url_json.GetString()); if(!new_avatar_url_str.empty()) new_avatar_url_str = get_avatar_thumbnail_url(homeserver, new_avatar_url_str); // TODO: Remove the constant strings around to reduce memory usage (6.3mb) new_avatar_url = new_avatar_url_str; update_user_display_info = room_data->set_user_avatar_url(user, std::move(new_avatar_url_str), timestamp); } else if((!new_avatar_url_json.IsString() || new_avatar_url_json.GetStringLength() == 0) && prev_avatar_url_json.IsString()) { body = user_display_name + " removed his profile picture"; new_avatar_url = ""; update_user_display_info = room_data->set_user_avatar_url(user, "", timestamp); } else { body = user_display_name + " joined the room"; } if(update_user_display_info) { MatrixEventUserInfo user_info; user_info.user_id = user->user_id; user_info.display_name = std::move(new_display_name); user_info.avatar_url = std::move(new_avatar_url); trigger_event(room_data, MatrixEventType::USER_INFO, std::move(user_info)); } } else { body = user_display_name + " joined the room"; } } else { body = user_display_name + " joined the room"; } } else if(strcmp(membership_json.GetString(), "leave") == 0) { if(sent_by_somebody_else) { bool known_action = false; if(unsigned_json.IsObject()) { const rapidjson::Value &prev_content_json = GetMember(unsigned_json, "prev_content"); if(prev_content_json.IsObject()) { const rapidjson::Value &prev_membership_json = GetMember(prev_content_json, "membership"); if(prev_membership_json.IsString() && strcmp(prev_membership_json.GetString(), "ban") == 0) { body = user_display_name + " was unbanned from the room by " + sender_display_name; known_action = true; } else if(prev_membership_json.IsString() && strcmp(prev_membership_json.GetString(), "invite") == 0) { body = sender_display_name + " withdrew " + user_display_name + "'s invitation"; known_action = true; } } } if(!known_action) body = user_display_name + " was kicked from the room by " + sender_display_name; } else { bool known_action = false; if(unsigned_json.IsObject()) { const rapidjson::Value &prev_content_json = GetMember(unsigned_json, "prev_content"); if(prev_content_json.IsObject()) { const rapidjson::Value &prev_membership_json = GetMember(prev_content_json, "membership"); if(prev_membership_json.IsString() && strcmp(prev_membership_json.GetString(), "invite") == 0) { body = user_display_name + " rejected the invitation"; known_action = true; } } } if(!known_action) body = user_display_name + " left the room"; } if(!reason_str.empty()) body += ", reason: " + reason_str; } else if(strcmp(membership_json.GetString(), "invite") == 0) { body = user_display_name + " was invited to the room by " + sender_display_name; } else if(strcmp(membership_json.GetString(), "ban") == 0) { body = user_display_name + " was banned from the room by " + sender_display_name; if(!reason_str.empty()) body += ", reason: " + reason_str; } else { body = "unimplemented membership: " + std::string(membership_json.GetString()); } auto message = std::make_shared(); message->type = MessageType::MEMBERSHIP; message->user = user; message->event_id = event_id_str; message->body = std::move(body); message->related_event_id = std::move(related_event_id); message->related_event_type = related_event_type; message->timestamp = timestamp; message->transaction_id = std::move(transaction_id); return message; } else if(strcmp(type_json.GetString(), "m.room.power_levels") == 0) { const rapidjson::Value ¬ifications_json = GetMember(*content_json, "notifications"); if(notifications_json.IsObject()) { const rapidjson::Value &room_json = GetMember(notifications_json, "room"); if(room_json.IsInt()) room_data->notification_power_level = room_json.GetInt(); } // TODO: Implement users_default const rapidjson::Value &users_json = GetMember(*content_json, "users"); if(!users_json.IsObject()) return nullptr; std::map power_level_changes; for(auto const &user_json : users_json.GetObject()) { if(!user_json.name.IsString() || !user_json.value.IsInt()) continue; std::string user_id(user_json.name.GetString(), user_json.name.GetStringLength()); get_user_by_id(room_data, user_id)->power_level = user_json.value.GetInt(); power_level_changes[std::move(user_id)].new_power_level = user_json.value.GetInt(); } if(unsigned_json.IsObject()) { // TODO: What about top level prev_content? const rapidjson::Value &unsigned_prev_content_json = GetMember(unsigned_json, "prev_content"); if(unsigned_prev_content_json.IsObject()) { const rapidjson::Value &prev_content_users_json = GetMember(unsigned_prev_content_json, "users"); if(prev_content_users_json.IsObject()) { for(auto const &user_json : prev_content_users_json.GetObject()) { if(!user_json.name.IsString() || !user_json.value.IsInt()) continue; std::string user_id(user_json.name.GetString(), user_json.name.GetStringLength()); power_level_changes[std::move(user_id)].old_power_level = user_json.value.GetInt(); } } } } auto message = std::make_shared(); message->type = MessageType::SYSTEM; message->user = user; message->event_id = event_id_str; message->body = power_levels_change_to_string(room_data, user_sender, power_level_changes); message->related_event_id = std::move(related_event_id); message->related_event_type = related_event_type; message->timestamp = timestamp; message->transaction_id = std::move(transaction_id); if(message->body.empty()) return nullptr; return message; } else if(strcmp(type_json.GetString(), "m.room.pinned_events") == 0) { auto message = std::make_shared(); message->type = MessageType::SYSTEM; message->user = user; message->event_id = event_id_str; message->body = extract_first_line_remove_newline_elipses(room_data->get_user_display_name(user_sender), AUTHOR_MAX_LENGTH) + " changed the pinned messages for the room."; message->related_event_id = std::move(related_event_id); message->related_event_type = related_event_type; message->timestamp = timestamp; message->transaction_id = std::move(transaction_id); return message; } else if(strcmp(type_json.GetString(), "m.room.name") == 0) { std::string sender_display_name = extract_first_line_remove_newline_elipses(room_data->get_user_display_name(user_sender), AUTHOR_MAX_LENGTH); const rapidjson::Value &new_room_name = GetMember(*content_json, "name"); auto message = std::make_shared(); message->type = MessageType::SYSTEM; message->user = user; message->event_id = event_id_str; if(new_room_name.IsString() && new_room_name.GetStringLength() > 0) message->body = sender_display_name + " changed the room name to \"" + new_room_name.GetString() + "\""; else message->body = sender_display_name + " removed the room name"; message->related_event_id = std::move(related_event_id); message->related_event_type = related_event_type; message->timestamp = timestamp; message->transaction_id = std::move(transaction_id); return message; } else if(strcmp(type_json.GetString(), "m.room.topic") == 0) { std::string sender_display_name = extract_first_line_remove_newline_elipses(room_data->get_user_display_name(user_sender), AUTHOR_MAX_LENGTH); const rapidjson::Value &new_room_topic = GetMember(*content_json, "topic"); auto message = std::make_shared(); message->type = MessageType::SYSTEM; message->user = user; message->event_id = event_id_str; if(new_room_topic.IsString() && new_room_topic.GetStringLength() > 0) message->body = sender_display_name + " changed the room topic to \"" + new_room_topic.GetString() + "\""; else message->body = sender_display_name + " removed the room topic"; message->related_event_id = std::move(related_event_id); message->related_event_type = related_event_type; message->timestamp = timestamp; message->transaction_id = std::move(transaction_id); return message; } else if(strcmp(type_json.GetString(), "m.room.avatar") == 0) { std::string sender_display_name = extract_first_line_remove_newline_elipses(room_data->get_user_display_name(user_sender), AUTHOR_MAX_LENGTH); auto message = std::make_shared(); message->type = MessageType::SYSTEM; message->user = user; message->event_id = event_id_str; message->body = sender_display_name + " changed the room avatar"; message->related_event_id = std::move(related_event_id); message->related_event_type = related_event_type; message->timestamp = timestamp; message->transaction_id = std::move(transaction_id); return message; } else { auto message = std::make_shared(); message->type = MessageType::UNIMPLEMENTED; message->user = user; message->event_id = event_id_str; message->body = "unimplemented event type: " + std::string(type_json.GetString()); message->related_event_id = std::move(related_event_id); message->related_event_type = related_event_type; message->timestamp = timestamp; message->transaction_id = std::move(transaction_id); return message; } const rapidjson::Value &body_json = GetMember(*content_json, "body"); if(!body_json.IsString()) return nullptr; bool body_is_formatted = true; const rapidjson::Value *formatted_body_json = &GetMember(*content_json, "formatted_body"); if(!formatted_body_json->IsString()) { formatted_body_json = &body_json; body_is_formatted = false; } auto message = std::make_shared(); std::string prefix; // TODO: Also show joins, leave, invites, bans, kicks, mutes, etc if((content_type.IsString() && strcmp(content_type.GetString(), "m.image") == 0) || strcmp(type_json.GetString(), "m.sticker") == 0) { const rapidjson::Value &url_json = GetMember(*content_json, "url"); if(!url_json.IsString() || strncmp(url_json.GetString(), "mxc://", 6) != 0) return nullptr; message->url = homeserver + "/_matrix/media/r0/download/" + (url_json.GetString() + 6); message->thumbnail_url = message_content_extract_thumbnail_url(*content_json, homeserver); message_content_extract_thumbnail_size(*content_json, message->thumbnail_size); message->type = MessageType::IMAGE; } else if(!content_type.IsString() || strcmp(content_type.GetString(), "m.text") == 0) { message->type = MessageType::TEXT; } else if(strcmp(content_type.GetString(), "m.video") == 0) { const rapidjson::Value &url_json = GetMember(*content_json, "url"); if(!url_json.IsString() || strncmp(url_json.GetString(), "mxc://", 6) != 0) return nullptr; message->url = homeserver + "/_matrix/media/r0/download/" + (url_json.GetString() + 6); message->thumbnail_url = message_content_extract_thumbnail_url(*content_json, homeserver); message_content_extract_thumbnail_size(*content_json, message->thumbnail_size); message->type = MessageType::VIDEO; if(message->thumbnail_url.empty()) prefix = "🎥 Play "; } else if(strcmp(content_type.GetString(), "m.audio") == 0) { const rapidjson::Value &url_json = GetMember(*content_json, "url"); if(!url_json.IsString() || strncmp(url_json.GetString(), "mxc://", 6) != 0) return nullptr; message->url = homeserver + "/_matrix/media/r0/download/" + (url_json.GetString() + 6); message->type = MessageType::AUDIO; prefix = "🎵 Play "; } else if(strcmp(content_type.GetString(), "m.file") == 0) { const rapidjson::Value &url_json = GetMember(*content_json, "url"); if(!url_json.IsString() || strncmp(url_json.GetString(), "mxc://", 6) != 0) return nullptr; message->url = homeserver + "/_matrix/media/r0/download/" + (url_json.GetString() + 6); message->type = MessageType::FILE; prefix = "💾 Download "; } else if(strcmp(content_type.GetString(), "m.emote") == 0) { // this is a /me message, TODO: show /me messages differently message->type = MessageType::TEXT; prefix = "*" + extract_first_line_remove_newline_elipses(room_data->get_user_display_name(user), AUTHOR_MAX_LENGTH) + "* "; } else if(strcmp(content_type.GetString(), "m.notice") == 0) { // TODO: show notices differently message->type = MessageType::TEXT; prefix = "* NOTICE * "; } else if(strcmp(content_type.GetString(), "m.location") == 0) { // TODO: show locations differently const rapidjson::Value &geo_uri_json = GetMember(*content_json, "geo_uri"); if(geo_uri_json.IsString()) prefix = geo_uri_json.GetString() + std::string(" | "); message->type = MessageType::TEXT; message->thumbnail_url = message_content_extract_thumbnail_url(*content_json, homeserver); message_content_extract_thumbnail_size(*content_json, message->thumbnail_size); } else if(strcmp(content_type.GetString(), "m.server_notice") == 0) { // TODO: show server notices differently message->type = MessageType::TEXT; prefix = "* Server notice * "; } else { return nullptr; } message->user = user; message->event_id = event_id_str; message->body = prefix + std::string(formatted_body_json->GetString(), formatted_body_json->GetStringLength()); message->body_is_formatted = body_is_formatted; message->related_event_id = std::move(related_event_id); message->related_event_type = related_event_type; message->timestamp = timestamp; message->transaction_id = std::move(transaction_id); return message; } std::string extract_user_name_from_user_id(const std::string &user_id) { size_t index = user_id.find(':'); if(index == std::string::npos || index == 0 || user_id[0] != '@') return ""; return user_id.substr(1, index - 1); } std::string extract_user_name_from_email(const std::string &email) { size_t index = email.find('@'); if(index == std::string::npos || index == 0) return ""; return email.substr(0, index); } // Not 100% correct on the server name part, but good enough static std::string_view extract_matrix_identifier(const char *str, size_t size) { if(size == 0) return {}; size_t index = 0; switch(str[index]) { case '@': case '!': case '$': case '+': case '#': break; default: return {}; } ++index; const size_t local_part_start = index; bool found_colon = false; // Parse local part for(; index < size; ++index) { char c = str[index]; if((c >= 'a' && c <= 'z') || c == '-' || c == '.' || c == '=' || c == '_' || c == '/') { } else if(c == ':') { found_colon = true; break; } } if(!found_colon || index - local_part_start == 0) return {}; ++index; const size_t server_part_start = index; // Parse server name for(; index < size; ++index) { char c = str[index]; if((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '.' || c == ':' || c == '[' || c == ']') { } else { break; } } if(index - server_part_start == 0) return {}; return { str, index }; } std::vector matrix_extract_room_ids(const std::string &str) { std::vector result; size_t index = 0; while(index < str.size()) { std::string_view room_id = extract_matrix_identifier(str.data() + index, str.size() - index); // TODO: Support ! room id joining. It doesn't work right now. Seems like matrix itself doesn't support that if(room_id.empty() || (room_id[0] != '#'/* && room_id[0] != '!'*/)) { ++index; } else { index += room_id.size(); result.emplace_back(room_id.data(), room_id.size()); } } return result; } static std::string combine_user_display_names_for_room_name(std::vector> &user_info, const std::string &fallback_user_id) { std::string result; if(user_info.size() == 0) result = extract_user_name_from_user_id(fallback_user_id); else if(user_info.size() == 1) result = user_info[0]->room->get_user_display_name(user_info[0]); else if(user_info.size() == 2) result = user_info[0]->room->get_user_display_name(user_info[0]) + " and " + user_info[1]->room->get_user_display_name(user_info[1]); else if(user_info.size() > 2) result = user_info[0]->room->get_user_display_name(user_info[0]) + ", " + user_info[1]->room->get_user_display_name(user_info[1]) + " and " + std::to_string(user_info.size() - 2) + " other(s)"; return result; } void Matrix::events_set_room_info(const rapidjson::Value &events_json, RoomData *room_data, int64_t timestamp) { if(!events_json.IsArray()) return; bool update_room_name = false; bool update_room_topic = false; bool update_room_avatar_url = false; for(const rapidjson::Value &event_item_json : events_json.GetArray()) { if(!event_item_json.IsObject()) continue; const rapidjson::Value &type_json = GetMember(event_item_json, "type"); if(!type_json.IsString()) continue; int64_t item_timestamp = timestamp; if(item_timestamp == 0) { const rapidjson::Value &origin_server_ts = GetMember(event_item_json, "origin_server_ts"); if(origin_server_ts.IsInt64()) item_timestamp = origin_server_ts.GetInt64(); } if(strcmp(type_json.GetString(), "m.room.name") == 0) { const rapidjson::Value &content_json = GetMember(event_item_json, "content"); if(!content_json.IsObject()) continue; const rapidjson::Value &name_json = GetMember(content_json, "name"); if(!name_json.IsString()) continue; update_room_name |= room_data->set_name(name_json.GetString(), item_timestamp); room_data->name_is_fallback = false; } else if(strcmp(type_json.GetString(), "m.room.avatar") == 0) { const rapidjson::Value &content_json = GetMember(event_item_json, "content"); if(!content_json.IsObject()) continue; const rapidjson::Value &url_json = GetMember(content_json, "url"); if(!url_json.IsString() || strncmp(url_json.GetString(), "mxc://", 6) != 0) continue; update_room_avatar_url |= room_data->set_avatar_url(get_avatar_thumbnail_url(homeserver, thumbnail_url_extract_media_id(url_json.GetString())), item_timestamp); room_data->avatar_is_fallback = false; } else if(strcmp(type_json.GetString(), "m.room.topic") == 0) { const rapidjson::Value &content_json = GetMember(event_item_json, "content"); if(!content_json.IsObject()) continue; const rapidjson::Value &topic_json = GetMember(content_json, "topic"); if(!topic_json.IsString()) continue; update_room_topic |= room_data->set_topic(topic_json.GetString(), item_timestamp); } } MatrixEventRoomInfo room_info_event; room_info_event.room = room_data; room_info_event.name = update_room_name ? std::optional(room_data->get_name()) : std::nullopt; room_info_event.topic = update_room_topic ? std::optional(room_data->get_topic()) : std::nullopt; room_info_event.avatar_url = update_room_avatar_url ? std::optional(room_data->get_avatar_url()) : std::nullopt; if(update_room_name || update_room_topic || update_room_avatar_url) trigger_event(room_data, MatrixEventType::ROOM_INFO, std::move(room_info_event)); } void Matrix::set_room_info_to_users_if_empty(RoomData *room, const std::string &room_creator_user_id) { const bool has_room_name = room->has_name(); const bool has_room_avatar_url = room->has_avatar_url(); bool update_room_name = false; bool update_room_avatar_url = false; std::vector> users_excluding_me; if(!has_room_name || !has_room_avatar_url || room->name_is_fallback || room->avatar_is_fallback) users_excluding_me = room->get_users_excluding_me(my_user_id); if(!has_room_name) { update_room_name |= room->set_name(combine_user_display_names_for_room_name(users_excluding_me, room_creator_user_id), 0); room->name_is_fallback = true; } if(!has_room_avatar_url) { if(users_excluding_me.empty()) { auto user = get_user_by_id(room, room_creator_user_id); if(user) update_room_avatar_url |= room->set_avatar_url(room->get_user_avatar_url(user), 0); } else { // TODO: If there are multiple users, then we want to use some other type of avatar, not the first users avatar update_room_avatar_url |= room->set_avatar_url(room->get_user_avatar_url(users_excluding_me.front()), 0); } room->avatar_is_fallback = true; } MatrixEventRoomInfo room_info_event; room_info_event.room = room; room_info_event.name = update_room_name ? std::optional(room->get_name()) : std::nullopt; room_info_event.avatar_url = update_room_avatar_url ? std::optional(room->get_avatar_url()) : std::nullopt; if(update_room_name || update_room_avatar_url) trigger_event(room, MatrixEventType::ROOM_INFO, std::move(room_info_event)); } void Matrix::events_add_pinned_events(const rapidjson::Value &events_json, RoomData *room_data) { if(!events_json.IsArray()) return; bool has_pinned_events = false; std::vector pinned_events; for(const rapidjson::Value &event_item_json : events_json.GetArray()) { if(!event_item_json.IsObject()) continue; const rapidjson::Value &type_json = GetMember(event_item_json, "type"); if(!type_json.IsString() || strcmp(type_json.GetString(), "m.room.pinned_events") != 0) continue; const rapidjson::Value &content_json = GetMember(event_item_json, "content"); if(!content_json.IsObject()) continue; const rapidjson::Value &pinned_json = GetMember(content_json, "pinned"); if(!pinned_json.IsArray()) continue; has_pinned_events = true; pinned_events.clear(); for(const rapidjson::Value &pinned_item_json : pinned_json.GetArray()) { if(!pinned_item_json.IsString()) continue; pinned_events.push_back(std::string(pinned_item_json.GetString(), pinned_item_json.GetStringLength())); } } if(has_pinned_events) room_data->set_pinned_events(std::move(pinned_events)); } void Matrix::events_add_room_to_tags(const rapidjson::Value &events_json, RoomData *room_data) { if(!events_json.IsArray()) return; bool has_tags = false; std::set new_tags; for(const rapidjson::Value &event_item_json : events_json.GetArray()) { if(!event_item_json.IsObject()) continue; const rapidjson::Value &type_json = GetMember(event_item_json, "type"); if(!type_json.IsString() || strcmp(type_json.GetString(), "m.tag") != 0) continue; const rapidjson::Value &content_json = GetMember(event_item_json, "content"); if(!content_json.IsObject()) continue; const rapidjson::Value &tags_json = GetMember(content_json, "tags"); if(!tags_json.IsObject()) continue; has_tags = true; new_tags.clear(); for(auto const &tag_json : tags_json.GetObject()) { if(!tag_json.name.IsString() || !tag_json.value.IsObject()) continue; //const char *tag_name = tag_get_name(tag_json.name.GetString(), tag_json.name.GetStringLength()); //if(!tag_name) // continue; // TODO: Support tag order new_tags.insert(std::string(tag_json.name.GetString(), tag_json.name.GetStringLength())); } } // Adding/removing tags is done with PUT and DELETE, but tags is part of account_data that contains all of the tags. // When we receive a list of tags its always the full list of tags if(has_tags) { room_data->acquire_room_lock(); std::set &room_tags = room_data->get_tags_thread_unsafe(); for(const std::string &room_tag : room_tags) { auto it = new_tags.find(room_tag); if(it == new_tags.end()) ui_thread_tasks.push([this, room_data, room_tag]{ delegate->room_remove_tag(room_data, room_tag); }); } for(const std::string &new_tag : new_tags) { auto it = room_tags.find(new_tag); if(it == room_tags.end()) ui_thread_tasks.push([this, room_data, new_tag]{ delegate->room_add_tag(room_data, new_tag); }); } if(new_tags.empty()) { new_tags.insert(OTHERS_ROOM_TAG); ui_thread_tasks.push([this, room_data]{ delegate->room_add_tag(room_data, OTHERS_ROOM_TAG); }); } const bool contains_direct_messaging = room_tags.find("m.direct") != room_tags.end(); room_tags = std::move(new_tags); if(contains_direct_messaging) room_tags.insert("m.direct"); room_data->release_room_lock(); } } void Matrix::add_invites(const rapidjson::Value &invite_json) { if(!invite_json.IsObject()) return; for(auto const &it : invite_json.GetObject()) { if(!it.value.IsObject()) continue; const rapidjson::Value &room_id = it.name; if(!room_id.IsString()) continue; const rapidjson::Value &invite_state_json = GetMember(it.value, "invite_state"); if(!invite_state_json.IsObject()) continue; const rapidjson::Value &events_json = GetMember(invite_state_json, "events"); if(!events_json.IsArray()) continue; for(const rapidjson::Value &event_json : events_json.GetArray()) { if(!event_json.IsObject()) continue; const rapidjson::Value &type_json = GetMember(event_json, "type"); if(!type_json.IsString()) continue; if(strcmp(type_json.GetString(), "m.room.member") != 0) continue; const rapidjson::Value &content_json = GetMember(event_json, "content"); if(!content_json.IsObject()) continue; const rapidjson::Value &sender_json = GetMember(event_json, "sender"); if(!sender_json.IsString()) continue; const rapidjson::Value ×tamp_json = GetMember(event_json, "origin_server_ts"); if(!timestamp_json.IsInt64()) continue; const rapidjson::Value &membership_json = GetMember(content_json, "membership"); if(membership_json.IsString() && strcmp(membership_json.GetString(), "invite") == 0) { const int64_t timestamp = timestamp_json.GetInt64(); // TODO: Check this this room should be saved in the rooms list, which might be needed if the server doesn't give a non-invite events // for the same data (user display name update, etc) Invite invite; auto invite_room = std::make_unique(); RoomData *room = invite_room.get(); invite_rooms.push_back(std::move(invite_room)); events_add_user_info(events_json, room, timestamp); events_set_room_info(events_json, room, timestamp); std::string sender_json_str(sender_json.GetString(), sender_json.GetStringLength()); auto invited_by = get_user_by_id(room, sender_json_str); set_room_info_to_users_if_empty(room, sender_json_str); invite.room_name = room->get_name(); invite.room_avatar_url = room->get_avatar_url(); invite.invited_by = invited_by; invite.timestamp = timestamp; std::string room_id_str(room_id.GetString(), room_id.GetStringLength()); set_invite(room_id_str, invite); break; } } } } void Matrix::remove_rooms(const rapidjson::Value &leave_json) { if(!leave_json.IsObject()) return; for(auto const &it : leave_json.GetObject()) { if(!it.value.IsObject()) continue; const rapidjson::Value &room_id = it.name; if(!room_id.IsString()) continue; std::string room_id_str(room_id.GetString(), room_id.GetStringLength()); if(remove_invite(room_id_str)) { // TODO: Show leave type and reason and who caused the invite to be removed ui_thread_tasks.push([this, room_id_str{std::move(room_id_str)}]{ delegate->remove_invite(room_id_str); }); } const rapidjson::Value &timeline_json = GetMember(it.value, "timeline"); if(!timeline_json.IsObject()) continue; const rapidjson::Value &events_json = GetMember(timeline_json, "events"); if(!events_json.IsArray()) continue; for(const rapidjson::Value &event_json : events_json.GetArray()) { if(!event_json.IsObject()) continue; const rapidjson::Value &type_json = GetMember(event_json, "type"); if(!type_json.IsString() || strcmp(type_json.GetString(), "m.room.member") != 0) continue; const rapidjson::Value &sender_json = GetMember(event_json, "sender"); if(!sender_json.IsString()) continue; const rapidjson::Value &content_json = GetMember(event_json, "content"); if(!content_json.IsObject()) continue; const rapidjson::Value &membership_json = GetMember(content_json, "membership"); if(!membership_json.IsString()) continue; std::string reason_str; const rapidjson::Value &reason_json = GetMember(content_json, "reason"); if(reason_json.IsString()) reason_str = reason_json.GetString(); auto room = get_room_by_id(room_id_str); if(!room) continue; std::string desc; LeaveType leave_type; if(strcmp(membership_json.GetString(), "leave") == 0) { if(strcmp(sender_json.GetString(), my_user_id.c_str()) == 0) { leave_type = LeaveType::LEAVE; } else { leave_type = LeaveType::KICKED; desc = "You were kicked from " + room->get_name() + " by " + sender_json.GetString(); } } else if(strcmp(membership_json.GetString(), "ban") == 0) { leave_type = LeaveType::BANNED; desc = "You were banned from " + room->get_name() + " by " + sender_json.GetString(); } else { continue; } if(!reason_str.empty()) desc += ", reason: " + reason_str; ui_thread_tasks.push([this, room, leave_type, desc{std::move(desc)}]{ delegate->leave_room(room, leave_type, desc); }); remove_room(room_id_str); break; } } } PluginResult Matrix::get_previous_room_messages(RoomData *room_data, bool latest_messages, size_t &num_new_messages, bool *reached_end) { num_new_messages = 0; std::string from = room_data->get_prev_batch(); if(from.empty() || latest_messages) // TODO: Remove. In v1.3 (/v3/) from can be empty from = "END"; // TODO: Load prev batch from cache rapidjson::Document request_data(rapidjson::kObjectType); request_data.AddMember("lazy_load_members", true, request_data.GetAllocator()); rapidjson::StringBuffer buffer; rapidjson::Writer writer(buffer); request_data.Accept(writer); std::vector additional_args = { { "-H", "Authorization: Bearer " + access_token } }; std::string filter = url_param_encode(buffer.GetString()); char url[512]; snprintf(url, sizeof(url), "%s/_matrix/client/r0/rooms/%s/messages?from=%s&limit=20&dir=b&filter=%s", homeserver.c_str(), room_data->id.c_str(), from.c_str(), filter.c_str()); rapidjson::Document json_root; DownloadResult download_result = download_json(json_root, url, std::move(additional_args), true); if(download_result != DownloadResult::OK) return download_result_to_plugin_result(download_result); if(!json_root.IsObject()) return PluginResult::ERR; const rapidjson::Value &state_json = GetMember(json_root, "state"); // TODO: Remove? events_add_user_info(state_json, room_data, 0); //events_set_room_info(state_json, room_data, 0); const rapidjson::Value &chunk_json = GetMember(json_root, "chunk"); num_new_messages = events_add_messages(chunk_json, room_data, MessageDirection::BEFORE, false); const rapidjson::Value &end_json = GetMember(json_root, "end"); if(!end_json.IsString()) { room_data->set_prev_batch(""); fprintf(stderr, "Warning: matrix messages response is missing 'end', this could happen if we received the very first messages in the room\n"); if(reached_end) *reached_end = true; return PluginResult::OK; } if(reached_end) *reached_end = strcmp(end_json.GetString(), from.c_str()) == 0; room_data->set_prev_batch(end_json.GetString()); return PluginResult::OK; } std::string create_transaction_id() { std::string random_readable_chars; random_readable_chars.resize(18); if(!generate_random_string_readable(random_readable_chars.data(), random_readable_chars.size())) return ""; return "m." + std::to_string(time(NULL)) + random_readable_chars; } static const char* content_type_to_message_type(ContentType content_type) { if(is_content_type_video(content_type)) return "m.video"; else if(is_content_type_audio(content_type)) return "m.audio"; else if(is_content_type_image(content_type)) return "m.image"; else return "m.file"; } static size_t find_backquote_index_with_escape(const std::string &str, size_t start_index = 0) { bool escape = false; for(size_t i = start_index; i < str.size(); ++i) { char c = str[i]; if(c == '\\') { escape = !escape; } else if(c == '`' && !escape) { return i; } else { escape = false; } } return std::string::npos; } static void replace_emoji_references_with_formatted_images(std::string &str, const std::unordered_map &custom_emojis) { for(const auto &it : custom_emojis) { std::string keybind = ":" + it.first + ":"; std::string url = it.second.url; html_escape_sequences(url); mgl::vec2i img_size = clamp_to_size(it.second.size, custom_emoji_max_size); std::string width = std::to_string(img_size.x); std::string height = std::to_string(img_size.y); std::string tag = "\"""; string_replace_all(str, keybind, tag); } } void Matrix::formatted_body_add_line(RoomData *room, std::string &formatted_body, const std::string &line_str, const std::unordered_map &custom_emojis) { size_t index = 0; while(true) { size_t backquote_start_index = find_backquote_index_with_escape(line_str, index); if(backquote_start_index != std::string::npos) { size_t backquote_end_index = find_backquote_index_with_escape(line_str, backquote_start_index + 1); if(backquote_end_index != std::string::npos) { std::string str_to_append = line_str.substr(index, backquote_start_index - index); replace_mentions(room, str_to_append); formatted_body += std::move(str_to_append); formatted_body += ""; formatted_body += line_str.substr(backquote_start_index + 1, backquote_end_index - (backquote_start_index + 1)); formatted_body += ""; index = backquote_end_index + 1; continue; } } std::string str_to_append = line_str.substr(index); replace_mentions(room, str_to_append); replace_emoji_references_with_formatted_images(str_to_append, custom_emojis); formatted_body += std::move(str_to_append); break; } } void Matrix::replace_mentions(RoomData *room, std::string &text) { size_t index = 0; while(index < text.size()) { index = text.find('@', index); if(index == std::string::npos) return; bool is_valid_user_id = false; bool user_id_finished = false; size_t user_id_start = index; size_t user_id_end = 0; index += 1; for(size_t i = index; i < text.size() && !user_id_finished; ++i) { char c = text[i]; switch(c) { case ':': { if(is_valid_user_id) { user_id_finished = true; user_id_end = i; index = i; } is_valid_user_id = true; break; } case ' ': case '\n': case '\r': case '\t': case '?': case ',': case ')': case '(': case '[': case ']': case '{': case '}': case '!': case ';': case '@': { user_id_finished = true; user_id_end = i; index = i; break; } } } if(user_id_end == 0) user_id_end = text.size(); if(is_valid_user_id) { std::string user_id = text.substr(user_id_start, user_id_end - user_id_start); auto user = get_user_by_id(room, user_id, nullptr, false); if(user) { std::string user_id_escaped = user_id; html_escape_sequences(user_id_escaped); std::string display_name_escaped = room->get_user_display_name(user); html_escape_sequences(display_name_escaped); std::string mention_text = "" + display_name_escaped + ""; text.replace(user_id_start, user_id.size(), mention_text); index += (mention_text.size() - user_id.size()); } } } } // TODO: remove first/last newspace in codeblock. std::string Matrix::body_to_formatted_body(RoomData *room, const std::string &body) { std::unordered_map custom_emojis_copy; { std::lock_guard lock(room_data_mutex); custom_emojis_copy = custom_emoji_by_key; } std::string formatted_body; bool is_inside_code_block = false; bool is_first_line = true; string_split(body, '\n', [this, room, &formatted_body, &is_inside_code_block, &is_first_line, &custom_emojis_copy](const char *str, size_t size){ if(!is_first_line) { if(is_inside_code_block) formatted_body += '\n'; else formatted_body += "
"; } std::string line_str(str, size); html_escape_sequences(line_str); if(size >= 3 && strncmp(str, "```", 3) == 0 && line_str.find("```", 3) == std::string::npos) { if(is_inside_code_block) { formatted_body += ""; is_inside_code_block = false; } else { if(size > 3) { formatted_body += "
";
                    } else {
                        formatted_body += "
";
                    }
                    is_inside_code_block = true;
                }
                is_first_line = true;
            } else {
                if(!is_inside_code_block && size > 0 && str[0] == '>') {
                    formatted_body += "";
                    formatted_body_add_line(room, formatted_body, line_str, custom_emojis_copy);
                    formatted_body += "";
                } else if(!is_inside_code_block && size > 0 && str[0] == '<') {
                    formatted_body += "";
                    formatted_body_add_line(room, formatted_body, line_str, custom_emojis_copy);
                    formatted_body += "";
                } else {
                    if(is_inside_code_block) {
                        formatted_body += line_str;
                    } else {
                        formatted_body_add_line(room, formatted_body, line_str, custom_emojis_copy);
                    }
                }
                is_first_line = false;
            }

            return true;
        });
        return formatted_body;
    }

    void Matrix::on_exit_room(RoomData *room) {
        
    }

    void Matrix::async_decrypt_message(std::shared_ptr decrypt_job) {
        if(!decrypt_thread.joinable()) {
            decrypt_thread = std::thread([this]() {
                while(decrypt_task.is_running()) {
                    auto decrypt_job = decrypt_task.pop_wait();
                    if(!decrypt_job)
                        return;

                    if(decrypt_job.value()->cancel)
                        continue;

                    bool success = false;
                    decrypt_job.value()->text = matrix_decrypt_gpg_message_if_needed(std::move(decrypt_job.value()->text), success);
                    decrypt_job.value()->decrypt_state = success ? MatrixChatBodyDecryptJob::DecryptState::DECRYPTED : MatrixChatBodyDecryptJob::DecryptState::FAILED_TO_DECRYPT;
                }
            });
        }
        decrypt_task.push(std::move(decrypt_job));
    }

    MatrixDelegate* Matrix::get_delegate() {
        return delegate;
    }

    PluginResult Matrix::post_message(RoomData *room, const std::string &body, std::string &event_id_response, const std::optional &file_info, const std::optional &thumbnail_info, const std::string &msgtype, const std::string &custom_transaction_id) {
        std::string transaction_id = custom_transaction_id;
        if(transaction_id.empty())
            transaction_id = create_transaction_id();

        if(transaction_id.empty())
            return PluginResult::ERR;

        std::string formatted_body;
        if(!file_info)
            formatted_body = body_to_formatted_body(room, body);

        rapidjson::Document request_data(rapidjson::kObjectType);
        if(msgtype.empty())
            request_data.AddMember("msgtype", rapidjson::StringRef(file_info ? content_type_to_message_type(file_info->content_type) : "m.text"), request_data.GetAllocator());
        else
            request_data.AddMember("msgtype", rapidjson::StringRef(msgtype.c_str()), request_data.GetAllocator());
        request_data.AddMember("body", rapidjson::StringRef(body.c_str()), request_data.GetAllocator());
        if(!formatted_body.empty()) {
            request_data.AddMember("format", "org.matrix.custom.html", request_data.GetAllocator());
            request_data.AddMember("formatted_body", rapidjson::StringRef(formatted_body.c_str()), request_data.GetAllocator());
        }

        // TODO: Add hashblur?
        if(file_info) {
            rapidjson::Value info_json(rapidjson::kObjectType);
            info_json.AddMember("size", file_info->file_size, request_data.GetAllocator());
            info_json.AddMember("mimetype", rapidjson::StringRef(content_type_to_string(file_info->content_type)), request_data.GetAllocator());
            if(file_info->dimensions) {
                info_json.AddMember("w", file_info->dimensions->width, request_data.GetAllocator());
                info_json.AddMember("h", file_info->dimensions->height, request_data.GetAllocator());
            }
            if(file_info->duration_seconds) {
                // TODO: Check for overflow?
                info_json.AddMember("duration", (int)(file_info->duration_seconds.value() * 1000.0), request_data.GetAllocator());
            }

            if(thumbnail_info) {
                rapidjson::Value thumbnail_info_json(rapidjson::kObjectType);
                thumbnail_info_json.AddMember("size", thumbnail_info->file_size, request_data.GetAllocator());
                thumbnail_info_json.AddMember("mimetype", rapidjson::StringRef(content_type_to_string(thumbnail_info->content_type)), request_data.GetAllocator());
                if(thumbnail_info->dimensions) {
                    thumbnail_info_json.AddMember("w", thumbnail_info->dimensions->width, request_data.GetAllocator());
                    thumbnail_info_json.AddMember("h", thumbnail_info->dimensions->height, request_data.GetAllocator());
                }

                info_json.AddMember("thumbnail_url", rapidjson::StringRef(thumbnail_info->content_uri.c_str()), request_data.GetAllocator());
                info_json.AddMember("thumbnail_info", std::move(thumbnail_info_json), request_data.GetAllocator());
            }

            request_data.AddMember("info", std::move(info_json), request_data.GetAllocator());
            request_data.AddMember("url", rapidjson::StringRef(file_info->content_uri.c_str()), request_data.GetAllocator());
        }

        rapidjson::StringBuffer buffer;
        rapidjson::Writer writer(buffer);
        request_data.Accept(writer);

        std::vector additional_args = {
            { "-X", "PUT" },
            { "-H", "content-type: application/json" },
            { "-H", "Authorization: Bearer " + access_token },
            { "--data-binary", buffer.GetString() }
        };

        char request_url[512];
        snprintf(request_url, sizeof(request_url), "%s/_matrix/client/r0/rooms/%s/send/m.room.message/%s", homeserver.c_str(), room->id.c_str(), transaction_id.c_str());

        rapidjson::Document json_root;
        DownloadResult download_result = download_json(json_root, request_url, std::move(additional_args), true);
        if(download_result != DownloadResult::OK) return download_result_to_plugin_result(download_result);

        if(!json_root.IsObject())
            return PluginResult::ERR;

        const rapidjson::Value &event_id_json = GetMember(json_root, "event_id");
        if(!event_id_json.IsString())
            return PluginResult::ERR;

        fprintf(stderr, "Matrix post message, response event id: %s\n", event_id_json.GetString());
        event_id_response = std::string(event_id_json.GetString(), event_id_json.GetStringLength());
        return PluginResult::OK;
    }

    static std::string block_quote(const std::string &str) {
        std::string result;
        for(char c : str) {
            if(c == '>') {
                result += "\\>";
            } else if(c == '\n') {
                result += "\n> ";
            } else {
                result += c;
            }
        }
        return result;
    }

    static std::string get_reply_message(Matrix *matrix, const Message *message, bool keep_formatted = false) {
        std::string related_to_body;
        switch(message->type) {
            case MessageType::TEXT: {
                if(message->related_event_type != RelatedEventType::NONE) {
                    related_to_body = remove_reply_formatting(matrix, message, keep_formatted);
                } else {
                    if(keep_formatted && message->body_is_formatted)
                        related_to_body = formatted_text_remove_reply(message->body);
                    else
                        related_to_body = message_to_qm_text(matrix, message, false);
                }
                break;
            }
            case MessageType::IMAGE:
                related_to_body = "sent an image";
                break;
            case MessageType::VIDEO:
                related_to_body = "sent a video";
                break;
            case MessageType::AUDIO:
                related_to_body = "sent an audio file";
                break;
            case MessageType::FILE:
                related_to_body = "sent a file";
                break;
            case MessageType::REDACTION: {
                if(keep_formatted && message->body_is_formatted)
                    related_to_body = formatted_text_remove_reply(message->body);
                else
                    related_to_body = message_to_qm_text(matrix, message, false);
                break;
            }
        }
        return related_to_body;
    }

    static std::string create_body_for_message_reply(Matrix *matrix, const Message *message, const std::string &body) {
        return "> <" + message->user->user_id + "> " + block_quote(get_reply_message(matrix, message)) + "\n\n" + body;
    }
    
    static std::string extract_homeserver_from_room_id(const std::string &room_id) {
        size_t sep_index = room_id.find(':');
        if(sep_index != std::string::npos)
            return room_id.substr(sep_index + 1);
        return "";
    }

    std::string Matrix::create_formatted_body_for_message_reply(RoomData *room, const Message *message, const std::string &body) {
        std::string formatted_body = body_to_formatted_body(room, body);
        std::string related_to_body = get_reply_message(this, message, true);
        if(!message->body_is_formatted)
            html_escape_sequences(related_to_body);
        // TODO: Add keybind to navigate to the reply message, which would also depend on this formatting.
        // Note: user id and event id is not url escaped here on purpose, because that messes up riot.im replies for certain user ids...
        return ""
                   "
" "id + "/" + message->event_id + "?via=" + extract_homeserver_from_room_id(room->id) + "\">In reply to" "user->user_id + "\"> " + message->user->user_id + "
" + std::move(related_to_body) + "
" "
" + std::move(formatted_body); } Message* get_latest_message_in_edit_chain(Message *message) { while(message) { Message *replaced_by = message->replaced_by.get(); if(!replaced_by) break; message = replaced_by; } return message; } PluginResult Matrix::post_reply(RoomData *room, const std::string &body, void *relates_to, std::string &event_id_response, const std::string &custom_transaction_id, const std::optional &file_info, const std::optional &thumbnail_info) { Message *relates_to_message_raw = (Message*)relates_to; Message *related_to_text_message = get_latest_message_in_edit_chain(relates_to_message_raw); std::string transaction_id = custom_transaction_id; if(transaction_id.empty()) transaction_id = create_transaction_id(); if(transaction_id.empty()) return PluginResult::ERR; rapidjson::Document in_reply_to_json(rapidjson::kObjectType); in_reply_to_json.AddMember("event_id", rapidjson::StringRef(relates_to_message_raw->event_id.c_str()), in_reply_to_json.GetAllocator()); rapidjson::Document relates_to_json(rapidjson::kObjectType); relates_to_json.AddMember("m.in_reply_to", std::move(in_reply_to_json), relates_to_json.GetAllocator()); std::string message_reply_body = create_body_for_message_reply(this, related_to_text_message, body); // Yes, the reply is to the edited message but the event_id reference is to the original message... std::string formatted_message_reply_body = create_formatted_body_for_message_reply(room, related_to_text_message, body); rapidjson::Document request_data(rapidjson::kObjectType); request_data.AddMember("msgtype", rapidjson::StringRef(file_info ? content_type_to_message_type(file_info->content_type) : "m.text"), request_data.GetAllocator()); request_data.AddMember("body", rapidjson::StringRef(file_info ? body.c_str() : message_reply_body.c_str()), request_data.GetAllocator()); request_data.AddMember("format", "org.matrix.custom.html", request_data.GetAllocator()); request_data.AddMember("formatted_body", rapidjson::StringRef(formatted_message_reply_body.c_str()), request_data.GetAllocator()); request_data.AddMember("m.relates_to", std::move(relates_to_json), request_data.GetAllocator()); // TODO: Add hashblur? if(file_info) { rapidjson::Value info_json(rapidjson::kObjectType); info_json.AddMember("size", file_info->file_size, request_data.GetAllocator()); info_json.AddMember("mimetype", rapidjson::StringRef(content_type_to_string(file_info->content_type)), request_data.GetAllocator()); if(file_info->dimensions) { info_json.AddMember("w", file_info->dimensions->width, request_data.GetAllocator()); info_json.AddMember("h", file_info->dimensions->height, request_data.GetAllocator()); } if(file_info->duration_seconds) { // TODO: Check for overflow? info_json.AddMember("duration", (int)(file_info->duration_seconds.value() * 1000.0), request_data.GetAllocator()); } if(thumbnail_info) { rapidjson::Value thumbnail_info_json(rapidjson::kObjectType); thumbnail_info_json.AddMember("size", thumbnail_info->file_size, request_data.GetAllocator()); thumbnail_info_json.AddMember("mimetype", rapidjson::StringRef(content_type_to_string(thumbnail_info->content_type)), request_data.GetAllocator()); if(thumbnail_info->dimensions) { thumbnail_info_json.AddMember("w", thumbnail_info->dimensions->width, request_data.GetAllocator()); thumbnail_info_json.AddMember("h", thumbnail_info->dimensions->height, request_data.GetAllocator()); } info_json.AddMember("thumbnail_url", rapidjson::StringRef(thumbnail_info->content_uri.c_str()), request_data.GetAllocator()); info_json.AddMember("thumbnail_info", std::move(thumbnail_info_json), request_data.GetAllocator()); } request_data.AddMember("info", std::move(info_json), request_data.GetAllocator()); request_data.AddMember("url", rapidjson::StringRef(file_info->content_uri.c_str()), request_data.GetAllocator()); } rapidjson::StringBuffer buffer; rapidjson::Writer writer(buffer); request_data.Accept(writer); std::vector additional_args = { { "-X", "PUT" }, { "-H", "content-type: application/json" }, { "-H", "Authorization: Bearer " + access_token }, { "--data-binary", buffer.GetString() } }; char request_url[512]; snprintf(request_url, sizeof(request_url), "%s/_matrix/client/r0/rooms/%s/send/m.room.message/%s", homeserver.c_str(), room->id.c_str(), transaction_id.c_str()); rapidjson::Document json_root; DownloadResult download_result = download_json(json_root, request_url, std::move(additional_args), true); if(download_result != DownloadResult::OK) return download_result_to_plugin_result(download_result); if(!json_root.IsObject()) return PluginResult::ERR; const rapidjson::Value &event_id_json = GetMember(json_root, "event_id"); if(!event_id_json.IsString()) return PluginResult::ERR; fprintf(stderr, "Matrix post reply, response event id: %s\n", event_id_json.GetString()); event_id_response = std::string(event_id_json.GetString(), event_id_json.GetStringLength()); return PluginResult::OK; } static Message* get_replied_to_message_recursive(Matrix *matrix, RoomData *room, Message *message) { while(message) { if(message->related_event_type == RelatedEventType::REPLY && !message->related_event_id.empty()) return get_latest_message_in_edit_chain(matrix->get_message_by_id(room, message->related_event_id).get()); else if(message->related_event_type == RelatedEventType::EDIT && !message->related_event_id.empty()) message = matrix->get_message_by_id(room, message->related_event_id).get(); else return nullptr; } return nullptr; } static std::string create_body_for_message_edit(Matrix *matrix, const Message *replied_to_message, const std::string &body) { if(replied_to_message) return create_body_for_message_reply(matrix, replied_to_message, " * " + body); else return " * " + body; } std::string Matrix::create_formatted_body_for_message_edit(RoomData *room, const Message *replied_to_message, const std::string &body) { if(replied_to_message) return create_formatted_body_for_message_reply(room, replied_to_message, " * " + body); else return body_to_formatted_body(room, " * " + body); } PluginResult Matrix::post_edit(RoomData *room, const std::string &body, void *relates_to, std::string &event_id_response, const std::string &custom_transaction_id) { Message *relates_to_message_raw = (Message*)relates_to; Message *replied_to_message = get_replied_to_message_recursive(this, room, relates_to_message_raw); std::string transaction_id = custom_transaction_id; if(transaction_id.empty()) transaction_id = create_transaction_id(); if(transaction_id.empty()) return PluginResult::ERR; rapidjson::Document request_data(rapidjson::kObjectType); const std::string message_edit_body = create_body_for_message_edit(this, replied_to_message, body); const std::string formatted_message_edit_body = create_formatted_body_for_message_edit(room, replied_to_message, body); request_data.AddMember("msgtype", "m.text", request_data.GetAllocator()); request_data.AddMember("body", rapidjson::StringRef(message_edit_body.c_str()), request_data.GetAllocator()); request_data.AddMember("format", "org.matrix.custom.html", request_data.GetAllocator()); request_data.AddMember("formatted_body", rapidjson::StringRef(formatted_message_edit_body.c_str()), request_data.GetAllocator()); const std::string formatted_body = body_to_formatted_body(room, body); rapidjson::Document new_content_json(rapidjson::kObjectType); new_content_json.AddMember("msgtype", "m.text", new_content_json.GetAllocator()); new_content_json.AddMember("body", rapidjson::StringRef(body.c_str()), new_content_json.GetAllocator()); new_content_json.AddMember("format", "org.matrix.custom.html", new_content_json.GetAllocator()); new_content_json.AddMember("formatted_body", rapidjson::StringRef(formatted_body.c_str()), new_content_json.GetAllocator()); request_data.AddMember("m.new_content", std::move(new_content_json), request_data.GetAllocator()); rapidjson::Document relates_to_json(rapidjson::kObjectType); relates_to_json.AddMember("event_id", rapidjson::StringRef(relates_to_message_raw->event_id.c_str()), relates_to_json.GetAllocator()); relates_to_json.AddMember("rel_type", "m.replace", relates_to_json.GetAllocator()); request_data.AddMember("m.relates_to", std::move(relates_to_json), request_data.GetAllocator()); rapidjson::StringBuffer buffer; rapidjson::Writer writer(buffer); request_data.Accept(writer); std::vector additional_args = { { "-X", "PUT" }, { "-H", "content-type: application/json" }, { "-H", "Authorization: Bearer " + access_token }, { "--data-binary", buffer.GetString() } }; char request_url[512]; snprintf(request_url, sizeof(request_url), "%s/_matrix/client/r0/rooms/%s/send/m.room.message/%s", homeserver.c_str(), room->id.c_str(), transaction_id.c_str()); rapidjson::Document json_root; DownloadResult download_result = download_json(json_root, request_url, std::move(additional_args), true); if(download_result != DownloadResult::OK) return download_result_to_plugin_result(download_result); if(!json_root.IsObject()) return PluginResult::ERR; const rapidjson::Value &event_id_json = GetMember(json_root, "event_id"); if(!event_id_json.IsString()) return PluginResult::ERR; fprintf(stderr, "Matrix post edit, response event id: %s\n", event_id_json.GetString()); event_id_response = std::string(event_id_json.GetString(), event_id_json.GetStringLength()); return PluginResult::OK; } PluginResult Matrix::post_reaction(RoomData *room, const std::string &body, void *relates_to, std::string &event_id_response, const std::string &custom_transaction_id) { Message *relates_to_message_raw = (Message*)relates_to; std::string transaction_id = custom_transaction_id; if(transaction_id.empty()) transaction_id = create_transaction_id(); if(transaction_id.empty()) return PluginResult::ERR; rapidjson::Document relates_to_json(rapidjson::kObjectType); relates_to_json.AddMember("event_id", rapidjson::StringRef(relates_to_message_raw->event_id.c_str()), relates_to_json.GetAllocator()); relates_to_json.AddMember("key", rapidjson::StringRef(body.c_str()), relates_to_json.GetAllocator()); relates_to_json.AddMember("rel_type", "m.annotation", relates_to_json.GetAllocator()); rapidjson::Document request_json(rapidjson::kObjectType); request_json.AddMember("m.relates_to", std::move(relates_to_json), request_json.GetAllocator()); rapidjson::StringBuffer buffer; rapidjson::Writer writer(buffer); request_json.Accept(writer); std::vector additional_args = { { "-X", "PUT" }, { "-H", "content-type: application/json" }, { "-H", "Authorization: Bearer " + access_token }, { "--data-binary", buffer.GetString() } }; char request_url[512]; snprintf(request_url, sizeof(request_url), "%s/_matrix/client/r0/rooms/%s/send/m.reaction/%s", homeserver.c_str(), room->id.c_str(), transaction_id.c_str()); rapidjson::Document json_root; DownloadResult download_result = download_json(json_root, request_url, std::move(additional_args), true); if(download_result != DownloadResult::OK) return download_result_to_plugin_result(download_result); if(!json_root.IsObject()) return PluginResult::ERR; const rapidjson::Value &event_id_json = GetMember(json_root, "event_id"); if(!event_id_json.IsString()) return PluginResult::ERR; fprintf(stderr, "Matrix post reaction, response event id: %s\n", event_id_json.GetString()); event_id_response = std::string(event_id_json.GetString(), event_id_json.GetStringLength()); return PluginResult::OK; } std::shared_ptr Matrix::get_message_by_id(RoomData *room, const std::string &event_id) { std::shared_ptr existing_room_message = room->get_message_by_id(event_id); if(existing_room_message) return existing_room_message; auto fetched_message_it = room->fetched_messages_by_event_id.find(event_id); if(fetched_message_it != room->fetched_messages_by_event_id.end()) return fetched_message_it->second; std::vector additional_args = { { "-H", "Authorization: Bearer " + access_token } }; char url[512]; snprintf(url, sizeof(url), "%s/_matrix/client/r0/rooms/%s/event/%s", homeserver.c_str(), room->id.c_str(), event_id.c_str()); std::string response; DownloadResult download_result = download_to_string_cache(url, response, std::move(additional_args), true, [](std::string &response) { rapidjson::Document json_root; rapidjson::ParseResult parse_result = json_root.Parse(response.c_str(), response.size()); if(parse_result.IsError() || !json_root.IsObject()) return false; const rapidjson::Value &errcode_json = GetMember(json_root, "errcode"); if(errcode_json.IsString()) { if(strcmp(errcode_json.GetString(), "M_FORBIDDEN") == 0 || strcmp(errcode_json.GetString(), "M_NOT_FOUND") == 0) return true; else return false; } return true; }, get_cache_dir().join("matrix").join("events").join(cppcodec::base64_url::encode(event_id))); if(download_result != DownloadResult::OK) { fprintf(stderr, "Failed to get message by id %s, error: %s\n", event_id.c_str(), response.c_str()); room->fetched_messages_by_event_id.insert(std::make_pair(event_id, nullptr)); return nullptr; } rapidjson::Document json_root; rapidjson::ParseResult parse_result = json_root.Parse(response.c_str(), response.size()); if(parse_result.IsError()) { fprintf(stderr, "Failed to get message by id %s, error: %s\n", event_id.c_str(), response.c_str()); room->fetched_messages_by_event_id.insert(std::make_pair(event_id, nullptr)); return nullptr; } if(!json_root.IsObject()) { fprintf(stderr, "Failed to get message by id %s, error: %s\n", event_id.c_str(), response.c_str()); room->fetched_messages_by_event_id.insert(std::make_pair(event_id, nullptr)); return nullptr; } const rapidjson::Value &error_json = GetMember(json_root, "error"); if(error_json.IsString()) { fprintf(stderr, "Matrix::get_message_by_id for event id: %s, error: %s\n", event_id.c_str(), error_json.GetString()); room->fetched_messages_by_event_id.insert(std::make_pair(event_id, nullptr)); return nullptr; } // TODO: Do this? //const rapidjson::Value &state_json = GetMember(json_root, "state"); //events_add_user_info(state_json, room, 0); std::shared_ptr new_message = parse_message_event(json_root, room); room->fetched_messages_by_event_id.insert(std::make_pair(event_id, new_message)); return new_message; } PluginResult Matrix::get_message_context(RoomData *room, const std::string &event_id, std::shared_ptr &message, Messages &before_messages, Messages &after_messages, std::string &before_token, std::string &after_token) { rapidjson::Document request_data(rapidjson::kObjectType); request_data.AddMember("lazy_load_members", true, request_data.GetAllocator()); rapidjson::StringBuffer buffer; rapidjson::Writer writer(buffer); request_data.Accept(writer); std::vector additional_args = { { "-H", "Authorization: Bearer " + access_token } }; std::string filter = url_param_encode(buffer.GetString()); char url[512]; snprintf(url, sizeof(url), "%s/_matrix/client/r0/rooms/%s/context/%s?limit=20&filter=%s", homeserver.c_str(), room->id.c_str(), event_id.c_str(), filter.c_str()); rapidjson::Document json_root; std::string err_msg; DownloadResult download_result = download_json(json_root, url, std::move(additional_args), true, &err_msg); if(download_result != DownloadResult::OK) { show_notification("QuickMedia", "Failed to get message", Urgency::CRITICAL); return download_result_to_plugin_result(download_result); } if(!json_root.IsObject()) { show_notification("QuickMedia", "Failed to parse server response", Urgency::CRITICAL); return PluginResult::ERR; } const rapidjson::Value &errcode_json = GetMember(json_root, "errcode"); if(errcode_json.IsString() && strcmp(errcode_json.GetString(), "M_NOT_FOUND") != 0) { const rapidjson::Value &error_json = GetMember(json_root, "error"); if(error_json.IsString()) { show_notification("QuickMedia", "Failed to get message, error: " + std::string(error_json.GetString(), error_json.GetStringLength()), Urgency::CRITICAL); return PluginResult::ERR; } } // TODO: Do this? //const rapidjson::Value &state_json = GetMember(json_root, "state"); //events_add_user_info(state_json, room, 0); const rapidjson::Value &start_json = GetMember(json_root, "start"); const rapidjson::Value &end_json = GetMember(json_root, "end"); const rapidjson::Value &event_json = GetMember(json_root, "event"); const rapidjson::Value &events_before_json = GetMember(json_root, "events_before"); const rapidjson::Value &events_after_json = GetMember(json_root, "events_after"); if(start_json.IsString()) before_token.assign(start_json.GetString(), start_json.GetStringLength()); if(end_json.IsString()) after_token.assign(end_json.GetString(), end_json.GetStringLength()); message = parse_message_event(event_json, room); if(events_before_json.IsArray()) { for(const rapidjson::Value &event_item_json : events_before_json.GetArray()) { std::shared_ptr new_message = parse_message_event(event_item_json, room); if(new_message) before_messages.push_back(std::move(new_message)); } } if(events_after_json.IsArray()) { for(const rapidjson::Value &event_item_json : events_after_json.GetArray()) { std::shared_ptr new_message = parse_message_event(event_item_json, room); if(new_message) after_messages.push_back(std::move(new_message)); } } return PluginResult::OK; } void Matrix::clear_previous_messages_token(RoomData *room) { room->set_prev_batch(""); } PluginResult Matrix::upload_custom_emoji(const std::string &filepath, const std::string &key, std::string &mxc_url, std::string &err_msg) { UploadInfo file_info; UploadInfo thumbnail_info; PluginResult upload_file_result = upload_file(filepath, "", file_info, thumbnail_info, err_msg, false, true); if(upload_file_result != PluginResult::OK) return upload_file_result; mxc_url = std::move(file_info.content_uri); rapidjson::Document request_data(rapidjson::kObjectType); { std::lock_guard lock(room_data_mutex); for(const auto &it : custom_emoji_by_key) { rapidjson::Document emoji_obj(rapidjson::kObjectType); emoji_obj.AddMember("url", rapidjson::Value(it.second.url.c_str(), request_data.GetAllocator()).Move(), request_data.GetAllocator()); emoji_obj.AddMember("width", it.second.size.x, request_data.GetAllocator()); emoji_obj.AddMember("height", it.second.size.y, request_data.GetAllocator()); request_data.AddMember(rapidjson::Value(it.first.c_str(), request_data.GetAllocator()).Move(), std::move(emoji_obj), request_data.GetAllocator()); } } CustomEmoji custom_emoji; custom_emoji.url = mxc_url; rapidjson::Document emoji_obj(rapidjson::kObjectType); emoji_obj.AddMember("url", rapidjson::Value(mxc_url.c_str(), request_data.GetAllocator()).Move(), request_data.GetAllocator()); if(file_info.dimensions) { custom_emoji.size = { file_info.dimensions->width, file_info.dimensions->height }; emoji_obj.AddMember("width", custom_emoji.size.x, request_data.GetAllocator()); emoji_obj.AddMember("height", custom_emoji.size.y, request_data.GetAllocator()); } request_data.AddMember(rapidjson::Value(key.c_str(), request_data.GetAllocator()).Move(), std::move(emoji_obj), request_data.GetAllocator()); rapidjson::StringBuffer buffer; rapidjson::Writer writer(buffer); request_data.Accept(writer); std::vector additional_args = { { "-X", "PUT" }, { "-H", "content-type: application/json" }, { "-H", "Authorization: Bearer " + access_token }, { "--data-binary", buffer.GetString() } }; std::string server_response; DownloadResult download_result = download_to_string(homeserver + "/_matrix/client/r0/user/" + my_user_id + "/account_data/qm.emoji", server_response, std::move(additional_args), true); if(download_result != DownloadResult::OK) return download_result_to_plugin_result(download_result); // TODO: Is this even needed? (including in other /account_data/qm.emoji functions), wont we get qm.emoji update in sync? file_overwrite_atomic(get_cache_dir().join("matrix").join("custom_emoji.json"), std::string(buffer.GetString(), buffer.GetSize())); std::lock_guard lock(room_data_mutex); custom_emoji_by_key[key] = std::move(custom_emoji); return PluginResult::OK; } bool Matrix::delete_custom_emoji(const std::string &key) { rapidjson::Document request_data(rapidjson::kObjectType); { std::lock_guard lock(room_data_mutex); auto it = custom_emoji_by_key.find(key); if(it == custom_emoji_by_key.end()) return false; for(const auto &it : custom_emoji_by_key) { if(it.first == key) continue; rapidjson::Document emoji_obj(rapidjson::kObjectType); emoji_obj.AddMember("url", rapidjson::Value(it.second.url.c_str(), request_data.GetAllocator()).Move(), request_data.GetAllocator()); emoji_obj.AddMember("width", it.second.size.x, request_data.GetAllocator()); emoji_obj.AddMember("height", it.second.size.y, request_data.GetAllocator()); request_data.AddMember(rapidjson::Value(it.first.c_str(), request_data.GetAllocator()).Move(), std::move(emoji_obj), request_data.GetAllocator()); } } rapidjson::StringBuffer buffer; rapidjson::Writer writer(buffer); request_data.Accept(writer); std::vector additional_args = { { "-X", "PUT" }, { "-H", "content-type: application/json" }, { "-H", "Authorization: Bearer " + access_token }, { "--data-binary", buffer.GetString() } }; std::string server_response; DownloadResult download_result = download_to_string(homeserver + "/_matrix/client/r0/user/" + my_user_id + "/account_data/qm.emoji", server_response, std::move(additional_args), true); if(download_result != DownloadResult::OK) return false; file_overwrite_atomic(get_cache_dir().join("matrix").join("custom_emoji.json"), std::string(buffer.GetString(), buffer.GetSize())); std::lock_guard lock(room_data_mutex); auto it = custom_emoji_by_key.find(key); if(it != custom_emoji_by_key.end()) custom_emoji_by_key.erase(it); return true; } bool Matrix::rename_custom_emoji(const std::string &key, const std::string &new_key) { rapidjson::Document request_data(rapidjson::kObjectType); { std::lock_guard lock(room_data_mutex); auto custom_emoji_list_copy = custom_emoji_by_key; auto it = custom_emoji_list_copy.find(key); if(it == custom_emoji_list_copy.end()) return false; auto existing_new_it = custom_emoji_list_copy.find(new_key); if(existing_new_it != custom_emoji_list_copy.end()) return false; auto custom_emoji_copy = it->second; custom_emoji_list_copy.erase(it); custom_emoji_list_copy[new_key] = std::move(custom_emoji_copy); for(const auto &it : custom_emoji_list_copy) { rapidjson::Document emoji_obj(rapidjson::kObjectType); emoji_obj.AddMember("url", rapidjson::Value(it.second.url.c_str(), request_data.GetAllocator()).Move(), request_data.GetAllocator()); emoji_obj.AddMember("width", it.second.size.x, request_data.GetAllocator()); emoji_obj.AddMember("height", it.second.size.y, request_data.GetAllocator()); request_data.AddMember(rapidjson::Value(it.first.c_str(), request_data.GetAllocator()).Move(), std::move(emoji_obj), request_data.GetAllocator()); } } rapidjson::StringBuffer buffer; rapidjson::Writer writer(buffer); request_data.Accept(writer); std::vector additional_args = { { "-X", "PUT" }, { "-H", "content-type: application/json" }, { "-H", "Authorization: Bearer " + access_token }, { "--data-binary", buffer.GetString() } }; std::string server_response; DownloadResult download_result = download_to_string(homeserver + "/_matrix/client/r0/user/" + my_user_id + "/account_data/qm.emoji", server_response, std::move(additional_args), true); if(download_result != DownloadResult::OK) return false; file_overwrite_atomic(get_cache_dir().join("matrix").join("custom_emoji.json"), std::string(buffer.GetString(), buffer.GetSize())); std::lock_guard lock(room_data_mutex); auto it = custom_emoji_by_key.find(key); if(it != custom_emoji_by_key.end()) { auto custom_emoji_copy = it->second; custom_emoji_by_key.erase(it); custom_emoji_by_key[new_key] = std::move(custom_emoji_copy); } return true; } bool Matrix::does_custom_emoji_with_name_exist(const std::string &name) { assert(is_initial_sync_finished()); std::lock_guard lock(room_data_mutex); return custom_emoji_by_key.find(name) != custom_emoji_by_key.end(); } std::unordered_map Matrix::get_custom_emojis() { assert(is_initial_sync_finished()); std::lock_guard lock(room_data_mutex); return custom_emoji_by_key; } PluginResult Matrix::post_file(RoomData *room, const std::string &filepath, std::string filename, std::string &event_id_response, std::string &err_msg, void *relates_to) { if(filename.empty()) filename = file_get_filename(filepath); UploadInfo file_info; UploadInfo thumbnail_info; PluginResult upload_file_result = upload_file(filepath, filename, file_info, thumbnail_info, err_msg); if(upload_file_result != PluginResult::OK) return upload_file_result; std::optional file_info_opt = std::move(file_info); std::optional thumbnail_info_opt; if(!thumbnail_info.content_uri.empty()) thumbnail_info_opt = std::move(thumbnail_info); if(relates_to) return post_reply(room, filename, relates_to, event_id_response, "", file_info_opt, thumbnail_info_opt); else return post_message(room, filename, event_id_response, file_info_opt, thumbnail_info_opt); } // |bypass_proxy| is used to use the remote homeserver url instead of pantalaimon (if pantalaimon is used) // for uploading emoji. PluginResult Matrix::upload_file(const std::string &filepath, std::string filename, UploadInfo &file_info, UploadInfo &thumbnail_info, std::string &err_msg, bool upload_thumbnail, bool bypass_proxy) { FileAnalyzer file_analyzer; if(!file_analyzer.load_file(filepath.c_str(), true)) { err_msg = "Failed to load " + filepath; return PluginResult::ERR; } file_info.content_type = file_analyzer.get_content_type(); file_info.file_size = file_analyzer.get_file_size(); file_info.dimensions = file_analyzer.get_dimensions(); file_info.duration_seconds = file_analyzer.get_duration_seconds(); if(filename.empty()) filename = file_get_filename(filepath); int64_t upload_limit; PluginResult config_result = get_config(&upload_limit); // get_config can fail sometimes??? happened on plan9.rocks, why? is /r0/config optional? if(config_result == PluginResult::OK && file_analyzer.get_file_size() > upload_limit) { err_msg = "File is too large! max upload size on your homeserver is " + std::to_string((double)upload_limit / 1024.0 / 1024.0) + " mb, the file you tried to upload is " + std::to_string((double)file_analyzer.get_file_size() / 1024.0 / 1024.0) + " mb"; return PluginResult::ERR; } if(upload_thumbnail && is_content_type_video(file_analyzer.get_content_type())) { char tmp_filename[] = "/tmp/quickmedia_video_frame_XXXXXX"; int tmp_file = mkstemp(tmp_filename); if(tmp_file != -1) { Path thumbnail_filename = filename; thumbnail_filename = thumbnail_filename.filename_no_ext() + ".thumb.jpg"; // TODO: See video_get_middle_frame why this is jpg if(video_get_middle_frame(file_analyzer, tmp_filename, thumbnail_max_size.x, thumbnail_max_size.y)) { UploadInfo upload_info_ignored; // Ignore because it wont be set anyways. Thumbnails dont have thumbnails. PluginResult upload_thumbnail_result = upload_file(tmp_filename, thumbnail_filename.data, thumbnail_info, upload_info_ignored, err_msg, false); if(upload_thumbnail_result != PluginResult::OK) { close(tmp_file); remove(tmp_filename); show_notification("QuickMedia", "Video was uploaded without a thumbnail, error: " + err_msg, Urgency::CRITICAL); } } else { fprintf(stderr, "Failed to get first frame of video, ignoring thumbnail...\n"); } close(tmp_file); remove(tmp_filename); } else { fprintf(stderr, "Failed to create temporary file for video thumbnail, ignoring thumbnail...\n"); } } else if(upload_thumbnail && is_content_type_image(file_analyzer.get_content_type())) { // Checking for sane file size limit client side, to prevent loading a huge file and crashing if(file_analyzer.get_file_size() > 300 * 1024 * 1024) { // 300mb err_msg = "File is too large! client-side limit for images is set to 300mb"; return PluginResult::ERR; } char tmp_filename[] = "/tmp/quickmedia_thumbnail_XXXXXX"; int tmp_file = mkstemp(tmp_filename); if(tmp_file != -1) { std::string thumbnail_path; if(create_thumbnail(filepath, tmp_filename, thumbnail_max_size, file_analyzer.get_content_type())) thumbnail_path = tmp_filename; else thumbnail_path = filepath; Path thumbnail_filename = filename; thumbnail_filename = thumbnail_filename.filename_no_ext() + ".thumb" + thumbnail_filename.ext(); UploadInfo upload_info_ignored; // Ignore because it wont be set anyways. Thumbnails dont have thumbnails. PluginResult upload_thumbnail_result = upload_file(thumbnail_path, thumbnail_filename.data, thumbnail_info, upload_info_ignored, err_msg, false); if(upload_thumbnail_result != PluginResult::OK) { close(tmp_file); remove(tmp_filename); return upload_thumbnail_result; } close(tmp_file); remove(tmp_filename); } else { fprintf(stderr, "Failed to create temporary file for image thumbnail, ignoring thumbnail...\n"); } } std::vector additional_args = { { "-X", "POST" }, { "-H", std::string("content-type: ") + content_type_to_string(file_analyzer.get_content_type()) }, { "-H", "Authorization: Bearer " + access_token }, { "--data-binary", "@" + filepath } }; std::string filename_escaped = url_param_encode(filename); std::string remote_homeserver_url = homeserver; if(bypass_proxy) remote_homeserver_url = get_remote_homeserver_url(); char url[512]; snprintf(url, sizeof(url), "%s/_matrix/media/r0/upload?filename=%s", remote_homeserver_url.c_str(), filename_escaped.c_str()); rapidjson::Document json_root; DownloadResult download_result = download_json(json_root, url, std::move(additional_args), true, &err_msg); if(download_result != DownloadResult::OK) return download_result_to_plugin_result(download_result); if(!json_root.IsObject()) { err_msg = "Got corrupt response from server"; return PluginResult::ERR; } const rapidjson::Value &error_json = GetMember(json_root, "error"); if(error_json.IsString()) { err_msg = error_json.GetString(); return PluginResult::ERR; } const rapidjson::Value &content_uri_json = GetMember(json_root, "content_uri"); if(!content_uri_json.IsString()) { err_msg = "Missing content_uri is server response"; return PluginResult::ERR; } fprintf(stderr, "Matrix upload, response content uri: %s\n", content_uri_json.GetString()); file_info.content_uri = content_uri_json.GetString(); return PluginResult::OK; } PluginResult Matrix::login(const std::string &username, const std::string &password, const std::string &homeserver, std::string &err_msg) { assert(!sync_running); rapidjson::Document identifier_json(rapidjson::kObjectType); identifier_json.AddMember("type", "m.id.user", identifier_json.GetAllocator()); // TODO: What if the server doesn't support this login type? redirect to sso web page etc identifier_json.AddMember("user", rapidjson::StringRef(username.c_str()), identifier_json.GetAllocator()); rapidjson::Document request_data(rapidjson::kObjectType); request_data.AddMember("type", "m.login.password", request_data.GetAllocator()); request_data.AddMember("identifier", std::move(identifier_json), request_data.GetAllocator()); request_data.AddMember("password", rapidjson::StringRef(password.c_str()), request_data.GetAllocator()); request_data.AddMember("initial_device_display_name", "QuickMedia", request_data.GetAllocator()); // :^) rapidjson::StringBuffer buffer; rapidjson::Writer writer(buffer); request_data.Accept(writer); std::vector additional_args = { { "-X", "POST" }, { "-H", "content-type: application/json" }, { "--data-binary", buffer.GetString() } }; rapidjson::Document json_root; DownloadResult download_result = download_json(json_root, homeserver + "/_matrix/client/r0/login", std::move(additional_args), true, &err_msg); if(download_result != DownloadResult::OK) return download_result_to_plugin_result(download_result); Path matrix_sync_data_path = get_cache_dir().join("matrix").join("sync_data.json"); remove(matrix_sync_data_path.data.c_str()); Path custom_emoji_path = get_cache_dir().join("matrix").join("custom_emoji.json"); remove(custom_emoji_path.data.c_str()); //Path filter_cache_path = get_storage_dir().join("matrix").join("filter"); //remove(filter_cache_path.data.c_str()); for_files_in_dir(get_cache_dir().join("matrix").join("events"), [](const Path &filepath, FileType) { remove(filepath.data.c_str()); return true; }); if(!json_root.IsObject()) { err_msg = "Failed to parse matrix login response"; return PluginResult::ERR; } const rapidjson::Value &error_json = GetMember(json_root, "error"); if(error_json.IsString()) { err_msg = error_json.GetString(); return PluginResult::ERR; } const rapidjson::Value &user_id_json = GetMember(json_root, "user_id"); if(!user_id_json.IsString()) { err_msg = "Failed to parse matrix login response"; return PluginResult::ERR; } const rapidjson::Value &access_token_json = GetMember(json_root, "access_token"); if(!access_token_json.IsString()) { err_msg = "Failed to parse matrix login response"; return PluginResult::ERR; } const rapidjson::Value &home_server_json = GetMember(json_root, "home_server"); if(home_server_json.IsString()) this->homeserver_domain = home_server_json.GetString(); const rapidjson::Value &well_known_json = GetMember(json_root, "well_known"); if(well_known_json.IsObject()) { const rapidjson::Value &homeserver_json = GetMember(well_known_json, "m.homeserver"); if(homeserver_json.IsObject()) { const rapidjson::Value &base_url_json = GetMember(homeserver_json, "base_url"); if(base_url_json.IsString()) well_known_base_url = base_url_json.GetString(); } } // Use the user-provided homeserver instead of the one the server tells us about, otherwise this wont work with a proxy // such as pantalaimon json_root.AddMember("homeserver", rapidjson::StringRef(homeserver.c_str()), request_data.GetAllocator()); this->my_user_id = user_id_json.GetString(); this->access_token = access_token_json.GetString(); this->homeserver = homeserver; // TODO: Handle well_known field. The spec says clients SHOULD handle it if its provided Path session_path = get_storage_dir().join(SERVICE_NAME); if(create_directory_recursive(session_path) == 0) { session_path.join("session.json"); if(!save_json_to_file_atomic(session_path, json_root)) { fprintf(stderr, "Warning: failed to save login response to %s\n", session_path.data.c_str()); } } else { fprintf(stderr, "Warning: failed to create directory: %s\n", session_path.data.c_str()); } return PluginResult::OK; } PluginResult Matrix::logout() { stop_sync(); Path session_path = get_storage_dir().join(SERVICE_NAME).join("session.json"); remove(session_path.data.c_str()); std::vector additional_args = { { "-X", "POST" }, { "-H", "Authorization: Bearer " + access_token } }; std::string server_response; download_to_string(homeserver + "/_matrix/client/r0/logout", server_response, std::move(additional_args), true); // Make sure all fields are reset here! rooms.clear(); room_data_by_id.clear(); my_user_id.clear(); access_token.clear(); homeserver.clear(); homeserver_domain.clear(); well_known_base_url.clear(); upload_limit.reset(); return PluginResult::OK; } PluginResult Matrix::delete_message(RoomData *room, void *message, std::string &err_msg){ std::string transaction_id = create_transaction_id(); if(transaction_id.empty()) return PluginResult::ERR; Message *message_typed = (Message*)message; // request_data could contains "reason", maybe it should be added sometime in the future? rapidjson::Value request_data(rapidjson::kObjectType); rapidjson::StringBuffer buffer; rapidjson::Writer writer(buffer); request_data.Accept(writer); std::vector additional_args = { { "-X", "PUT" }, { "-H", "content-type: application/json" }, { "-H", "Authorization: Bearer " + access_token }, { "--data-binary", buffer.GetString() } }; char url[512]; snprintf(url, sizeof(url), "%s/_matrix/client/r0/rooms/%s/redact/%s/%s", homeserver.c_str(), room->id.c_str(), message_typed->event_id.c_str(), transaction_id.c_str()); rapidjson::Document json_root; DownloadResult download_result = download_json(json_root, url, std::move(additional_args), true, &err_msg); if(download_result != DownloadResult::OK) return download_result_to_plugin_result(download_result); if(!json_root.IsObject()) { err_msg = "Failed to parse matrix login response"; return PluginResult::ERR; } const rapidjson::Value &error_json = GetMember(json_root, "error"); if(error_json.IsString()) { err_msg = error_json.GetString(); return PluginResult::ERR; } const rapidjson::Value &event_id_json = GetMember(json_root, "event_id"); if(!event_id_json.IsString()) return PluginResult::ERR; fprintf(stderr, "Matrix delete message, response event id: %s\n", event_id_json.GetString()); return PluginResult::OK; } PluginResult Matrix::get_pinned_events(RoomData *room, std::vector &pinned_events) { std::vector additional_args = { { "-H", "Authorization: Bearer " + access_token } }; char url[512]; snprintf(url, sizeof(url), "%s/_matrix/client/r0/rooms/%s/state/m.room.pinned_events/", homeserver.c_str(), room->id.c_str()); rapidjson::Document json_root; std::string err_msg; DownloadResult download_result = download_json(json_root, url, std::move(additional_args), true, &err_msg); if(download_result != DownloadResult::OK) return download_result_to_plugin_result(download_result); if(!json_root.IsObject()) { show_notification("QuickMedia", "Failed to parse server response", Urgency::CRITICAL); return PluginResult::ERR; } const rapidjson::Value &errcode_json = GetMember(json_root, "errcode"); if(errcode_json.IsString()) { // This is the case if the room has never set pinned events. // After a pinned event has added and then removed so the list is empty then an empty list is returned instead. if(strcmp(errcode_json.GetString(), "M_NOT_FOUND") == 0) return PluginResult::OK; const rapidjson::Value &error_json = GetMember(json_root, "error"); if(error_json.IsString()) { show_notification("QuickMedia", "Failed to get pinned events for room " + room->id + ", error: " + std::string(error_json.GetString(), error_json.GetStringLength()), Urgency::CRITICAL); return PluginResult::ERR; } } const rapidjson::Value &pinned_json = GetMember(json_root, "pinned"); if(!pinned_json.IsArray()) return PluginResult::ERR; for(const rapidjson::Value &event_json : pinned_json.GetArray()) { if(!event_json.IsString()) continue; pinned_events.emplace_back(event_json.GetString(), event_json.GetStringLength()); } return PluginResult::OK; } PluginResult Matrix::pin_message(RoomData *room, const std::string &event_id) { std::vector pinned_events; PluginResult result = get_pinned_events(room, pinned_events); if(result != PluginResult::OK) return result; pinned_events.push_back(event_id); return set_pinned_events(room, pinned_events, true); } PluginResult Matrix::unpin_message(RoomData *room, const std::string &event_id) { std::vector pinned_events; PluginResult result = get_pinned_events(room, pinned_events); if(result != PluginResult::OK) return result; auto find_it = std::find(pinned_events.begin(), pinned_events.end(), event_id); if(find_it == pinned_events.end()) return PluginResult::OK; pinned_events.erase(find_it); return set_pinned_events(room, pinned_events, false); } PluginResult Matrix::set_pinned_events(RoomData *room, const std::vector &pinned_events, bool is_add) { rapidjson::Document request_data(rapidjson::kObjectType); rapidjson::Value pinned_events_json(rapidjson::kArrayType); for(auto &pinned_event : pinned_events) { pinned_events_json.PushBack(rapidjson::StringRef(pinned_event.c_str()), request_data.GetAllocator()); } request_data.AddMember("pinned", std::move(pinned_events_json), request_data.GetAllocator()); rapidjson::StringBuffer buffer; rapidjson::Writer writer(buffer); request_data.Accept(writer); std::vector additional_args = { { "-X", "PUT" }, { "-H", "content-type: application/json" }, { "-H", "Authorization: Bearer " + access_token }, { "--data-binary", buffer.GetString() } }; char url[512]; snprintf(url, sizeof(url), "%s/_matrix/client/r0/rooms/%s/state/m.room.pinned_events/", homeserver.c_str(), room->id.c_str()); rapidjson::Document json_root; std::string err_msg; DownloadResult download_result = download_json(json_root, url, std::move(additional_args), true, &err_msg); if(download_result != DownloadResult::OK) return download_result_to_plugin_result(download_result); if(!json_root.IsObject()) { show_notification("QuickMedia", "Failed to parse server response", Urgency::CRITICAL); return PluginResult::ERR; } const rapidjson::Value &error_json = GetMember(json_root, "error"); if(error_json.IsString()) { show_notification("QuickMedia", std::string("Failed to ") + (is_add ? "pin" : "unpin") + " message, error: " + std::string(error_json.GetString(), error_json.GetStringLength()), Urgency::CRITICAL); return PluginResult::ERR; } return PluginResult::OK; } PluginResult Matrix::load_cached_session() { Path session_path = get_storage_dir().join(SERVICE_NAME).join("session.json"); std::string session_json_content; if(file_get_content(session_path, session_json_content) != 0) { fprintf(stderr, "Info: failed to read matrix session from %s. Either its missing or we failed to read the file\n", session_path.data.c_str()); return PluginResult::ERR; } rapidjson::Document json_root; rapidjson::ParseResult parse_result = json_root.Parse(session_json_content.c_str(), session_json_content.size()); if(parse_result.IsError()) { fprintf(stderr, "Matrix cached session parse error: %d\n", parse_result.Code()); return PluginResult::ERR; } if(!json_root.IsObject()) return PluginResult::ERR; const rapidjson::Value &user_id_json = GetMember(json_root, "user_id"); if(!user_id_json.IsString()) { fprintf(stderr, "Failed to parse matrix cached session response\n"); return PluginResult::ERR; } const rapidjson::Value &access_token_json = GetMember(json_root, "access_token"); if(!access_token_json.IsString()) { fprintf(stderr, "Failed to parse matrix cached session response\n"); return PluginResult::ERR; } const rapidjson::Value &homeserver_json = GetMember(json_root, "homeserver"); if(!homeserver_json.IsString()) { fprintf(stderr, "Failed to parse matrix cached session response\n"); return PluginResult::ERR; } const rapidjson::Value &home_server_json = GetMember(json_root, "home_server"); if(home_server_json.IsString()) this->homeserver_domain = home_server_json.GetString(); const rapidjson::Value &well_known_json = GetMember(json_root, "well_known"); if(well_known_json.IsObject()) { const rapidjson::Value &homeserver_json = GetMember(well_known_json, "m.homeserver"); if(homeserver_json.IsObject()) { const rapidjson::Value &base_url_json = GetMember(homeserver_json, "base_url"); if(base_url_json.IsString()) well_known_base_url = base_url_json.GetString(); } } this->my_user_id = user_id_json.GetString(); this->access_token = access_token_json.GetString(); this->homeserver = homeserver_json.GetString(); return PluginResult::OK; } PluginResult Matrix::on_start_typing(RoomData *room) { if(!QuickMedia::get_config().matrix.send_typing_notifications) return PluginResult::OK; rapidjson::Document request_data(rapidjson::kObjectType); request_data.AddMember("typing", true, request_data.GetAllocator()); request_data.AddMember("timeout", 30000, request_data.GetAllocator()); // 30 sec timeout rapidjson::StringBuffer buffer; rapidjson::Writer writer(buffer); request_data.Accept(writer); std::vector additional_args = { { "-X", "PUT" }, { "-H", "content-type: application/json" }, { "--data-binary", buffer.GetString() }, { "-H", "Authorization: Bearer " + access_token } }; std::string server_response; DownloadResult download_result = download_to_string(homeserver + "/_matrix/client/r0/rooms/" + room->id + "/typing/" + url_param_encode(my_user_id) , server_response, std::move(additional_args), true); return download_result_to_plugin_result(download_result); } PluginResult Matrix::on_stop_typing(RoomData *room) { if(!QuickMedia::get_config().matrix.send_typing_notifications) return PluginResult::OK; rapidjson::Document request_data(rapidjson::kObjectType); request_data.AddMember("typing", false, request_data.GetAllocator()); rapidjson::StringBuffer buffer; rapidjson::Writer writer(buffer); request_data.Accept(writer); std::vector additional_args = { { "-X", "PUT" }, { "-H", "content-type: application/json" }, { "--data-binary", buffer.GetString() }, { "-H", "Authorization: Bearer " + access_token } }; std::string server_response; DownloadResult download_result = download_to_string(homeserver + "/_matrix/client/r0/rooms/" + room->id + "/typing/" + url_param_encode(my_user_id), server_response, std::move(additional_args), true); return download_result_to_plugin_result(download_result); } PluginResult Matrix::set_read_marker(RoomData *room, const std::string &event_id, int64_t event_timestamp) { rapidjson::Document request_data(rapidjson::kObjectType); request_data.AddMember("m.fully_read", rapidjson::StringRef(event_id.c_str()), request_data.GetAllocator()); if(QuickMedia::get_config().matrix.send_read_receipts) request_data.AddMember("m.read", rapidjson::StringRef(event_id.c_str()), request_data.GetAllocator()); rapidjson::StringBuffer buffer; rapidjson::Writer writer(buffer); request_data.Accept(writer); std::vector additional_args = { { "-X", "POST" }, { "-H", "content-type: application/json" }, { "--data-binary", buffer.GetString() }, { "-H", "Authorization: Bearer " + access_token } }; std::string server_response; DownloadResult download_result = download_to_string(homeserver + "/_matrix/client/r0/rooms/" + room->id + "/read_markers", server_response, std::move(additional_args), true); auto me = get_me(room); if(me) room->set_user_read_marker(me, event_id); set_qm_last_read_message_timestamp(room, event_timestamp); return download_result_to_plugin_result(download_result); } PluginResult Matrix::set_qm_last_read_message_timestamp(RoomData *room, int64_t timestamp) { rapidjson::Document request_data(rapidjson::kObjectType); { std::lock_guard lock(room_data_mutex); if(!load_qm_read_markers_from_account_data()) { // TODO: Only do this if multiple instances of quickmedia matrix is running (on the same account) fprintf(stderr, "Error: load_qm_read_markers_from_account_data failed, failed to set read marker\n"); return PluginResult::ERR; } qm_read_markers_by_room_cache[room->id] = timestamp; room->read_marker_event_timestamp = timestamp; for(const auto &[key, val] : qm_read_markers_by_room_cache) { request_data.AddMember(rapidjson::Value(key.c_str(), request_data.GetAllocator()).Move(), val, request_data.GetAllocator()); } } rapidjson::StringBuffer buffer; rapidjson::Writer writer(buffer); request_data.Accept(writer); std::vector additional_args = { { "-X", "PUT" }, { "-H", "content-type: application/json" }, { "-H", "Authorization: Bearer " + access_token }, { "--data-binary", buffer.GetString() } }; std::string server_response; DownloadResult download_result = download_to_string(homeserver + "/_matrix/client/r0/user/" + my_user_id + "/account_data/qm.last_read_message_timestamp", server_response, std::move(additional_args), true); if(download_result != DownloadResult::OK) return download_result_to_plugin_result(download_result); return PluginResult::OK; } bool Matrix::load_qm_read_markers_from_account_data() { std::vector additional_args = { { "-H", "content-type: application/json" }, { "-H", "Authorization: Bearer " + access_token } }; rapidjson::Document json_root; std::string err_msg; DownloadResult download_result = download_json(json_root, homeserver + "/_matrix/client/r0/user/" + my_user_id + "/account_data/qm.last_read_message_timestamp", std::move(additional_args), true, &err_msg); if(download_result != DownloadResult::OK) { fprintf(stderr, "Warning: failed to get account qm.last_read_message_timestamp\n"); return false; } if(!json_root.IsObject()) return false; std::lock_guard lock(room_data_mutex); qm_read_markers_by_room_cache.clear(); for(auto const &obj : json_root.GetObject()) { if(!obj.name.IsString() || !obj.value.IsInt64()) continue; std::string room_id = std::string(obj.name.GetString(), obj.name.GetStringLength()); auto room_it = room_data_by_id.find(room_id); if(room_it != room_data_by_id.end()) rooms[room_it->second]->read_marker_event_timestamp = obj.value.GetInt64(); qm_read_markers_by_room_cache[std::move(room_id)] = obj.value.GetInt64(); } return true; } static std::string room_id_extract_server_name(const std::string &room_id) { size_t index = room_id.find(':'); if(index == std::string::npos) return ""; else return room_id.substr(index + 1); } PluginResult Matrix::join_room(const std::string &room_id_or_name) { assert(delegate); std::string url = homeserver + "/_matrix/client/r0/join/" + url_param_encode(room_id_or_name); if(!room_id_or_name.empty() && room_id_or_name[0] == '!') url += "?via=" + url_param_encode(room_id_extract_server_name(room_id_or_name)); std::vector additional_args = { { "-X", "POST" }, { "-H", "content-type: application/json" }, { "--data-binary", "{}" }, { "-H", "Authorization: Bearer " + access_token } }; rapidjson::Document json_root; std::string err_msg; DownloadResult download_result = download_json(json_root, url, std::move(additional_args), true, &err_msg); if(download_result != DownloadResult::OK) return download_result_to_plugin_result(download_result); if(!json_root.IsObject()) return PluginResult::ERR; const rapidjson::Value &error_json = GetMember(json_root, "error"); if(error_json.IsString()) { show_notification("QuickMedia", "Failed to join " + room_id_or_name + ", error: " + std::string(error_json.GetString(), error_json.GetStringLength()), Urgency::CRITICAL); return PluginResult::ERR; } const rapidjson::Value &room_id_json = GetMember(json_root, "room_id"); if(!room_id_json.IsString()) return PluginResult::ERR; const std::string room_id(room_id_json.GetString(), room_id_json.GetStringLength()); std::lock_guard invite_lock(invite_mutex); auto invite_it = invites.find(room_id); if(invite_it != invites.end()) { std::lock_guard lock(room_data_mutex); RoomData *room = get_room_by_id(room_id); if(!room) { auto new_room = std::make_unique(); new_room->id = room_id; new_room->set_name(invite_it->second.room_name, invite_it->second.timestamp); new_room->set_avatar_url(invite_it->second.room_avatar_url, invite_it->second.timestamp); room = new_room.get(); add_room(std::move(new_room)); ui_thread_tasks.push([this, room]{ delegate->join_room(room); }); room->acquire_room_lock(); std::set &room_tags = room->get_tags_thread_unsafe(); if(room_tags.empty()) { room_tags.insert(OTHERS_ROOM_TAG); ui_thread_tasks.push([this, room]{ delegate->room_add_tag(room, OTHERS_ROOM_TAG); }); } room->release_room_lock(); } } return PluginResult::OK; } PluginResult Matrix::leave_room(const std::string &room_id) { std::vector additional_args = { { "-X", "POST" }, { "-H", "content-type: application/json" }, { "--data-binary", "{}" }, { "-H", "Authorization: Bearer " + access_token } }; std::string server_response; DownloadResult download_result = download_to_string(homeserver + "/_matrix/client/r0/rooms/" + url_param_encode(room_id) + "/leave", server_response, std::move(additional_args), true); if(download_result == DownloadResult::OK) { RoomData *room = get_room_by_id(room_id); if(room) { ui_thread_tasks.push([this, room]{ delegate->leave_room(room, LeaveType::LEAVE, ""); }); remove_room(room_id); } } return download_result_to_plugin_result(download_result); } bool Matrix::is_invite_silenced(const std::string &room_id, int64_t timestamp) { return silenced_invites.find(room_id + "|" + std::to_string(timestamp)) != silenced_invites.end(); } void Matrix::silence_invite(const std::string &room_id, int64_t timestamp) { std::string line = room_id + "|" + std::to_string(timestamp); auto it = silenced_invites.find(line); if(it != silenced_invites.end()) return; Path matrix_dir = get_storage_dir().join("matrix"); create_directory_recursive(matrix_dir); Path silenced_invites_path = matrix_dir.join("silenced_invites"); FILE *file = fopen(silenced_invites_path.data.c_str(), "ab"); if(!file) return; line += "\n"; fwrite(line.data(), 1, line.size(), file); fclose(file); } PluginResult Matrix::get_public_rooms(const std::string &server, const std::string &search_term, const std::string &since, BodyItems &rooms, std::string &next_batch) { rapidjson::Document filter_data(rapidjson::kObjectType); if(!search_term.empty()) filter_data.AddMember("generic_search_term", rapidjson::StringRef(search_term.c_str()), filter_data.GetAllocator()); rapidjson::Document request_data(rapidjson::kObjectType); request_data.AddMember("limit", 20, request_data.GetAllocator()); if(!search_term.empty()) request_data.AddMember("filter", std::move(filter_data), request_data.GetAllocator()); if(!since.empty()) request_data.AddMember("since", rapidjson::StringRef(since.c_str()), request_data.GetAllocator()); rapidjson::StringBuffer buffer; rapidjson::Writer writer(buffer); request_data.Accept(writer); std::vector additional_args = { { "-X", "POST" }, { "-H", "content-type: application/json" }, { "-H", "Authorization: Bearer " + access_token }, { "--data-binary", buffer.GetString() } }; std::string url = homeserver + "/_matrix/client/r0/publicRooms?server="; url += url_param_encode(server); rapidjson::Document json_root; DownloadResult download_result = download_json(json_root, url, std::move(additional_args), true); if(download_result != DownloadResult::OK) return download_result_to_plugin_result(download_result); if(!json_root.IsObject()) return PluginResult::ERR; const rapidjson::Value &next_batch_json = GetMember(json_root, "next_batch"); if(next_batch_json.IsString()) next_batch = next_batch_json.GetString(); else next_batch.clear(); const rapidjson::Value &chunk_json = GetMember(json_root, "chunk"); if(chunk_json.IsArray()) { for(const rapidjson::Value &chunk_item_json : chunk_json.GetArray()) { if(!chunk_item_json.IsObject()) continue; const rapidjson::Value &room_id_json = GetMember(chunk_item_json, "room_id"); if(!room_id_json.IsString()) continue; std::string room_name; const rapidjson::Value &name_json = GetMember(chunk_item_json, "name"); if(name_json.IsString()) room_name = name_json.GetString(); else room_name = room_id_json.GetString(); auto room_body_item = BodyItem::create(std::move(room_name)); room_body_item->url = room_id_json.GetString(); std::string description; const rapidjson::Value &topic_json = GetMember(chunk_item_json, "topic"); if(topic_json.IsString()) description = strip(topic_json.GetString()); const rapidjson::Value &canonical_alias_json = GetMember(chunk_item_json, "canonical_alias"); if(canonical_alias_json.IsString()) { if(!description.empty()) description += '\n'; description += canonical_alias_json.GetString(); room_body_item->url = canonical_alias_json.GetString(); } const rapidjson::Value &num_joined_members_json = GetMember(chunk_item_json, "num_joined_members"); if(num_joined_members_json.IsInt()) { if(!description.empty()) description += '\n'; const int num_joined_numbers = num_joined_members_json.GetInt(); description += ("👤" + std::to_string(num_joined_numbers) + " user" + (num_joined_numbers == 1 ? "" : "s")); } room_body_item->set_description(std::move(description)); const rapidjson::Value &avatar_url_json = GetMember(chunk_item_json, "avatar_url"); if(avatar_url_json.IsString()) { std::string avatar_url = thumbnail_url_extract_media_id(avatar_url_json.GetString()); if(!avatar_url.empty()) avatar_url = get_avatar_thumbnail_url(homeserver, avatar_url); if(!avatar_url.empty()) room_body_item->thumbnail_url = std::move(avatar_url); } room_body_item->thumbnail_size = {32, 32}; room_body_item->thumbnail_mask_type = ThumbnailMaskType::CIRCLE; rooms.push_back(std::move(room_body_item)); } } return PluginResult::OK; } PluginResult Matrix::search_user(const std::string &search_term, unsigned int limit, BodyItems &result_items) { rapidjson::Document request_data(rapidjson::kObjectType); request_data.AddMember("search_term", rapidjson::StringRef(search_term.c_str()), request_data.GetAllocator()); request_data.AddMember("limit", limit, request_data.GetAllocator()); rapidjson::StringBuffer buffer; rapidjson::Writer writer(buffer); request_data.Accept(writer); std::vector additional_args = { { "-X", "POST" }, { "-H", "content-type: application/json" }, { "--data-binary", buffer.GetString() }, { "-H", "Authorization: Bearer " + access_token } }; rapidjson::Document json_root; std::string err_msg; DownloadResult download_result = download_json(json_root, homeserver + "/_matrix/client/r0/user_directory/search", std::move(additional_args), true, &err_msg); if(download_result != DownloadResult::OK) return download_result_to_plugin_result(download_result); if(!json_root.IsObject()) return PluginResult::ERR; const rapidjson::Value &error_json = GetMember(json_root, "error"); if(error_json.IsString()) { show_notification("QuickMedia", "Failed to search for " + search_term + ", error: " + std::string(error_json.GetString(), error_json.GetStringLength()), Urgency::CRITICAL); return PluginResult::ERR; } const rapidjson::Value &results_json = GetMember(json_root, "results"); if(!results_json.IsArray()) return PluginResult::OK; for(const rapidjson::Value &result_item_json : results_json.GetArray()) { if(!result_item_json.IsObject()) continue; const rapidjson::Value &user_id_json = GetMember(result_item_json, "user_id"); const rapidjson::Value &display_name_json = GetMember(result_item_json, "display_name"); const rapidjson::Value &avatar_url_json = GetMember(result_item_json, "avatar_url"); if(!user_id_json.IsString()) continue; auto body_item = BodyItem::create(""); body_item->url.assign(user_id_json.GetString(), user_id_json.GetStringLength()); body_item->set_description(body_item->url); body_item->set_description_color(get_theme().faded_text_color); if(display_name_json.IsString()) body_item->set_author(std::string(display_name_json.GetString(), display_name_json.GetStringLength())); else body_item->set_author(std::string(user_id_json.GetString(), user_id_json.GetStringLength())); body_item->set_author_color(user_id_to_color(body_item->url)); if(avatar_url_json.IsString()) { std::string avatar_url = thumbnail_url_extract_media_id(std::string(avatar_url_json.GetString(), avatar_url_json.GetStringLength())); if(!avatar_url.empty()) avatar_url = get_avatar_thumbnail_url(homeserver, avatar_url); body_item->thumbnail_url = std::move(avatar_url); } body_item->thumbnail_mask_type = ThumbnailMaskType::CIRCLE; body_item->thumbnail_size = mgl::vec2i(32, 32); result_items.push_back(std::move(body_item)); } return PluginResult::OK; } PluginResult Matrix::invite_user(const std::string &room_id, const std::string &user_id) { rapidjson::Document request_data(rapidjson::kObjectType); request_data.AddMember("user_id", rapidjson::StringRef(user_id.c_str()), request_data.GetAllocator()); rapidjson::StringBuffer buffer; rapidjson::Writer writer(buffer); request_data.Accept(writer); std::vector additional_args = { { "-X", "POST" }, { "-H", "content-type: application/json" }, { "--data-binary", buffer.GetString() }, { "-H", "Authorization: Bearer " + access_token } }; rapidjson::Document json_root; std::string err_msg; DownloadResult download_result = download_json(json_root, homeserver + "/_matrix/client/r0/rooms/" + room_id + "/invite", std::move(additional_args), true, &err_msg); if(download_result != DownloadResult::OK) return download_result_to_plugin_result(download_result); if(!json_root.IsObject()) return PluginResult::ERR; const rapidjson::Value &error_json = GetMember(json_root, "error"); if(error_json.IsString()) { show_notification("QuickMedia", "Failed to invite " + user_id + " to " + room_id + ", error: " + std::string(error_json.GetString(), error_json.GetStringLength()), Urgency::CRITICAL); return PluginResult::ERR; } return PluginResult::OK; } bool Matrix::was_message_posted_by_me(void *message) { Message *message_typed = (Message*)message; return my_user_id == message_typed->user->user_id; } std::string Matrix::message_get_author_displayname(Message *message) const { return message->user->room->get_user_display_name(message->user); } PluginResult Matrix::get_config(int64_t *upload_size) { // TODO: What if the upload limit changes? is it possible for the upload limit to change while the server is running? if(upload_limit) { *upload_size = upload_limit.value(); return PluginResult::OK; } *upload_size = 0; std::vector additional_args = { { "-H", "Authorization: Bearer " + access_token } }; char url[512]; snprintf(url, sizeof(url), "%s/_matrix/media/r0/config", homeserver.c_str()); rapidjson::Document json_root; DownloadResult download_result = download_json(json_root, url, std::move(additional_args), true); if(download_result != DownloadResult::OK) return download_result_to_plugin_result(download_result); if(!json_root.IsObject()) return PluginResult::ERR; const rapidjson::Value &upload_size_json = GetMember(json_root, "m.upload.size"); if(!upload_size_json.IsInt64()) return PluginResult::ERR; upload_limit = upload_size_json.GetInt64(); *upload_size = upload_limit.value(); return PluginResult::OK; } std::shared_ptr Matrix::get_me(RoomData *room) { return get_user_by_id(room, my_user_id); } const std::string& Matrix::get_homeserver_domain() const { return homeserver_domain; } std::string Matrix::get_remote_homeserver_url() const { if(!well_known_base_url.empty()) return well_known_base_url; if(!homeserver_domain.empty()) { if(string_starts_with(homeserver_domain, "http://") || string_starts_with(homeserver_domain, "https://")) return homeserver_domain; return "https://" + homeserver_domain; // TODO: What if domain does not use https? } if(string_starts_with(homeserver, "http://") || string_starts_with(homeserver, "https://")) return homeserver; return "https://" + homeserver; // TODO: What if domain does not use https? } RoomData* Matrix::get_room_by_id(const std::string &id) { std::lock_guard lock(room_data_mutex); auto room_it = room_data_by_id.find(id); if(room_it == room_data_by_id.end()) return nullptr; return rooms[room_it->second].get(); } void Matrix::add_room(std::unique_ptr room) { std::lock_guard lock(room_data_mutex); room->index = rooms.size(); room_data_by_id.insert(std::make_pair(room->id, room->index)); auto read_marker_it = qm_read_markers_by_room_cache.find(room->id); if(read_marker_it != qm_read_markers_by_room_cache.end()) room->read_marker_event_timestamp = read_marker_it->second; rooms.push_back(std::move(room)); } void Matrix::remove_room(const std::string &room_id) { std::lock_guard lock(room_data_mutex); auto room_it = room_data_by_id.find(room_id); if(room_it == room_data_by_id.end()) return; // We want to clear data instead of removing the object iself because we want to instance to still be valid, // also in the future we can have a "history" tag for rooms we have just left rooms[room_it->second]->clear_data(); room_data_by_id.erase(room_it); } bool Matrix::set_invite(const std::string &room_id, Invite invite) { std::lock_guard lock(invite_mutex); auto res = invites.insert(std::make_pair(room_id, std::move(invite))); return res.second; } bool Matrix::remove_invite(const std::string &room_id) { std::lock_guard lock(invite_mutex); auto invites_it = invites.find(room_id); if(invites_it != invites.end()) { invites.erase(invites_it); return true; } return false; } void Matrix::set_next_batch(std::string new_next_batch, bool set_initial_sync) { std::lock_guard lock(next_batch_mutex); next_batch = std::move(new_next_batch); if(set_initial_sync) initial_sync_finished = !next_batch.empty(); } std::string Matrix::get_next_batch() { std::lock_guard lock(next_batch_mutex); return next_batch; } void Matrix::set_next_notifications_token(std::string new_next_token) { std::lock_guard lock(next_batch_mutex); next_notifications_token = std::move(new_next_token); } std::string Matrix::get_next_notifications_token() { std::lock_guard lock(next_batch_mutex); return next_notifications_token; } std::shared_ptr Matrix::get_user_by_id(RoomData *room, const std::string &user_id, bool *is_new_user, bool create_if_not_found) { auto user = room->get_user_by_id(user_id); if(user) { if(is_new_user) *is_new_user = false; return user; } if(!create_if_not_found) return nullptr; //fprintf(stderr, "Unknown user: %s, creating locally... synapse bug?\n", user_id.c_str()); auto user_info = std::make_shared(room, user_id); room->add_user(user_info); if(is_new_user) *is_new_user = true; return user_info; } void Matrix::update_room_users(RoomData *room) { #if 1 std::vector additional_args = { { "-H", "Authorization: Bearer " + access_token } }; char url[512]; snprintf(url, sizeof(url), "%s/_matrix/client/r0/rooms/%s/joined_members", homeserver.c_str(), room->id.c_str()); rapidjson::Document json_root; DownloadResult download_result = download_json(json_root, url, std::move(additional_args), true); if(download_result != DownloadResult::OK || !json_root.IsObject()) { fprintf(stderr, "Fetching users for room %s failed!\n", room->id.c_str()); return; } const rapidjson::Value &joined_json = GetMember(json_root, "joined"); if(!joined_json.IsObject()) return; for(auto const &joined_obj : joined_json.GetObject()) { if(!joined_obj.name.IsString() || !joined_obj.value.IsObject()) continue; const rapidjson::Value &avatar_url_json = GetMember(joined_obj.value, "avatar_url"); const rapidjson::Value &display_name_json = GetMember(joined_obj.value, "display_name"); const rapidjson::Value &displayname_json = GetMember(joined_obj.value, "displayname"); // Construct bug... std::string user_id(joined_obj.name.GetString(), joined_obj.name.GetStringLength()); bool is_new_user; auto user = get_user_by_id(room, user_id, &is_new_user); assert(user); std::string display_name; if(display_name_json.IsString()) display_name = display_name_json.GetString(); else if(displayname_json.IsString()) display_name = displayname_json.GetString(); else display_name = user_id; std::string avatar_url; if(avatar_url_json.IsString()) avatar_url = std::string(avatar_url_json.GetString(), avatar_url_json.GetStringLength()); if(!avatar_url.empty()) avatar_url = get_avatar_thumbnail_url(homeserver, thumbnail_url_extract_media_id(avatar_url)); // TODO: Remove the constant strings around to reduce memory usage (6.3mb) room->set_user_avatar_url(user, avatar_url, 0); room->set_user_display_name(user, display_name, 0); MatrixEventUserInfo user_info; user_info.user_id = user_id; user_info.display_name = display_name; user_info.avatar_url = avatar_url; if(is_new_user) { trigger_event(room, MatrixEventType::ADD_USER, std::move(user_info)); } else { trigger_event(room, MatrixEventType::USER_INFO, std::move(user_info)); } } #else std::vector additional_args = { { "-H", "Authorization: Bearer " + access_token } }; // TODO: Use at param? which is room->get_prev_batch(); char url[512]; snprintf(url, sizeof(url), "%s/_matrix/client/r0/rooms/%s/members?not_membership=leave", homeserver.c_str(), room->id.c_str()); rapidjson::Document json_root; DownloadResult download_result = download_json(json_root, url, std::move(additional_args), true); if(download_result != DownloadResult::OK || !json_root.IsObject()) { fprintf(stderr, "Fetching users for room %s failed!\n", room->id.c_str()); return; } const rapidjson::Value &chunk_json = GetMember(json_root, "chunk"); if(!chunk_json.IsArray()) return; events_add_user_info(chunk_json, room, 0); #endif } // TODO: GET the filter to check if its valid? std::string Matrix::get_filter_cached() { #if 0 if(filter_cached) return filter_cached.value(); Path filter_path = get_storage_dir().join("matrix").join("filter"); std::string file_content; if(file_get_content(filter_path, file_content) == 0) { fprintf(stderr, "Loaded filter from cache\n"); filter_cached = file_content; return filter_cached.value(); } fprintf(stderr, "Filter not available in cache, adding to server\n"); std::vector additional_args = { { "-X", "POST" }, { "-H", "Authorization: Bearer " + access_token }, { "--data-binary", FILTER } }; char url[512]; snprintf(url, sizeof(url), "%s/_matrix/client/r0/user/%s/filter", homeserver.c_str(), user_id.c_str()); rapidjson::Document json_root; DownloadResult download_result = download_json(json_root, url, std::move(additional_args), true); if(download_result != DownloadResult::OK || !json_root.IsObject()) return FILTER; const rapidjson::Value &filter_id_json = GetMember(json_root, "filter_id"); if(!filter_id_json.IsString()) return FILTER; filter_cached = filter_id_json.GetString(); Path filter_dir = get_storage_dir().join("matrix"); if(create_directory_recursive(filter_dir) == 0) { file_overwrite_atomic(filter_path, filter_cached.value()); } return filter_cached.value(); #else assert(false); return INITIAL_FILTER; #endif } void Matrix::load_silenced_invites() { std::string file_content; if(file_get_content(get_storage_dir().join("matrix").join("silenced_invites"), file_content) != 0) return; silenced_invites.clear(); string_split(file_content, '\n', [&](const char *str, size_t size) { silenced_invites.insert(std::string(str, size)); return true; }, false); } void Matrix::update() { const size_t max_updates = 5000; size_t update_counter = 0; std::optional> task; while((task = ui_thread_tasks.pop_if_available()) != std::nullopt && update_counter < max_updates) { task.value()(); ++update_counter; } } void Matrix::trigger_event(RoomData *room, MatrixEventType type, MatrixEventUserInfo user_info) { user_info.room = room; switch(type) { case MatrixEventType::ADD_USER: { ui_thread_tasks.push([this, user_info{std::move(user_info)}]{ delegate->add_user(std::move(user_info)); }); break; } case MatrixEventType::REMOVE_USER: { ui_thread_tasks.push([this, user_info{std::move(user_info)}]{ delegate->remove_user(std::move(user_info)); }); break; } case MatrixEventType::USER_INFO: { ui_thread_tasks.push([this, user_info{std::move(user_info)}]{ delegate->set_user_info(std::move(user_info)); }); break; } default: break; } } void Matrix::trigger_event(RoomData *room, MatrixEventType type, MatrixEventRoomInfo room_info) { room_info.room = room; switch(type) { case MatrixEventType::ROOM_INFO: { ui_thread_tasks.push([this, room_info{std::move(room_info)}]{ delegate->set_room_info(std::move(room_info)); }); break; } default: break; } } }