From 9602603135f456d906192112288dcd84429c8fee Mon Sep 17 00:00:00 2001 From: dec05eba Date: Wed, 30 Sep 2020 22:05:41 +0200 Subject: Matrix: implement message editing --- src/Body.cpp | 8 +- src/Entry.cpp | 11 +- src/QuickMedia.cpp | 57 +++++++++-- src/Text.cpp | 4 + src/plugins/Matrix.cpp | 271 +++++++++++++++++++++++++++++++++++++++++++------ 5 files changed, 306 insertions(+), 45 deletions(-) (limited to 'src') diff --git a/src/Body.cpp b/src/Body.cpp index 5927831..13e3d7d 100644 --- a/src/Body.cpp +++ b/src/Body.cpp @@ -550,11 +550,11 @@ namespace QuickMedia { } void Body::draw_item(sf::RenderWindow &window, BodyItem *item, sf::Vector2f pos, sf::Vector2f size) { - //sf::Vector2u window_size = window.getSize(); - // glEnable(GL_SCISSOR_TEST); - //glScissor(pos.x, (int)window_size.y - (int)pos.y - (int)pos.y, size.x, size.y); + sf::Vector2u window_size = window.getSize(); + glEnable(GL_SCISSOR_TEST); + glScissor(pos.x, (int)window_size.y - (int)pos.y - (int)size.y, size.x, size.y); draw_item(window, item, pos, size, get_item_height(item) + spacing_y, -1, Json::nullValue); - //glDisable(GL_SCISSOR_TEST); + glDisable(GL_SCISSOR_TEST); } void Body::draw_item(sf::RenderWindow &window, BodyItem *item, const sf::Vector2f &pos, const sf::Vector2f &size, const float item_height, const int item_index, const Json::Value &content_progress) { diff --git a/src/Entry.cpp b/src/Entry.cpp index 977feab..a3c576f 100644 --- a/src/Entry.cpp +++ b/src/Entry.cpp @@ -13,7 +13,7 @@ namespace QuickMedia { on_submit_callback(nullptr), text("", font, cjk_font, 18, 0.0f), width(0.0f), - background(sf::Vector2f(1.0f, 1.0f), 10.0f, 10), + background(sf::Vector2f(1.0f, 1.0f), 7.0f, 10), placeholder(placeholder_text, *font, 18) { text.setEditable(true); @@ -54,6 +54,15 @@ namespace QuickMedia { text.setEditable(editable); } + void Entry::set_text(std::string new_text) { + text.setString(std::move(new_text)); + } + + void Entry::move_caret_to_end() { + text.updateGeometry(); + text.moveCaretToEnd(); + } + void Entry::set_position(const sf::Vector2f &pos) { background.setPosition(pos); text.setPosition(pos + sf::Vector2f(background_margin_horizontal, background_margin_vertical)); diff --git a/src/QuickMedia.cpp b/src/QuickMedia.cpp index c3e61fe..ecb44d5 100644 --- a/src/QuickMedia.cpp +++ b/src/QuickMedia.cpp @@ -3346,20 +3346,21 @@ namespace QuickMedia { enum class ChatState { NAVIGATING, TYPING_MESSAGE, - REPLYING + REPLYING, + EDITING }; Page new_page = Page::CHAT; ChatState chat_state = ChatState::NAVIGATING; - std::shared_ptr replying_to_message; + std::shared_ptr currently_operating_on_item; sf::Text replying_to_text("Replying to:", *font, 18); sf::Sprite logo_sprite(plugin_logo); Entry chat_input("Press ctrl+m to begin writing a message...", font.get(), cjk_font.get()); chat_input.set_editable(false); - chat_input.on_submit_callback = [matrix, &chat_input, &tabs, &selected_tab, ¤t_room_id, &new_page, &chat_state, &replying_to_message](const sf::String &text) mutable { + chat_input.on_submit_callback = [matrix, &chat_input, &tabs, &selected_tab, ¤t_room_id, &new_page, &chat_state, ¤tly_operating_on_item](const sf::String &text) mutable { if(tabs[selected_tab].type == ChatTabType::MESSAGES) { if(text.isEmpty()) return false; @@ -3397,15 +3398,26 @@ namespace QuickMedia { } } else if(chat_state == ChatState::REPLYING) { // TODO: Make asynchronous - if(matrix->post_reply(current_room_id, text, replying_to_message->userdata) == PluginResult::OK) { + if(matrix->post_reply(current_room_id, text, currently_operating_on_item->userdata) == PluginResult::OK) { chat_input.set_editable(false); chat_state = ChatState::NAVIGATING; - replying_to_message = nullptr; + currently_operating_on_item = nullptr; return true; } else { show_notification("QuickMedia", "Failed to post matrix reply", Urgency::CRITICAL); return false; } + } else if(chat_state == ChatState::EDITING) { + // TODO: Make asynchronous + if(matrix->post_edit(current_room_id, text, currently_operating_on_item->userdata) == PluginResult::OK) { + chat_input.set_editable(false); + chat_state = ChatState::NAVIGATING; + currently_operating_on_item = nullptr; + return true; + } else { + show_notification("QuickMedia", "Failed to post matrix edit", Urgency::CRITICAL); + return false; + } } } return false; @@ -3545,12 +3557,34 @@ namespace QuickMedia { std::shared_ptr selected = tabs[selected_tab].body->get_selected_shared(); if(selected) { chat_state = ChatState::REPLYING; - replying_to_message = selected; + currently_operating_on_item = selected; chat_input.set_editable(true); + replying_to_text.setString("Replying to:"); } else { // TODO: Show inline notification show_notification("QuickMedia", "No message selected for replying"); } + } else if(tabs[selected_tab].type == ChatTabType::MESSAGES && event.key.control && event.key.code == sf::Keyboard::E) { + std::shared_ptr selected = tabs[selected_tab].body->get_selected_shared(); + if(selected) { + if(!selected->url.empty()) { // cant edit messages that are image/video posts + // TODO: Show inline notification + show_notification("QuickMedia", "You can only edit messages with no file attached to it"); + } else if(!matrix->was_message_posted_by_me(current_room_id, selected->userdata)) { + // TODO: Show inline notification + show_notification("QuickMedia", "You can't edit a message that was posted by somebody else"); + } else { + chat_state = ChatState::EDITING; + currently_operating_on_item = selected; + chat_input.set_editable(true); + chat_input.set_text(selected->get_description()); // TODO: Description? it may change in the future, in which case this should be edited + chat_input.move_caret_to_end(); + replying_to_text.setString("Editing message:"); + } + } else { + // TODO: Show inline notification + show_notification("QuickMedia", "No message selected for editing"); + } } } @@ -3559,7 +3593,7 @@ namespace QuickMedia { chat_state = ChatState::TYPING_MESSAGE; } - if((chat_state == ChatState::TYPING_MESSAGE || chat_state == ChatState::REPLYING) && tabs[selected_tab].type == ChatTabType::MESSAGES) { + if((chat_state == ChatState::TYPING_MESSAGE || chat_state == ChatState::REPLYING || chat_state == ChatState::EDITING) && tabs[selected_tab].type == ChatTabType::MESSAGES) { if(event.type == sf::Event::TextEntered) { //chat_input.onTextEntered(event.text.unicode); // TODO: Also show typing event when ctrl+v pasting? @@ -3573,8 +3607,9 @@ namespace QuickMedia { } } else if(event.type == sf::Event::KeyPressed && event.key.code == sf::Keyboard::Escape) { chat_input.set_editable(false); + chat_input.set_text(""); chat_state = ChatState::NAVIGATING; - replying_to_message = nullptr; + currently_operating_on_item = nullptr; } //chat_input.on_event(event); chat_input.process_event(event); @@ -3799,11 +3834,11 @@ namespace QuickMedia { window.draw(gradient_points, 4, sf::Quads); // Note: sf::Quads doesn't work with egl } - if(chat_state == ChatState::REPLYING) { + if(chat_state == ChatState::REPLYING || chat_state == ChatState::EDITING) { const float margin = 5.0f; const float replying_to_text_height = replying_to_text.getLocalBounds().height + margin; - const float item_height = std::min(body_size.y - replying_to_text_height - margin, tabs[MESSAGES_TAB_INDEX].body->get_item_height(replying_to_message.get()) + margin); + const float item_height = std::min(body_size.y - replying_to_text_height - margin, tabs[MESSAGES_TAB_INDEX].body->get_item_height(currently_operating_on_item.get()) + margin); sf::RectangleShape overlay(sf::Vector2f(window_size.x, window_size.y - tab_shade_height - chat_input_height_full)); overlay.setPosition(0.0f, tab_shade_height); @@ -3821,7 +3856,7 @@ namespace QuickMedia { replying_to_text.setPosition(body_item_pos.x, body_item_pos.y - replying_to_text_height); window.draw(replying_to_text); - tabs[MESSAGES_TAB_INDEX].body->draw_item(window, replying_to_message.get(), body_item_pos, body_item_size); + tabs[MESSAGES_TAB_INDEX].body->draw_item(window, currently_operating_on_item.get(), body_item_pos, body_item_size); } if(tabs[selected_tab].type == ChatTabType::MESSAGES && !tabs[selected_tab].body->is_last_item_fully_visible()) { diff --git a/src/Text.cpp b/src/Text.cpp index 5e0ad87..fb1373c 100644 --- a/src/Text.cpp +++ b/src/Text.cpp @@ -164,6 +164,10 @@ namespace QuickMedia return editable; } + void Text::moveCaretToEnd() { + caretIndex = vertices_linear.size(); + } + float Text::getWidth() const { return boundingBox.width; diff --git a/src/plugins/Matrix.cpp b/src/plugins/Matrix.cpp index 50be2de..b93164e 100644 --- a/src/plugins/Matrix.cpp +++ b/src/plugins/Matrix.cpp @@ -140,6 +140,7 @@ namespace QuickMedia { auto room_it = room_data_by_id.find(room_id_str); if(room_it == room_data_by_id.end()) { auto room_data = std::make_unique(); + room_data->id = room_id_json.asString(); room_data_by_id.insert(std::make_pair(room_id_str, std::move(room_data))); room_name = room_id_str; fprintf(stderr, "Missing room %s from /sync, adding in joined_rooms\n", room_id_str.c_str()); @@ -268,6 +269,7 @@ namespace QuickMedia { auto room_it = room_data_by_id.find(room_id_str); if(room_it == room_data_by_id.end()) { auto room_data = std::make_unique(); + room_data->id = room_id_str; room_data_by_id.insert(std::make_pair(room_id_str, std::move(room_data))); room_it = room_data_by_id.find(room_id_str); // TODO: Get iterator from above insert } @@ -294,6 +296,7 @@ namespace QuickMedia { auto room_it = room_data_by_id.find(room_id_str); if(room_it == room_data_by_id.end()) { auto room_data = std::make_unique(); + room_data->id = room_id_str; room_data_by_id.insert(std::make_pair(room_id_str, std::move(room_data))); room_it = room_data_by_id.find(room_id_str); // TODO: Get iterator from above insert } @@ -442,53 +445,45 @@ namespace QuickMedia { if(!body_json.isString()) continue; - bool is_reply = false; + std::string replaces_event_id; const Json::Value &relates_to_json = content_json["m.relates_to"]; if(relates_to_json.isObject()) { - const Json::Value &in_reply_to_json = relates_to_json["m.in_reply_to"]; - is_reply = in_reply_to_json.isObject(); + const Json::Value &replaces_event_id_json = relates_to_json["event_id"]; + const Json::Value &rel_type_json = relates_to_json["rel_type"]; + if(replaces_event_id_json.isString() && rel_type_json.isString() && strcmp(rel_type_json.asCString(), "m.replace") == 0) + replaces_event_id = replaces_event_id_json.asString(); } + auto message = std::make_shared(); + if(strcmp(content_type.asCString(), "m.text") == 0) { - auto message = std::make_shared(); - message->user_id = user_it->second; - message->event_id = event_id_str; - message->body = body_json.asString(); message->type = MessageType::TEXT; - message->is_reply = is_reply; - new_messages.push_back(message); - room_data->message_by_event_id[event_id_str] = message; } else if(strcmp(content_type.asCString(), "m.image") == 0) { const Json::Value &url_json = content_json["url"]; if(!url_json.isString() || strncmp(url_json.asCString(), "mxc://", 6) != 0) continue; - auto message = std::make_shared(); - message->user_id = user_it->second; - message->event_id = event_id_str; - message->body = body_json.asString(); message->url = homeserver + "/_matrix/media/r0/download/" + url_json.asString().substr(6); message->thumbnail_url = message_content_extract_thumbnail_url(content_json, homeserver); message->type = MessageType::IMAGE; - message->is_reply = is_reply; - new_messages.push_back(message); - room_data->message_by_event_id[event_id_str] = message; } else if(strcmp(content_type.asCString(), "m.video") == 0) { const Json::Value &url_json = content_json["url"]; if(!url_json.isString() || strncmp(url_json.asCString(), "mxc://", 6) != 0) continue; - auto message = std::make_shared(); - message->event_id = event_id_str; - message->user_id = user_it->second; - message->body = body_json.asString(); message->url = homeserver + "/_matrix/media/r0/download/" + url_json.asString().substr(6); message->thumbnail_url = message_content_extract_thumbnail_url(content_json, homeserver); message->type = MessageType::VIDEO; - message->is_reply = is_reply; - new_messages.push_back(message); - room_data->message_by_event_id[event_id_str] = message; + } else { + continue; } + + message->user_id = user_it->second; + message->event_id = event_id_str; + message->body = body_json.asString(); + message->replaces_event_id = std::move(replaces_event_id); + new_messages.push_back(message); + room_data->message_by_event_id[event_id_str] = message; } // TODO: Loop and std::move instead? doesn't insert create copies? @@ -774,9 +769,13 @@ namespace QuickMedia { static std::string create_body_for_message_reply(const RoomData *room_data, const Message *message, const std::string &body) { std::string related_to_body; switch(message->type) { - case MessageType::TEXT: - related_to_body = message->body; + case MessageType::TEXT: { + if(!message->replaces_event_id.empty() && strncmp(message->body.c_str(), " * ", 3) == 0) + related_to_body = message->body.substr(3); + else + related_to_body = message->body; break; + } case MessageType::IMAGE: related_to_body = "sent an image"; break; @@ -795,7 +794,13 @@ namespace QuickMedia { return PluginResult::ERR; } - Message *relates_to_message = (Message*)relates_to; + Message *relates_to_message_raw = (Message*)relates_to; + std::shared_ptr relates_to_message_shared = room_it->second->message_by_event_id[relates_to_message_raw->event_id]; + std::shared_ptr relates_to_message_original = get_edited_message_original_message(room_it->second.get(), relates_to_message_shared); + if(!relates_to_message_original) { + fprintf(stderr, "Failed to get the original message for message with event id: %s\n", relates_to_message_raw->event_id.c_str()); + return PluginResult::ERR; + } char random_characters[18]; if(!generate_random_characters(random_characters, sizeof(random_characters))) @@ -804,14 +809,14 @@ namespace QuickMedia { std::string random_readable_chars = random_characters_to_readable_string(random_characters, sizeof(random_characters)); Json::Value in_reply_to_json(Json::objectValue); - in_reply_to_json["event_id"] = relates_to_message->event_id; + in_reply_to_json["event_id"] = relates_to_message_original->event_id; Json::Value relates_to_json(Json::objectValue); relates_to_json["m.in_reply_to"] = std::move(in_reply_to_json); Json::Value request_data(Json::objectValue); request_data["msgtype"] = message_type_to_request_msg_type_str(MessageType::TEXT); - request_data["body"] = create_body_for_message_reply(room_it->second.get(), relates_to_message, body); + request_data["body"] = create_body_for_message_reply(room_it->second.get(), relates_to_message_raw, body); // Yes, the reply is to the edited message but the event_id reference is to the original message... request_data["m.relates_to"] = std::move(relates_to_json); Json::StreamWriterBuilder builder; @@ -856,6 +861,204 @@ namespace QuickMedia { return PluginResult::OK; } + PluginResult Matrix::post_edit(const std::string &room_id, const std::string &body, void *relates_to) { + auto room_it = room_data_by_id.find(room_id); + if(room_it == room_data_by_id.end()) { + fprintf(stderr, "Error: no such room: %s\n", room_id.c_str()); + return PluginResult::ERR; + } + + Message *relates_to_message_raw = (Message*)relates_to; + std::shared_ptr relates_to_message_shared = room_it->second->message_by_event_id[relates_to_message_raw->event_id]; + std::shared_ptr relates_to_message_original = get_edited_message_original_message(room_it->second.get(), relates_to_message_shared); + if(!relates_to_message_original) { + fprintf(stderr, "Failed to get the original message for message with event id: %s\n", relates_to_message_raw->event_id.c_str()); + return PluginResult::ERR; + } + + char random_characters[18]; + if(!generate_random_characters(random_characters, sizeof(random_characters))) + return PluginResult::ERR; + + std::string random_readable_chars = random_characters_to_readable_string(random_characters, sizeof(random_characters)); + + std::string formatted_body; + bool contains_formatted_text = false; + int line = 0; + string_split(body, '\n', [&formatted_body, &contains_formatted_text, &line](const char *str, size_t size){ + if(line > 0) + formatted_body += "
"; + if(size > 0 && str[0] == '>') { + std::string line(str, size); + html_escape_sequences(line); + formatted_body += ""; + formatted_body += line; + formatted_body += ""; + contains_formatted_text = true; + } else { + formatted_body.append(str, size); + } + ++line; + return true; + }); + + Json::Value new_content_json(Json::objectValue); + new_content_json["msgtype"] = "m.text"; + new_content_json["body"] = body; + if(contains_formatted_text) { + new_content_json["format"] = "org.matrix.custom.html"; + new_content_json["formatted_body"] = formatted_body; + } + + Json::Value relates_to_json(Json::objectValue); + relates_to_json["event_id"] = relates_to_message_original->event_id; + relates_to_json["rel_type"] = "m.replace"; + + Json::Value request_data(Json::objectValue); + request_data["msgtype"] = message_type_to_request_msg_type_str(MessageType::TEXT); + request_data["body"] = " * " + body; + if(contains_formatted_text) { + request_data["format"] = "org.matrix.custom.html"; + request_data["formatted_body"] = " * " + formatted_body; + } + request_data["m.new_content"] = std::move(new_content_json); + request_data["m.relates_to"] = std::move(relates_to_json); + + Json::StreamWriterBuilder builder; + builder["commentStyle"] = "None"; + builder["indentation"] = ""; + + std::vector additional_args = { + { "-X", "PUT" }, + { "-H", "content-type: application/json" }, + { "-H", "Authorization: Bearer " + access_token }, + { "--data-binary", Json::writeString(builder, std::move(request_data)) } + }; + + char request_url[512]; + snprintf(request_url, sizeof(request_url), "%s/_matrix/client/r0/rooms/%s/send/m.room.message/m%ld.%.*s", homeserver.c_str(), room_id.c_str(), time(NULL), (int)random_readable_chars.size(), random_readable_chars.c_str()); + fprintf(stderr, "Post message to |%s|\n", request_url); + + std::string server_response; + if(download_to_string(request_url, server_response, std::move(additional_args), use_tor, true) != DownloadResult::OK) + return PluginResult::NET_ERR; + + if(server_response.empty()) + return PluginResult::ERR; + + Json::Value json_root; + Json::CharReaderBuilder json_builder; + std::unique_ptr json_reader(json_builder.newCharReader()); + std::string json_errors; + if(!json_reader->parse(&server_response[0], &server_response[server_response.size()], &json_root, &json_errors)) { + fprintf(stderr, "Matrix post message response parse error: %s\n", json_errors.c_str()); + return PluginResult::ERR; + } + + if(!json_root.isObject()) + return PluginResult::ERR; + + const Json::Value &event_id_json = 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.asCString()); + return PluginResult::OK; + } + + // TODO: Right now this recursively calls /rooms//context/ and trusts server to not make it recursive. To make this robust, check iteration count and do not trust server. + // TODO: Optimize? + std::shared_ptr Matrix::get_edited_message_original_message(RoomData *room_data, std::shared_ptr message) { + if(message->replaces_event_id.empty()) + return message; + + auto message_it = room_data->message_by_event_id.find(message->replaces_event_id); + if(message_it == room_data->message_by_event_id.end()) { + Json::Value request_data(Json::objectValue); + request_data["lazy_load_members"] = true; + + Json::StreamWriterBuilder builder; + builder["commentStyle"] = "None"; + builder["indentation"] = ""; + + std::vector additional_args = { + { "-H", "Authorization: Bearer " + access_token } + }; + + std::string filter = url_param_encode(Json::writeString(builder, std::move(request_data))); + + char url[512]; + snprintf(url, sizeof(url), "%s/_matrix/client/r0/rooms/%s/context/%s?limit=0&filter=%s", homeserver.c_str(), room_data->id.c_str(), message->event_id.c_str(), filter.c_str()); + fprintf(stderr, "get message context, url: |%s|\n", url); + + std::string server_response; + if(download_to_string(url, server_response, std::move(additional_args), use_tor, true) != DownloadResult::OK) + return nullptr; + + if(server_response.empty()) + return nullptr; + + Json::Value json_root; + Json::CharReaderBuilder json_builder; + std::unique_ptr json_reader(json_builder.newCharReader()); + std::string json_errors; + if(!json_reader->parse(&server_response[0], &server_response[server_response.size()], &json_root, &json_errors)) { + fprintf(stderr, "Matrix /rooms//context/ response parse error: %s\n", json_errors.c_str()); + return nullptr; + } + + if(!json_root.isObject()) + return nullptr; + + const Json::Value &event_json = json_root["event"]; + if(!event_json.isObject()) + return nullptr; + + const Json::Value &event_id_json = event_json["event_id"]; + if(!event_id_json.isString()) + return nullptr; + + const Json::Value &content_json = event_json["content"]; + if(!content_json.isObject()) + return nullptr; + + const Json::Value &body_json = content_json["body"]; + if(!body_json.isString()) + return nullptr; + + std::string replaces_event_id; + const Json::Value &relates_to_json = content_json["m.relates_to"]; + if(relates_to_json.isObject()) { + const Json::Value &event_id_json = relates_to_json["event_id"]; + const Json::Value &rel_type_json = relates_to_json["rel_type"]; + if(event_id_json.isString() && rel_type_json.isString() && strcmp(rel_type_json.asCString(), "m.replace") == 0) + replaces_event_id = event_id_json.asString(); + } + + const Json::Value &content_type = content_json["msgtype"]; + if(!content_type.isString()) + return nullptr; + + auto new_message = std::make_shared(); + new_message->user_id = -1; + new_message->event_id = event_id_json.asString(); + new_message->replaces_event_id = std::move(replaces_event_id); + if(strcmp(content_type.asCString(), "m.text") == 0) { + new_message->type = MessageType::TEXT; + } else if(strcmp(content_type.asCString(), "m.image") == 0) { + new_message->type = MessageType::IMAGE; + } else if(strcmp(content_type.asCString(), "m.video") == 0) { + new_message->type = MessageType::VIDEO; + } else { + return nullptr; + } + + return get_edited_message_original_message(room_data, std::move(new_message)); + } else { + return get_edited_message_original_message(room_data, message_it->second); + } + } + // Returns empty string on error static const char* file_get_filename(const std::string &filepath) { size_t index = filepath.rfind('/'); @@ -1155,4 +1358,14 @@ namespace QuickMedia { return PluginResult::OK; } + + bool Matrix::was_message_posted_by_me(const std::string &room_id, void *message) const { + auto room_it = room_data_by_id.find(room_id); + if(room_it == room_data_by_id.end()) { + fprintf(stderr, "Error: no such room: %s\n", room_id.c_str()); + return false; + } + Message *message_typed = (Message*)message; + return user_id == room_it->second->user_info[message_typed->user_id].user_id; + } } \ No newline at end of file -- cgit v1.2.3