#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/Utils.hpp" #include #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. static const char* SERVICE_NAME = "matrix"; static const char* OTHERS_ROOM_TAG = "tld.name.others"; // Filter without account data 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 (char)std::toupper(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 ""; } } namespace QuickMedia { static const sf::Vector2i thumbnail_max_size(600, 337); 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 void remove_body_item_by_url(BodyItems &body_items, const std::string &url) { for(auto it = body_items.begin(); it != body_items.end();) { if((*it)->url == url) it = body_items.erase(it); else ++it; } } static int color_hash_code(const std::string &str) { int hash = 0; if(str.empty()) return hash; for (char chr : str) { hash = ((hash << 5) - hash) + (unsigned char)chr; } return std::abs(hash); } static sf::Color user_id_to_color(const std::string &user_id) { const int num_colors = 8; const sf::Color colors[num_colors] = { sf::Color(54, 139, 214), sf::Color(172, 59, 168), sf::Color(3, 179, 129), sf::Color(230, 79, 122), sf::Color(255, 129, 45), sf::Color(45, 194, 197), sf::Color(92, 86, 245), sf::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), resolve_state(UserResolveState::NOT_RESOLVED) { 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)), resolve_state(UserResolveState::RESOLVED), 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; } 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; user->resolve_state = UserResolveState::RESOLVED; } 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); user->resolve_state = UserResolveState::RESOLVED; } size_t RoomData::prepend_messages_reverse(const std::vector> &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(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 std::vector> &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(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_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 std::vector>& RoomData::get_messages_thread_unsafe() const { return messages; } const std::vector& RoomData::get_pinned_events_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); // TODO: Check if this always works and if it also works for other homeservers than synapse if(prev_batch.empty() || new_prev_batch < prev_batch) 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_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) : program(program), matrix(matrix), rooms_page(rooms_page), room_tags_page(room_tags_page), invites_page(invites_page) { rooms_page->matrix_delegate = this; room_tags_page->matrix_delegate = this; } void MatrixQuickMedia::join_room(RoomData *room) { std::lock_guard lock(room_body_items_mutex); std::string room_name = room->get_name(); if(room_name.empty()) room_name = room->id; 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 = sf::Vector2i(32, 32); room->body_item = body_item; rooms_page->add_body_item(body_item); room_body_item_by_room[room] = body_item; } void MatrixQuickMedia::leave_room(RoomData *room, LeaveType leave_type, const std::string &reason) { std::lock_guard lock(room_body_items_mutex); room_body_item_by_room.erase(room); rooms_page->remove_body_item_by_room_id(room->id); room_tags_page->remove_body_item_by_room_id(room->id); if(leave_type != LeaveType::LEAVE) show_notification("QuickMedia", reason); } void MatrixQuickMedia::room_add_tag(RoomData *room, const std::string &tag) { std::lock_guard lock(room_body_items_mutex); room_tags_page->add_room_body_item_to_tag(room_body_item_by_room[room], tag); } void MatrixQuickMedia::room_remove_tag(RoomData *room, const std::string &tag) { std::lock_guard lock(room_body_items_mutex); room_tags_page->remove_room_body_item_from_tag(room_body_item_by_room[room], tag); } void MatrixQuickMedia::room_add_new_messages(RoomData *room, const Messages &messages, bool is_initial_sync, bool sync_is_cache, MessageDirection message_dir) { std::lock_guard lock(pending_room_messages_mutex); auto &room_messages_data = pending_room_messages[room]; room_messages_data.messages.insert(room_messages_data.messages.end(), messages.begin(), messages.end()); room_messages_data.is_initial_sync = is_initial_sync; room_messages_data.sync_is_cache = sync_is_cache; room_messages_data.message_dir = message_dir; } 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 = sf::Vector2i(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(RoomData *room, std::string event_id, std::string sender, std::string body) { std::lock_guard lock(room_body_items_mutex); Notification notification; notification.event_id = std::move(event_id); notification.sender = std::move(sender); notification.body = std::move(body); unread_notifications[room].push_back(std::move(notification)); } static void sort_room_body_items(std::vector> &room_body_items) { #if 0 std::sort(room_body_items.begin(), room_body_items.end(), [](const std::shared_ptr &body_item1, const std::shared_ptr &body_item2) { return strcasecmp(body_item1->get_title().c_str(), body_item2->get_title().c_str()) < 0; }); #if 1 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); int room1_focus_sum = (int)(room1->unread_notification_count > 0) + (int)!room1->last_message_read; int room2_focus_sum = (int)(room2->unread_notification_count > 0) + (int)!room2->last_message_read; return room1_focus_sum > room2_focus_sum; }); #else std::sort(room_body_items.begin(), room_body_items.end(), [](const std::shared_ptr &body_item1, const std::shared_ptr &body_item2) { return strcasecmp(body_item1->get_title().c_str(), body_item2->get_title().c_str()) < 0; }); #endif #endif 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; }); } void body_set_selected_item(Body *body, BodyItem *selected_item) { for(size_t i = 0; i < body->items.size(); ++i) { if(body->items[i]->url == selected_item->url) { body->select_first_item(); body->set_selected_item(i, false); return; } } } void MatrixQuickMedia::update(MatrixPageType page_type, Body *chat_body, bool messages_tab_visible) { update_pending_room_messages(page_type, chat_body, messages_tab_visible); std::lock_guard room_body_lock(room_body_items_mutex); for(auto &it : unread_notifications) { for(auto &unread_notification : it.second) { show_notification("QuickMedia matrix - " + unread_notification.sender + " (" + it.first->get_name() + ")", unread_notification.body); } } //if(!unread_notifications.empty()) { // rooms_page->sort_rooms(); // room_tags_page->sort_rooms(); //} unread_notifications.clear(); } void MatrixQuickMedia::clear_data() { std::lock_guard lock(pending_room_messages_mutex); std::lock_guard room_body_lock(room_body_items_mutex); //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 0 return *std::max_element(messages.begin(), messages.end(), [](const std::shared_ptr &message1, const std::shared_ptr &message2) { return message1->timestamp < message2->timestamp; }); #else 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; #endif } 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, Messages &new_messages, bool is_initial_sync, bool sync_is_cache, Body *chat_body, bool messages_tab_visible) { 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; } if(read_marker_message_timestamp == 0 || read_marker_message_timestamp < room->read_marker_event_timestamp) read_marker_message_timestamp = room->read_marker_event_timestamp; std::shared_ptr last_new_message = get_last_message_by_timestamp(new_messages); if(!last_new_message) return; auto last_message_it = last_message_by_room.find(room); if(last_message_it != last_message_by_room.end()) { if(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 { 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(read_marker_message_timestamp != 0 && last_new_message->timestamp > read_marker_message_timestamp) last_unread_message = last_new_message.get(); else if(read_marker_message_timestamp == 0 && !my_user_read_marker.empty() && last_new_message->event_id.find(':') != std::string::npos && last_new_message->event_id > my_user_read_marker) last_unread_message = last_new_message.get(); if(!last_unread_message && read_marker_message_timestamp == 0) last_unread_message = last_new_message.get(); //assert(room_body_item); if(!room->body_item) return; if(last_unread_message && !sync_is_cache) { bool is_window_focused = program->is_window_focused(); RoomData *current_room = program->get_current_chat_room(); bool set_room_as_unread = !is_window_focused || room != current_room || (!chat_body || !chat_body->is_last_item_fully_visible()) || !messages_tab_visible; std::string room_desc; if(set_room_as_unread) room_desc += "Unread: "; 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) { room_desc += "\n** " + std::to_string(unread_notification_count) + " unread mention(s) **"; // TODO: Better notification? room->body_item->set_description_color(sf::Color(255, 100, 100)); } else { room->body_item->set_description_color(sf::Color(179, 179, 179)); } room->body_item->set_description(std::move(room_desc)); if(set_room_as_unread) room->body_item->set_title_color(sf::Color(255, 100, 100)); room->last_message_read = false; rooms_page->move_room_to_top(room); room_tags_page->move_room_to_top(room); } else if(is_initial_sync) { 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(sf::Color(179, 179, 179)); } } void MatrixQuickMedia::update_pending_room_messages(MatrixPageType page_type, Body *chat_body, bool messages_tab_visible) { std::lock_guard lock(pending_room_messages_mutex); bool is_window_focused = program->is_window_focused(); RoomData *current_room = program->get_current_chat_room(); for(auto &it : pending_room_messages) { RoomData *room = it.first; auto &messages = it.second.messages; bool is_initial_sync = it.second.is_initial_sync; //auto &room_body_item = room_body_item_by_room[room]; //std::string room_desc = extract_first_line_remove_newline_elipses(matrix->message_get_author_displayname(it.second.back().get()), AUTHOR_MAX_LENGTH) + ": " + extract_first_line_remove_newline_elipses(it.second.back()->body, 150); //room_body_item->set_description(std::move(room_desc)); if(!it.second.sync_is_cache && it.second.message_dir == MessageDirection::AFTER && !is_initial_sync) { for(auto &message : messages) { if(message->notification_mentions_me) { // 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) { show_notification("QuickMedia matrix - " + extract_first_line_remove_newline_elipses(matrix->message_get_author_displayname(message.get()), AUTHOR_MAX_LENGTH) + " (" + room->get_name() + ")", message->body); } } } } update_room_description(room, messages, is_initial_sync, it.second.sync_is_cache, chat_body, messages_tab_visible); } pending_room_messages.clear(); } 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 std::string &title, const std::string &url, std::vector &result_tabs) { (void)title; auto chat_page = std::make_unique(program, url, this); result_tabs.push_back(Tab{nullptr, std::move(chat_page), nullptr}); return PluginResult::OK; } void MatrixRoomsPage::on_navigate_to_page(Body *body) { if(search_bar) body->filter_search_fuzzy(search_bar->get_text()); //sort_room_body_items(body->items); } void MatrixRoomsPage::update() { { std::lock_guard lock(mutex); int prev_selected_item = body->get_selected_item(); if(clear_data_on_update) { clear_data_on_update = false; body->clear_items(); } if(!pending_remove_body_items.empty() || !room_body_items.empty()) { sort_on_update = true; filter_on_update = true; } for(const std::string &room_id : pending_remove_body_items) { remove_body_item_by_url(body->items, room_id); // TODO: There can be a race condition where current_chat_page is set after entering a room and then we will enter a room we left 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; } } pending_remove_body_items.clear(); body->set_selected_item(prev_selected_item, false); body->clamp_selection(); body->append_items(std::move(room_body_items)); } if(sort_on_update) { sort_on_update = false; //BodyItem *selected_item = body->get_selected(); sort_room_body_items(body->items); //body_set_selected_item(body, selected_item); } matrix_delegate->update(MatrixPageType::ROOM_LIST, nullptr, false); if(filter_on_update) { filter_on_update = false; if(search_bar) body->filter_search_fuzzy(search_bar->get_text()); //sort_room_body_items(body->items); } } void MatrixRoomsPage::add_body_item(std::shared_ptr body_item) { std::lock_guard lock(mutex); room_body_items.push_back(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 hash map instead of linear search? or cache the index std::lock_guard lock(mutex); #if 0 int room_body_index = body->get_index_by_body_item(room->body_item.get()); if(room_body_index != -1) { std::shared_ptr body_item = body->items[room_body_index]; int body_swap_index = -1; if(room->unread_notification_count > 0) body_swap_index = find_top_body_position_for_mentioned_room(body->items, body_item.get()); else if(!room->last_message_read) body_swap_index = find_top_body_position_for_unread_room(body->items, body_item.get()); if(body_swap_index != -1 && body_swap_index != room_body_index) { body->items.erase(body->items.begin() + room_body_index); if(body_swap_index <= room_body_index) body->items.insert(body->items.begin() + body_swap_index, std::move(body_item)); else body->items.insert(body->items.begin() + (body_swap_index - 1), std::move(body_item)); } } #else 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->items.size(); ++i) { RoomData *room_i = static_cast(body->items[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) { auto body_item_to_insert = body->items[room_body_index]; body->items.erase(body->items.begin() + room_body_index); if(room_body_index >= (int)i) body->items.insert(body->items.begin() + i, std::move(body_item_to_insert)); else body->items.insert(body->items.begin() + (i - 1), std::move(body_item_to_insert)); if((int)i < selected_item && room_body_index > selected_item && body->items.size() > 1 && i != body->items.size() - 1) { body->select_first_item(); body->set_selected_item(selected_item + 1, false); } return; } } #endif } void MatrixRoomsPage::remove_body_item_by_room_id(const std::string &room_id) { std::lock_guard lock(mutex); pending_remove_body_items.push_back(room_id); } void MatrixRoomsPage::set_current_chat_page(MatrixChatPage *chat_page) { std::lock_guard lock(mutex); current_chat_page = chat_page; } void MatrixRoomsPage::clear_data() { std::lock_guard lock(mutex); room_body_items.clear(); pending_remove_body_items.clear(); clear_data_on_update = true; if(current_chat_page) current_chat_page->should_clear_data = true; } void MatrixRoomsPage::sort_rooms() { sort_on_update = true; } PluginResult MatrixRoomTagsPage::submit(const std::string &title, const std::string &url, std::vector &result_tabs) { (void)title; std::lock_guard lock(mutex); auto body = create_body(); Body *body_ptr = body.get(); TagData &tag_data = tag_body_items_by_name[url]; body->items = tag_data.room_body_items; //BodyItem *selected_item = body->get_selected(); sort_room_body_items(body->items); //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; } // TODO: Also add/remove body items to above body (in submit) void MatrixRoomTagsPage::update() { { std::lock_guard lock(mutex); int prev_selected_item = body->get_selected_item(); if(clear_data_on_update) { clear_data_on_update = false; body->clear_items(); } for(auto &it : remove_room_body_items_by_tags) { auto tag_body_it = tag_body_items_by_name.find(it.first); if(tag_body_it == tag_body_items_by_name.end()) continue; for(auto &room_to_remove : it.second) { auto room_body_item_it = std::find(tag_body_it->second.room_body_items.begin(), tag_body_it->second.room_body_items.end(), room_to_remove); 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()) { auto room_body_item_it = std::find(body->items.begin(), body->items.end(), tag_body_it->second.tag_item); if(room_body_item_it != body->items.end()) { body->items.erase(room_body_item_it); filter_on_update = true; } tag_body_items_by_name.erase(tag_body_it); } } remove_room_body_items_by_tags.clear(); for(auto &it : add_room_body_items_by_tags) { TagData *tag_data; auto tag_body_it = tag_body_items_by_name.find(it.first); if(tag_body_it == tag_body_items_by_name.end()) { std::string tag_name = tag_get_name(it.first); if(!tag_name.empty()) { auto tag_body_item = BodyItem::create(std::move(tag_name)); tag_body_item->url = it.first; tag_body_items_by_name.insert(std::make_pair(it.first, TagData{tag_body_item, {}})); // TODO: Sort by tag priority body->items.push_back(tag_body_item); tag_data = &tag_body_items_by_name[it.first]; tag_data->tag_item = tag_body_item; filter_on_update = true; } } else { tag_data = &tag_body_it->second; } for(auto &room_body_item : it.second) { bool already_exists = false; for(auto &body_it : tag_data->room_body_items) { if(body_it->userdata == room_body_item->userdata) { already_exists = true; break; } } if(!already_exists) tag_data->room_body_items.push_back(room_body_item); } } add_room_body_items_by_tags.clear(); body->set_selected_item(prev_selected_item, false); } matrix_delegate->update(MatrixPageType::ROOM_LIST, nullptr, false); if(filter_on_update) { filter_on_update = false; if(search_bar) body->filter_search_fuzzy(search_bar->get_text()); } } void MatrixRoomTagsPage::add_room_body_item_to_tag(std::shared_ptr body_item, const std::string &tag) { std::lock_guard lock(mutex); add_room_body_items_by_tags[tag].push_back(body_item); } void MatrixRoomTagsPage::remove_room_body_item_from_tag(std::shared_ptr body_item, const std::string &tag) { std::lock_guard lock(mutex); remove_room_body_items_by_tags[tag].push_back(body_item); } void MatrixRoomTagsPage::move_room_to_top(RoomData *room) { std::lock_guard lock(mutex); 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) { std::lock_guard lock(mutex); 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) { std::lock_guard lock(mutex); current_rooms_page = rooms_page; } void MatrixRoomTagsPage::clear_data() { std::lock_guard lock(mutex); tag_body_items_by_name.clear(); add_room_body_items_by_tags.clear(); remove_room_body_items_by_tags.clear(); clear_data_on_update = true; if(current_rooms_page) current_rooms_page->clear_data(); } void MatrixRoomTagsPage::sort_rooms() { std::lock_guard lock(mutex); if(current_rooms_page) current_rooms_page->sort_rooms(); } MatrixInvitesPage::MatrixInvitesPage(Program *program, Matrix *matrix, Body *body, SearchBar *search_bar) : Page(program), matrix(matrix), body(body), search_bar(search_bar) { } PluginResult MatrixInvitesPage::submit(const std::string &title, const std::string &url, std::vector &result_tabs) { auto body = create_body(); body->items.push_back(BodyItem::create("Accept")); body->items.push_back(BodyItem::create("Decline")); result_tabs.push_back(Tab{std::move(body), std::make_unique(program, matrix, this, url, "Invite to " + title), nullptr}); return PluginResult::OK; } PluginResult MatrixInviteDetailsPage::submit(const std::string &title, const std::string&, std::vector&) { TaskResult task_result = program->run_task_with_loading_screen([this, title]() { if(title == "Accept") return matrix->join_room(room_id) == PluginResult::OK; else if(title == "Decline") return matrix->leave_room(room_id) == PluginResult::OK; return false; }); if(task_result == TaskResult::TRUE) { invites_page->remove_body_item_by_room_id(room_id); } else if(task_result == TaskResult::FALSE) { std::string action_str; if(title == "Accept") action_str = "accept"; else if(title == "Decline") action_str = "decline"; show_notification("QuickMedia", "Failed to " + action_str + " the room invite", Urgency::CRITICAL); } program->set_go_to_previous_page(); return PluginResult::OK; } void MatrixInvitesPage::update() { std::lock_guard lock(mutex); int prev_selected_item = body->get_selected_item(); if(clear_data_on_update) { clear_data_on_update = false; body->clear_items(); } if(!pending_remove_body_items.empty() || !body_items.empty()) filter_on_update = true; for(const std::string &room_id : pending_remove_body_items) { remove_body_item_by_url(body->items, room_id); } pending_remove_body_items.clear(); body->set_selected_item(prev_selected_item, false); body->clamp_selection(); // TODO: Insert in reverse order (to show the latest invite at the top?) body->insert_items_by_timestamps(std::move(body_items)); if(body->items.size() != prev_invite_count) { prev_invite_count = body->items.size(); title = "Invites (" + std::to_string(body->items.size()) + ")"; } if(filter_on_update) { filter_on_update = false; if(search_bar) body->filter_search_fuzzy(search_bar->get_text()); } } void MatrixInvitesPage::add_body_item(std::shared_ptr body_item) { std::lock_guard lock(mutex); body_items.push_back(std::move(body_item)); } void MatrixInvitesPage::remove_body_item_by_room_id(const std::string &room_id) { std::lock_guard lock(mutex); pending_remove_body_items.push_back(room_id); } void MatrixInvitesPage::clear_data() { std::lock_guard lock(mutex); body_items.clear(); pending_remove_body_items.clear(); title = "Invites (0)"; prev_invite_count = 0; clear_data_on_update = true; } MatrixChatPage::MatrixChatPage(Program *program, std::string room_id, MatrixRoomsPage *rooms_page) : Page(program), room_id(std::move(room_id)), rooms_page(rooms_page) { assert(rooms_page); rooms_page->set_current_chat_page(this); } MatrixChatPage::~MatrixChatPage() { rooms_page->set_current_chat_page(nullptr); } void MatrixChatPage::update() { rooms_page->matrix_delegate->update(MatrixPageType::CHAT, chat_body, messages_tab_visible); if(rooms_page) rooms_page->update(); } PluginResult MatrixRoomDirectoryPage::submit(const std::string &title, const std::string&, std::vector &result_tabs) { std::string server_name = 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...", 350)}); 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 std::string &title, const std::string &url, std::vector&) { TaskResult task_result = program->run_task_with_loading_screen([this, url]() { return matrix->join_room(url) == PluginResult::OK; }); if(task_result == TaskResult::TRUE) { show_notification("QuickMedia", "You joined " + title, Urgency::NORMAL); program->set_go_to_previous_page(); } else if(task_result == TaskResult::FALSE) { show_notification("QuickMedia", "Failed to join " + title, Urgency::CRITICAL); } 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 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]() { std::vector additional_args = { { "-H", "Authorization: Bearer " + access_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]; snprintf(url, sizeof(url), "%s/_matrix/client/r0/notifications?limit=100&only=highlight", 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 || !json_root.IsObject()) { fprintf(stderr, "Fetching notifications failed!\n"); return; } const rapidjson::Value ¬ification_json = GetMember(json_root, "notifications"); parse_notifications(notification_json); { 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(); set_next_batch(""); invites.clear(); filter_cached.reset(); my_events_transaction_ids.clear(); } 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; } } 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_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_unsafe(); room->pinned_events_updated = false; room->release_room_lock(); } PluginResult Matrix::get_previous_room_messages(RoomData *room, Messages &messages, bool latest_messages) { size_t num_new_messages = 0; PluginResult result = get_previous_room_messages(room, latest_messages, num_new_messages); 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::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) { if(!notifications_json.IsArray()) return PluginResult::ERR; 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() || read_json.GetBool()) continue; const rapidjson::Value &room_id_json = GetMember(notification_json, "room_id"); if(!room_id_json.IsString()) continue; const rapidjson::Value &event_json = GetMember(notification_json, "event"); if(!event_json.IsObject()) continue; const rapidjson::Value &event_id_json = GetMember(event_json, "event_id"); if(!event_id_json.IsString()) continue; 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()); std::string sender(sender_json.GetString(), sender_json.GetStringLength()); std::string body(body_json.GetString(), body_json.GetStringLength()); delegate->add_unread_notification(room, std::move(event_id), std::move(sender), std::move(body)); } 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); if(!is_additional_messages_sync) 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) delegate->join_room(room); events_add_messages(events_json, room, MessageDirection::AFTER, has_unread_notifications); if(!is_additional_messages_sync) 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) delegate->join_room(room); } if(remove_invite(room_id_str)) { // TODO: Show leave type and reason and who caused the invite to be removed 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_unsafe(); if(room_tags.empty()) { room_tags.insert(OTHERS_ROOM_TAG); 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()) { delegate->leave_room(room.get(), LeaveType::LEAVE, ""); 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) 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) { std::string size = std::to_string(int(32 * get_ui_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); auto user_info = get_user_by_id(room_data, user_id); room_data->set_user_display_name(user_info, std::move(display_name)); room_data->set_user_avatar_url(user_info, std::move(avatar_url)); 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, sf::Vector2i &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) { 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; } static size_t string_find_case_insensitive(const char *haystack, size_t index, size_t length, const std::string &needle) { const char *haystack_end = haystack + length; auto it = std::search(haystack + index, haystack_end, needle.begin(), needle.end(), [](char c1, char c2) { return std::toupper(c1) == std::toupper(c2); }); if(it != haystack_end) return it - haystack; else return std::string::npos; } // 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 = string_find_case_insensitive(&msg[0], index, msg.size(), username); 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; } 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 std::vector> 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; } 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"); } } if(delegate) delegate->room_add_new_messages(room_data, new_messages, next_batch.empty(), sync_is_cache, message_dir); return num_new_messages; } 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; auto user = get_user_by_id(room_data, sender_json_str); if(!user) { // Note: this is important because otherwise replying and such is broken fprintf(stderr, "Warning: skipping unknown user: %s\n", sender_json_str.c_str()); return nullptr; } auto user_sender = user; if(sent_by_somebody_else) user_sender = get_user_by_id(room_data, sender_json_orig->GetString()); 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); 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"); 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); 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"; 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) 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"; room_data->set_user_avatar_url(user, ""); } else { body = user_display_name + " joined the room"; } } 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 { 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()) continue; std::string url_json_str = url_json.GetString() + 6; room_data->set_avatar_url(get_thumbnail_url(homeserver, thumbnail_url_extract_media_id(url_json_str))); room_data->avatar_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()) continue; std::string url_json_str = url_json.GetString() + 6; room_data->set_avatar_url(get_thumbnail_url(homeserver, thumbnail_url_extract_media_id(url_json_str))); 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_unsafe(); for(const std::string &room_tag : room_tags) { auto it = new_tags.find(room_tag); if(it == new_tags.end()) 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()) delegate->room_add_tag(room_data, new_tag); } if(new_tags.empty()) { new_tags.insert(OTHERS_ROOM_TAG); 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) { Invite invite; RoomData invite_room; events_add_user_info(events_json, &invite_room); events_set_room_info(events_json, &invite_room); std::string sender_json_str(sender_json.GetString(), sender_json.GetStringLength()); auto invited_by = get_user_by_id(&invite_room, sender_json_str); if(!invited_by) { fprintf(stderr, "Invited by unknown user. Bug in homeserver?\n"); break; } set_room_info_to_users_if_empty(&invite_room, sender_json_str); invite.room_name = invite_room.get_name(); invite.room_avatar_url = invite_room.get_avatar_url(); invite.invited_by = invited_by; invite.timestamp = timestamp_json.GetInt64(); invite.new_invite = !next_batch.empty(); std::string room_id_str(room_id.GetString(), room_id.GetStringLength()); if(set_invite(room_id_str, 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 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; delegate->leave_room(room, leave_type, desc); remove_room(room_id_str); break; } } } PluginResult Matrix::get_previous_room_messages(RoomData *room_data, bool latest_messages, size_t &num_new_messages) { 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"); 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()) { fprintf(stderr, "Warning: matrix messages response is missing 'end', this could happen if we received the very first messages in the room\n"); return PluginResult::OK; } 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; } static void formatted_body_add_line(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) { formatted_body += line_str.substr(index, backquote_start_index - index); 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; } } formatted_body += line_str.substr(index); break; } } static std::string body_to_formatted_body(const std::string &body) { std::string formatted_body; bool is_inside_code_block = false; bool is_first_line = true; string_split(body, '\n', [&formatted_body, &is_inside_code_block, &is_first_line](const char *str, size_t size){ if(!is_first_line) 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(formatted_body, line_str);
                    formatted_body += "";
                } else {
                    if(is_inside_code_block)
                        formatted_body += line_str;
                    else
                        formatted_body_add_line(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(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 "";
    }

    static std::string create_formatted_body_for_message_reply(RoomData *room, const Message *message, const std::string &body) {
        std::string formatted_body = body_to_formatted_body(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); } // TODO: Support greentext PluginResult Matrix::post_reply(RoomData *room, const std::string &body, void *relates_to, std::string &event_id_response, const std::string &custom_transaction_id) { // TODO: Store shared_ptr instead of raw pointer... 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(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()); // TODO: Allow other types of edits 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; #if 0 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=0&filter=%s", homeserver.c_str(), room->id.c_str(), event_id.c_str(), filter.c_str()); #else 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()); #endif 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) 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 &errcode_json = GetMember(json_root, "errcode"); if(errcode_json.IsString() && strcmp(errcode_json.GetString(), "M_FORBIDDEN") == 0) { fprintf(stderr, "You don't have permission to access event %s\n", event_id.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; } #if 0 const rapidjson::Value &state_json = GetMember(json_root, "state"); events_add_user_info(state_json, room); #endif //events_set_room_info(state_json, room); #if 0 const rapidjson::Value &event_json = GetMember(json_root, "event"); std::shared_ptr new_message = parse_message_event(event_json, room); #else std::shared_ptr new_message = parse_message_event(json_root, room); #endif room->fetched_messages_by_event_id.insert(std::make_pair(event_id, new_message)); return new_message; } 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())) { 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(upload_limit) + " bytes, the file you tried to upload is " + std::to_string(file_analyzer.get_file_size()) + " bytes"; 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(filepath.c_str(), 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); return upload_thumbnail_result; } } 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())) 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 std::filesystem::path &filepath) { remove(filepath.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::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()); request_data.AddMember("m.hidden", false, request_data.GetAllocator()); // What is this for? element sends it but its not part of the documentation. Is it for hiding read receipt from other users? in that case, TODO: make it configurable 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) { assert(delegate); 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/join/" + room_id, server_response, std::move(additional_args), true); if(download_result == DownloadResult::OK) { 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)); delegate->join_room(room); room->acquire_room_lock(); std::set &room_tags = room->get_tags_unsafe(); if(room_tags.empty()) { room_tags.insert(OTHERS_ROOM_TAG); delegate->room_add_tag(room, OTHERS_ROOM_TAG); } room->release_room_lock(); } } } return download_result_to_plugin_result(download_result); } 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/" + 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) { delegate->leave_room(room, LeaveType::LEAVE, ""); 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(); } 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 = sf::Vector2i(32 * get_ui_scale(), 32 * get_ui_scale()); room_body_item->thumbnail_mask_type = ThumbnailMaskType::CIRCLE; rooms.push_back(std::move(room_body_item)); } } 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 becasue 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::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(); delegate->clear_data(); } std::shared_ptr Matrix::get_user_by_id(RoomData *room, const std::string &user_id) { auto user = room->get_user_by_id(user_id); if(user) return user; //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); return user_info; } void Matrix::update_user_with_latest_state(RoomData *room, const std::string &user_id) { char url[512]; snprintf(url, sizeof(url), "%s/_matrix/client/r0/profile/%s", homeserver.c_str(), user_id.c_str()); rapidjson::Document json_root; DownloadResult download_result = download_json(json_root, url, {}, true); if(download_result != DownloadResult::OK || !json_root.IsObject()) { fprintf(stderr, "Fetching profile for user %s failed!\n", user_id.c_str()); auto user = get_user_by_id(room, user_id); assert(user); user->resolve_state = UserResolveState::RESOLVED; return; } parse_user_info(json_root, user_id, room); } 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()); auto user = get_user_by_id(room, user_id); 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, std::move(avatar_url)); room->set_user_display_name(user, std::move(display_name)); } #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 } }