#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 #include #include #include #include #include #include #include "../../include/QuickMedia.hpp" // 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. namespace QuickMedia { static const mgl::vec2i thumbnail_max_size(600, 337); 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\":{\"limit\":0,\"types\":[\"\"]},\"room\":{\"state\":{\"not_types\":[\"m.room.related_groups\",\"m.room.power_levels\",\"m.room.join_rules\",\"m.room.history_visibility\"],\"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* ADDITIONAL_MESSAGES_FILTER = "{\"presence\":{\"limit\":0,\"types\":[\"\"]},\"account_data\":{\"limit\":0,\"types\":[\"\"]},\"room\":{\"state\":{\"not_types\":[\"m.room.related_groups\",\"m.room.power_levels\",\"m.room.join_rules\",\"m.room.history_visibility\"],\"lazy_load_members\":true},\"timeline\":{\"limit\":20,\"lazy_load_members\":true},\"ephemeral\":{\"limit\":0,\"types\":[\"\"],\"lazy_load_members\":true},\"account_data\":{\"limit\":0,\"types\":[\"\"],\"lazy_load_members\":true}}}"; static const char* CONTINUE_FILTER = "{\"presence\":{\"limit\":0,\"types\":[\"\"]},\"account_data\":{\"limit\":0,\"types\":[\"\"]},\"room\":{\"state\":{\"not_types\":[\"m.room.related_groups\",\"m.room.power_levels\",\"m.room.join_rules\",\"m.room.history_visibility\"],\"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 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, "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); } 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]; } 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 = user_id; } UserInfo::UserInfo(RoomData *room, std::string user_id, std::string display_name, std::string avatar_url) : room(room), display_name_color(user_id_to_color(user_id)), user_id(std::move(user_id)), display_name(std::move(display_name)), avatar_url(std::move(avatar_url)) { } // 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; } std::string RoomData::get_user_avatar_url(const std::shared_ptr &user) { std::lock_guard lock(user_mutex); return user->avatar_url; } void RoomData::set_user_display_name(std::shared_ptr &user, std::string display_name) { std::lock_guard lock(user_mutex); user->display_name = std::move(display_name); if(user->display_name.empty()) user->display_name = user->user_id; } void RoomData::set_user_avatar_url(std::shared_ptr &user, std::string avatar_url) { std::lock_guard lock(user_mutex); user->avatar_url = std::move(avatar_url); } 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; 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; } 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.empty(); } void RoomData::set_name(const std::string &new_name) { std::lock_guard lock(room_mutex); name = new_name; } std::string RoomData::get_name() { std::lock_guard lock(room_mutex); return name; } void RoomData::set_topic(const std::string &new_topic) { std::lock_guard lock(room_mutex); topic = new_topic; } std::string RoomData::get_topic() { std::lock_guard lock(room_mutex); return topic; } bool RoomData::has_avatar_url() { std::lock_guard lock(room_mutex); return !avatar_url.empty(); } void RoomData::set_avatar_url(const std::string &new_avatar_url) { std::lock_guard lock(room_mutex); avatar_url = new_avatar_url; } std::string RoomData::get_avatar_url() { std::lock_guard lock(room_mutex); return avatar_url; } 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(); size_t i = 0; for(auto it = messages.begin(); it != messages.end();) { if((*it)->cache) { message_by_event_id.erase((*it)->event_id); it = messages.erase(it); if(i <= messages_read_index) --messages_read_index; } else { ++it; } ++i; } //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, bool is_cache) { 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(!is_cache && 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, bool sync_is_cache, MessageDirection message_dir) { bool is_window_focused = program->is_window_focused(); RoomData *current_room = program->get_current_chat_room(); if(!sync_is_cache && message_dir == MessageDirection::AFTER) { for(auto &message : messages) { if(message->notification_mentions_me) { std::string body = remove_reply_formatting(message->body); bool read = 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); read = false; } 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, sync_is_cache); } 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); invites_page->add_body_item(std::move(body_item)); if(invite.new_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); } 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::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); } 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); } void MatrixQuickMedia::clear_data() { //room_body_item_by_room.clear(); //pending_room_messages.clear(); //rooms_page->clear_data(); //room_tags_page->clear_data(); invites_page->clear_data(); //unread_notifications.clear(); } 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; } static std::string message_to_room_description_text(Message *message) { std::string body = strip(message->body); if(message->type == MessageType::REACTION) return "Reacted with: " + extract_first_line_remove_newline_elipses(body, 150); else if(message->related_event_type == RelatedEventType::REPLY) return extract_first_line_remove_newline_elipses(remove_reply_formatting(body), 150); else if(message->related_event_type == RelatedEventType::EDIT) return "Edited: " + extract_first_line_remove_newline_elipses(remove_reply_formatting(body), 150); else return extract_first_line_remove_newline_elipses(body, 150); } void MatrixQuickMedia::update_room_description(RoomData *room, const Messages &new_messages, bool is_initial_sync, bool sync_is_cache) { time_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(!sync_is_cache && (last_unread_message || room->unread_notification_count > 0)) { 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); std::string room_desc; if(set_room_as_unread) room_desc += "Unread: "; if(last_unread_message) room_desc += extract_first_line_remove_newline_elipses(matrix->message_get_author_displayname(last_unread_message), AUTHOR_MAX_LENGTH) + ": " + message_to_room_description_text(last_unread_message); int unread_notification_count = room->unread_notification_count; if(unread_notification_count > 0 && set_room_as_unread) { if(!room_desc.empty()) room_desc += '\n'; 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); } else { room->body_item->set_description_color(get_theme().faded_text_color); } room->body_item->set_description(std::move(room_desc)); if(set_room_as_unread) room->body_item->set_title_color(get_theme().attention_alert_text_color); 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->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(last_new_message.get())); room->body_item->set_description_color(get_theme().faded_text_color); 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(); } void MatrixRoomsPage::clear_data() { body->clear_items(); if(current_chat_page) current_chat_page->should_clear_data = true; } 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, {}})); // TODO: Sort by tag priority 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; } void MatrixRoomTagsPage::clear_data() { tag_body_items_by_name.clear(); body->clear_items(); if(current_rooms_page) current_rooms_page->clear_data(); } 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")); 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); } } 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()) + ")"; } } void MatrixInvitesPage::clear_data() { body->clear_items(); prev_invite_count = 0; title = "Invites (0)"; } 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; } static void add_user_to_body_by_user_info(Body *users_body, 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); 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(users_body, user_info); } void MatrixChatPage::remove_user(MatrixEventUserInfo user_info) { if(!current_room || !users_body || user_info.room != current_room) return; // 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; // TODO: Optimize auto user_body_item = users_body->find_item([&user_info](std::shared_ptr &it) { return it->url == user_info.user_id; }); if(!user_body_item) return; if(user_info.avatar_url) user_body_item->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(); user_body_item->set_author(extract_first_line_remove_newline_elipses(*display_name, AUTHOR_MAX_LENGTH)); users_body->apply_search_filter_for_item(user_body_item.get()); } } void MatrixChatPage::set_current_room(RoomData *room, Body *users_body) { this->current_room = room; this->users_body = users_body; if(!room || !users_body) return; rooms_page->matrix_delegate->for_each_user_in_room(room, [users_body](const MatrixEventUserInfo &user_info) { add_user_to_body_by_user_info(users_body, 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); body_item->set_description_color(get_theme().attention_alert_text_color); } 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()); result_tabs.push_back(Tab{nullptr, std::make_unique(program, extra_data->room->id, all_rooms_page, selected_item->url), 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" }; static void remove_empty_fields_in_sync_rooms_response(rapidjson::Value &rooms_json) { for(const char *member_name : {"join", "invite", "leave"}) { auto join_it = rooms_json.FindMember(member_name); if(join_it != rooms_json.MemberEnd() && join_it->value.IsObject() && join_it->value.MemberCount() == 0) rooms_json.RemoveMember(join_it); } } static void remove_empty_fields_in_sync_account_data_response(rapidjson::Value &account_data_json) { for(const char *member_name : {"events"}) { auto join_it = account_data_json.FindMember(member_name); if(join_it != account_data_json.MemberEnd() && join_it->value.IsObject() && join_it->value.MemberCount() == 0) account_data_json.RemoveMember(join_it); } } static void remove_unused_sync_data_fields(rapidjson::Value &json_root) { for(auto it = json_root.MemberBegin(); it != json_root.MemberEnd();) { if(strcmp(it->name.GetString(), "account_data") == 0 && it->value.IsObject()) { remove_empty_fields_in_sync_account_data_response(it->value); if(it->value.MemberCount() == 0) it = json_root.RemoveMember(it); else ++it; } else if(strcmp(it->name.GetString(), "rooms") == 0 && it->value.IsObject()) { // TODO: Call this, but dont remove our read marker (needed for notifications on mentions for example). Or maybe we can get it from "account_data"? //remove_ephemeral_field_in_sync_rooms_response(rooms_it->value); remove_empty_fields_in_sync_rooms_response(it->value); if(it->value.MemberCount() == 0) it = json_root.RemoveMember(it); else ++it; } else { it = json_root.EraseMember(it); } } } 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_is_cache = false; sync_running = true; sync_thread = std::thread([this, matrix_cache_dir]() { sync_is_cache = true; bool cache_available = false; FILE *sync_cache_file = fopen(matrix_cache_dir.data.c_str(), "rb"); if(sync_cache_file) { cache_available = true; rapidjson::Document doc; char read_buffer[8192]; rapidjson::FileReadStream is(sync_cache_file, read_buffer, sizeof(read_buffer)); while(true) { rapidjson::ParseResult parse_result = doc.ParseStream(is); if(parse_result.IsError()) break; if(parse_sync_response(doc, false, false) != PluginResult::OK) fprintf(stderr, "Failed to parse cached sync response\n"); } fclose(sync_cache_file); } sync_is_cache = false; // 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::string filter_encoded = url_param_encode(INITIAL_FILTER); std::vector additional_args = { { "-H", "Authorization: Bearer " + access_token }, { "-m", "35" } }; const rapidjson::Value *next_batch_json; PluginResult result; bool initial_sync = true; while(sync_running) { char url[1024]; if(next_batch.empty()) snprintf(url, sizeof(url), "%s/_matrix/client/r0/sync?filter=%s&timeout=0", homeserver.c_str(), filter_encoded.c_str()); else snprintf(url, sizeof(url), "%s/_matrix/client/r0/sync?filter=%s&timeout=30000&since=%s", homeserver.c_str(), filter_encoded.c_str(), next_batch.c_str()); 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(initial_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; } } if(next_batch.empty()) clear_sync_cache_for_new_sync(); result = parse_sync_response(json_root, false, initial_sync); if(result != PluginResult::OK) { fprintf(stderr, "Failed to parse sync response\n"); initial_sync = false; goto sync_end; } next_batch_json = &GetMember(json_root, "next_batch"); if(next_batch_json->IsString()) { set_next_batch(next_batch_json->GetString()); 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(initial_sync) { notification_thread = std::thread([this]() { get_previous_notifications([this](const MatrixNotification ¬ification) { if(notification.read) return; MatrixDelegate *delegate = this->delegate; ui_thread_tasks.push([delegate, notification] { delegate->add_unread_notification(std::move(notification)); }); }); finished_fetching_notifications = true; { std::vector additional_args = { { "-H", "Authorization: Bearer " + access_token }, { "-m", "35" } }; char url[1024]; std::string filter_encoded = url_param_encode(ADDITIONAL_MESSAGES_FILTER); snprintf(url, sizeof(url), "%s/_matrix/client/r0/sync?filter=%s&timeout=0", homeserver.c_str(), filter_encoded.c_str()); 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 for additional messages failed\n"); return; } // TODO: Test? //if(next_batch.empty()) // clear_sync_cache_for_new_sync(); additional_messages_queue.pop_wait(); parse_sync_response(json_root, true, false); } }); filter_encoded = url_param_encode(CONTINUE_FILTER); additional_messages_queue.push(true); malloc_trim(0); } #if 0 if(!filter_cached) { filter_cached = true; filter_encoded = url_param_encode(get_filter_cached()); } #endif // TODO: Circulate file sync_cache_file = fopen(matrix_cache_dir.data.c_str(), initial_sync ? "wb" : "ab"); initial_sync = false; if(sync_cache_file) { if(json_root.IsObject()) { char buffer[4096]; rapidjson::FileWriteStream file_write_stream(sync_cache_file, buffer, sizeof(buffer)); rapidjson::Writer writer(file_write_stream); remove_unused_sync_data_fields(json_root); json_root.Accept(writer); } fclose(sync_cache_file); } sync_end: 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(sync_additional_messages_thread.joinable()) { program_kill_in_thread(sync_additional_messages_thread.get_id()); additional_messages_queue.close(); sync_additional_messages_thread.join(); additional_messages_queue.restart(); } if(notification_thread.joinable()) { program_kill_in_thread(notification_thread.get_id()); notification_thread.join(); } delegate = nullptr; sync_failed = false; sync_fail_reason.clear(); next_batch.clear(); next_notifications_token.clear(); invites.clear(); filter_cached.reset(); my_events_transaction_ids.clear(); finished_fetching_notifications = false; } bool Matrix::is_initial_sync_finished() const { return !next_batch.empty(); } 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); //events_set_room_info(state_json, room_data); 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); } } PluginResult Matrix::parse_sync_response(const rapidjson::Document &root, bool is_additional_messages_sync, bool initial_sync) { if(!root.IsObject()) return PluginResult::ERR; //const rapidjson::Value &account_data_json = GetMember(root, "account_data"); //std::optional> dm_rooms; //parse_sync_account_data(account_data_json, dm_rooms); // TODO: Include "Direct messages" as a tag using |dm_rooms| above const rapidjson::Value &rooms_json = GetMember(root, "rooms"); parse_sync_room_data(rooms_json, is_additional_messages_sync, initial_sync); 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; time_t timestamp = 0; const rapidjson::Value &ts_json = GetMember(notification_json, "ts"); if(ts_json.IsInt64()) timestamp = ts_json.GetInt64(); 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; 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()); continue; } std::string event_id(event_id_json.GetString(), event_id_json.GetStringLength()); if(notifications_by_event_id.insert(event_id).second) { 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(body_json.GetString()); notification.timestamp = timestamp; notification.read = read_json.GetBool(); callback_func(notification); notifications.push_back(std::move(notification)); } } return PluginResult::OK; } PluginResult Matrix::parse_sync_account_data(const rapidjson::Value &account_data_json, std::optional> &dm_rooms) { 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; bool has_direct_rooms = false; std::set dm_rooms_tmp; 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.direct") != 0) continue; const rapidjson::Value &content_json = GetMember(event_item_json, "content"); if(!content_json.IsObject()) continue; has_direct_rooms = true; for(auto const &it : content_json.GetObject()) { if(!it.value.IsArray()) continue; for(const rapidjson::Value &room_id_json : it.value.GetArray()) { if(!room_id_json.IsString()) continue; dm_rooms_tmp.insert(std::string(room_id_json.GetString(), room_id_json.GetStringLength())); } } } if(has_direct_rooms) dm_rooms = std::move(dm_rooms_tmp); return PluginResult::OK; } PluginResult Matrix::parse_sync_room_data(const rapidjson::Value &rooms_json, bool is_additional_messages_sync, bool initial_sync) { if(!rooms_json.IsObject()) return PluginResult::OK; std::unordered_set existing_rooms; 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; } if(initial_sync) existing_rooms.insert(room); 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); events_set_room_info(events_json, room); 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()) { if(is_additional_messages_sync) { // This may be non-existent if this is the first event in the room const rapidjson::Value &prev_batch_json = GetMember(timeline_json, "prev_batch"); if(prev_batch_json.IsString()) room->set_prev_batch(prev_batch_json.GetString()); } bool has_unread_notifications = false; const rapidjson::Value &unread_notification_json = GetMember(it.value, "unread_notifications"); if(unread_notification_json.IsObject() && !is_additional_messages_sync) { const rapidjson::Value &highlight_count_json = GetMember(unread_notification_json, "highlight_count"); if(highlight_count_json.IsInt64() && (highlight_count_json.GetInt64() > 0 || initial_sync)) { room->unread_notification_count = highlight_count_json.GetInt64(); if(highlight_count_json.GetInt64() > 0) has_unread_notifications = true; } } const rapidjson::Value &events_json = GetMember(timeline_json, "events"); events_add_user_info(events_json, room); events_set_room_info(events_json, room); 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(); } } } if(!is_additional_messages_sync) { 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); } if(initial_sync) { std::lock_guard lock(room_data_mutex); for(auto &room : rooms) { if(existing_rooms.find(room.get()) == existing_rooms.end()) { RoomData *room_p = room.get(); ui_thread_tasks.push([this, room_p]{ delegate->leave_room(room_p, LeaveType::LEAVE, "", true); }); remove_room(room->id); } } } return PluginResult::OK; } void Matrix::events_add_user_info(const rapidjson::Value &events_json, RoomData *room_data) { 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; parse_user_info(content_json, sender_json->GetString(), room_data); } } 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_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::shared_ptr Matrix::parse_user_info(const rapidjson::Value &json, const std::string &user_id, RoomData *room_data) { 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_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); room_data->set_user_display_name(user_info, display_name); room_data->set_user_avatar_url(user_info, avatar_url); MatrixEventUserInfo event_user_info; event_user_info.user_id = user_id; event_user_info.display_name = display_name; event_user_info.avatar_url = 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) { 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; 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: 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 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_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, false, MessageDirection::AFTER); }); } 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_message->cache = sync_is_cache; 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); } if(has_unread_notifications) { time_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; 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(me && message->timestamp > read_marker_message_timestamp) message->notification_mentions_me = message_contains_user_mention(message->body, my_display_name) || message_contains_user_mention(message->body, me->user_id) || message_contains_user_mention(message->body, "@room"); } } bool cache_sync = sync_is_cache; bool is_initial_sync = next_batch.empty(); ui_thread_tasks.push([this, room_data, cache_sync, new_messages{std::move(new_messages)}, is_initial_sync, message_dir]{ delegate->room_add_new_messages(room_data, new_messages, is_initial_sync, cache_sync, message_dir); }); return num_new_messages; } // 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) + ")"; } } 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; } 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() && my_events_transaction_ids.find(transaction_id_json.GetString()) != my_events_transaction_ids.end()) 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; } } } 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; 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 their display name to " + extract_first_line_remove_newline_elipses(new_displayname_str, AUTHOR_MAX_LENGTH); new_display_name = new_displayname_str; room_data->set_user_display_name(user, std::move(new_displayname_str)); } else if((!new_displayname_json.IsString() || new_displayname_json.GetStringLength() == 0) && prev_displayname_json.IsString()) { body = user_display_name + " removed their display name"; new_display_name = ""; room_data->set_user_display_name(user, ""); } 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 their 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_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; room_data->set_user_avatar_url(user, std::move(new_avatar_url_str)); } else if((!new_avatar_url_json.IsString() || new_avatar_url_json.GetStringLength() == 0) && prev_avatar_url_json.IsString()) { body = user_display_name + " removed their profile picture"; new_avatar_url = ""; room_data->set_user_avatar_url(user, ""); } else { body = user_display_name + " joined the room"; } if(new_display_name || new_avatar_url) { 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 &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; power_level_changes[std::string(user_json.name.GetString(), user_json.name.GetStringLength())].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; power_level_changes[std::string(user_json.name.GetString(), user_json.name.GetStringLength())].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 { 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; 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 + body_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; } // Returns empty string on error static 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.empty() || user_id[0] != '@') return ""; return user_id.substr(1, index - 1); } 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 = extract_first_line_remove_newline_elipses(user_info[0]->room->get_user_display_name(user_info[0]), AUTHOR_MAX_LENGTH); else if(user_info.size() == 2) result = extract_first_line_remove_newline_elipses(user_info[0]->room->get_user_display_name(user_info[0]) + " and " + user_info[1]->room->get_user_display_name(user_info[1]), 64); else if(user_info.size() > 2) result = extract_first_line_remove_newline_elipses(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)", 64); return result; } void Matrix::events_set_room_info(const rapidjson::Value &events_json, RoomData *room_data) { 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()) continue; 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; room_data->set_name(name_json.GetString()); 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; room_data->set_avatar_url(get_thumbnail_url(homeserver, thumbnail_url_extract_media_id(url_json.GetString()))); 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; room_data->set_topic(topic_json.GetString()); } } } void Matrix::set_room_info_to_users_if_empty(RoomData *room, const std::string &room_creator_user_id) { bool has_room_name = room->has_name(); bool has_room_avatar_url = room->has_avatar_url(); 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) { room->set_name(combine_user_display_names_for_room_name(users_excluding_me, room_creator_user_id)); 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) room->set_avatar_url(room->get_user_avatar_url(user)); } else { // TODO: If there are multiple users, then we want to use some other type of avatar, not the first users avatar room->set_avatar_url(room->get_user_avatar_url(users_excluding_me.front())); } room->avatar_is_fallback = true; } } 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); }); } room_tags = std::move(new_tags); 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) { // 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); events_set_room_info(events_json, room); 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_json.GetInt64(); invite.new_invite = !sync_is_cache; std::string room_id_str(room_id.GetString(), room_id.GetStringLength()); if(set_invite(room_id_str, invite)) ui_thread_tasks.push([this, room_id_str{std::move(room_id_str)}, invite{std::move(invite)}]{ delegate->add_invite(room_id_str, std::move(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; const bool is_cache = sync_is_cache; ui_thread_tasks.push([this, room, leave_type, desc{std::move(desc)}, is_cache]{ delegate->leave_room(room, leave_type, desc, is_cache); }); 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) from = "END"; 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); //events_set_room_info(state_json, room_data); 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("invalid"); 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; } static bool generate_random_characters(char *buffer, int buffer_size) { int fd = open("/dev/urandom", O_RDONLY); if(fd == -1) { perror("/dev/urandom"); return false; } if(read(fd, buffer, buffer_size) < buffer_size) { fprintf(stderr, "Failed to read %d bytes from /dev/urandom\n", buffer_size); close(fd); return false; } close(fd); return true; } static std::string random_characters_to_readable_string(const char *buffer, int buffer_size) { std::ostringstream result; result << std::hex; for(int i = 0; i < buffer_size; ++i) result << (int)(unsigned char)buffer[i]; return result.str(); } std::string create_transaction_id() { char random_characters[18]; if(!generate_random_characters(random_characters, sizeof(random_characters))) return ""; std::string random_readable_chars = random_characters_to_readable_string(random_characters, sizeof(random_characters)); 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; } void Matrix::formatted_body_add_line(RoomData *room, std::string &formatted_body, const std::string &line_str) { 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); 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 '@': { 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()); } } } } std::string Matrix::body_to_formatted_body(RoomData *room, const std::string &body) { 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](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);
                    formatted_body += "";
                } else {
                    if(is_inside_code_block) {
                        formatted_body += line_str;
                    } else {
                        formatted_body_add_line(room, formatted_body, line_str);
                    }
                }
                is_first_line = false;
            }

            return true;
        });
        return formatted_body;
    }

    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) {
        std::string transaction_id = create_transaction_id();
        if(transaction_id.empty())
            return PluginResult::ERR;
        if(!file_info)
            my_events_transaction_ids.insert(transaction_id);

        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;
    }

    std::string remove_reply_formatting(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);
            }
        }
        return str;
    }

    std::string message_get_body_remove_formatting(Message *message) {
        if(message->related_event_type == RelatedEventType::REPLY || message->related_event_type == RelatedEventType::EDIT)
            return remove_reply_formatting(message->body);
        else
            return message->body;
    }

    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(const Message *message) {
        std::string related_to_body;
        switch(message->type) {
            case MessageType::TEXT: {
                if(message->related_event_type != RelatedEventType::NONE)
                    related_to_body = remove_reply_formatting(message->body);
                else
                    related_to_body = message->body;
                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:
                related_to_body = message->body;
                break;
        }
        return related_to_body;
    }

    static std::string create_body_for_message_reply(const Message *message, const std::string &body) {
        return "> <" + message->user->user_id + "> " + block_quote(get_reply_message(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(message);
        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); } PluginResult Matrix::post_reply(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; my_events_transaction_ids.insert(transaction_id); 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(relates_to_message_raw, 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, relates_to_message_raw, body); rapidjson::Document request_data(rapidjson::kObjectType); request_data.AddMember("msgtype", "m.text", request_data.GetAllocator()); // TODO: Allow image reply? element doesn't do that but we could! request_data.AddMember("body", rapidjson::StringRef(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()); 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; } PluginResult Matrix::post_edit(RoomData *room, const std::string &body, void *relates_to, std::string &event_id_response) { Message *relates_to_message_raw = (Message*)relates_to; std::string transaction_id = create_transaction_id(); if(transaction_id.empty()) return PluginResult::ERR; my_events_transaction_ids.insert(transaction_id); 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()); if(!formatted_body.empty()) { 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()); } 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()); std::string body_edit_str = " * " + body; std::string formatted_body_edit_str; rapidjson::Document request_data(rapidjson::kObjectType); request_data.AddMember("msgtype", "m.text", request_data.GetAllocator()); request_data.AddMember("body", rapidjson::StringRef(body_edit_str.c_str()), request_data.GetAllocator()); if(!formatted_body.empty()) { formatted_body_edit_str = " * " + formatted_body; request_data.AddMember("format", "org.matrix.custom.html", request_data.GetAllocator()); request_data.AddMember("formatted_body", rapidjson::StringRef(formatted_body_edit_str.c_str()), request_data.GetAllocator()); } request_data.AddMember("m.new_content", std::move(new_content_json), request_data.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) { Message *relates_to_message_raw = (Message*)relates_to; std::string transaction_id = create_transaction_id(); if(transaction_id.empty()) return PluginResult::ERR; my_events_transaction_ids.insert(transaction_id); 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? what about state apply order? //const rapidjson::Value &state_json = GetMember(json_root, "state"); //events_add_user_info(state_json, room); 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? what about state apply order? //const rapidjson::Value &state_json = GetMember(json_root, "state"); //events_add_user_info(state_json, room); 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(""); } 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 Matrix::post_file(RoomData *room, const std::string &filepath, std::string &event_id_response, std::string &err_msg) { UploadInfo file_info; UploadInfo thumbnail_info; PluginResult upload_file_result = upload_file(room, filepath, 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); const char *filename = file_get_filename(filepath); return post_message(room, filename, event_id_response, file_info_opt, thumbnail_info_opt); } PluginResult Matrix::upload_file(RoomData *room, const std::string &filepath, UploadInfo &file_info, UploadInfo &thumbnail_info, std::string &err_msg, bool upload_thumbnail) { 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(); int upload_limit; PluginResult config_result = get_config(&upload_limit); if(config_result != PluginResult::OK) { err_msg = "Failed to get file size limit from server"; return config_result; } // 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 is set to 300mb"; return PluginResult::ERR; } if((int)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) { if(video_get_first_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(room, tmp_filename, 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())) { 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(), true)) thumbnail_path = tmp_filename; else thumbnail_path = filepath; UploadInfo upload_info_ignored; // Ignore because it wont be set anyways. Thumbnails dont have thumbnails. PluginResult upload_thumbnail_result = upload_file(room, thumbnail_path, 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 } }; const char *filename = file_get_filename(filepath); std::string filename_escaped = url_param_encode(filename); char url[512]; snprintf(url, sizeof(url), "%s/_matrix/media/r0/upload?filename=%s", homeserver.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 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(); // 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(); 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; //my_events_transaction_ids.insert(transaction_id); 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() && 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 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(); 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) { 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) { 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()); 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); request_data.AddMember("timestamp", timestamp, 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() } }; room->read_marker_event_timestamp = timestamp; std::string server_response; DownloadResult download_result = download_to_string(homeserver + "/_matrix/client/r0/user/" + my_user_id + "/rooms/" + room->id + "/account_data/qm.last_read_message_timestamp", server_response, std::move(additional_args), true); return download_result_to_plugin_result(download_result); } PluginResult Matrix::join_room(const std::string &room_id_or_name) { assert(delegate); 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, homeserver + "/_matrix/client/r0/join/" + url_param_encode(room_id_or_name), 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); new_room->set_avatar_url(invite_it->second.room_avatar_url); 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, "", false); }); remove_room(room_id); } } return download_result_to_plugin_result(download_result); } 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_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_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(int *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.IsInt()) return PluginResult::ERR; upload_limit = upload_size_json.GetInt(); *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; } 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)); 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) { std::lock_guard lock(next_batch_mutex); next_batch = std::move(new_next_batch); } 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; } void Matrix::clear_sync_cache_for_new_sync() { std::lock_guard room_data_lock(room_data_mutex); std::lock_guard invites_lock(invite_mutex); for(auto &room : rooms) { room->clear_data(); } // We intentionally dont clear |rooms| here because we want the objects inside it to still be valid. TODO: Clear |rooms| here //room_data_by_id.clear(); invites.clear(); ui_thread_tasks.push([this]{ delegate->clear_data(); }); } 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_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); room->set_user_display_name(user, display_name); 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); #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::update() { mgl::Clock timer; std::optional> task; while((task = ui_thread_tasks.pop_if_available()) != std::nullopt) { task.value()(); } } 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; } } } }