From ef1dd33682ae26b4af1343aaecf443e7cd883674 Mon Sep 17 00:00:00 2001 From: dec05eba Date: Thu, 1 Oct 2020 22:21:14 +0200 Subject: Matrix: implement mention/reply notifications --- src/Body.cpp | 15 ++++- src/QuickMedia.cpp | 150 ++++++++++++++++++++++++++++++++++--------------- src/plugins/Matrix.cpp | 105 ++++++++++++++++++++++++++++++++-- 3 files changed, 221 insertions(+), 49 deletions(-) (limited to 'src') diff --git a/src/Body.cpp b/src/Body.cpp index 843a1b1..673a095 100644 --- a/src/Body.cpp +++ b/src/Body.cpp @@ -75,7 +75,8 @@ namespace QuickMedia { page_scroll(0.0f), item_background(sf::Vector2f(1.0f, 1.0f), 10.0f, 10), num_visible_items(0), - last_item_fully_visible(true) + last_item_fully_visible(true), + last_fully_visible_item(-1) { progress_text.setFillColor(sf::Color::White); replies_text.setFillColor(sf::Color(129, 162, 190)); @@ -238,6 +239,12 @@ namespace QuickMedia { return items[selected_item]; } + BodyItem* Body::get_last_fully_visible_item() { + if(last_fully_visible_item < 0 || last_fully_visible_item >= (int)items.size() || !items[last_fully_visible_item]->visible) + return nullptr; + return items[last_fully_visible_item].get(); + } + void Body::clamp_selection() { int num_items = (int)items.size(); if(items.empty()) @@ -291,6 +298,7 @@ namespace QuickMedia { item_background_shadow.setFillColor(line_seperator_color); num_visible_items = 0; last_item_fully_visible = true; + last_fully_visible_item = -1; int num_items = items.size(); if(num_items == 0 || size.y <= 0.0f) { @@ -413,6 +421,8 @@ namespace QuickMedia { if((after_pos.y - start_y) + item_height + spacing_y > size.y) last_item_fully_visible = false; + else + last_fully_visible_item = i; if(after_pos.y - start_y >= size.y) break; @@ -422,6 +432,9 @@ namespace QuickMedia { ++num_visible_items; } + if(last_fully_visible_item == -1) + last_fully_visible_item = selected_item; + glDisable(GL_SCISSOR_TEST); for(auto it = item_thumbnail_textures.begin(); it != item_thumbnail_textures.end();) { diff --git a/src/QuickMedia.cpp b/src/QuickMedia.cpp index 909bdd7..da5453e 100644 --- a/src/QuickMedia.cpp +++ b/src/QuickMedia.cpp @@ -3331,50 +3331,58 @@ namespace QuickMedia { */ // This is needed to get initial data, with joined rooms etc. TODO: Remove this once its cached // and allow asynchronous update of rooms - RoomSyncMessages room_sync_messages; - if(matrix->sync(room_sync_messages) != PluginResult::OK) { - show_notification("QuickMedia", "Intial matrix sync failed", Urgency::CRITICAL); - current_page = Page::EXIT; - return; - } - - if(matrix->get_joined_rooms(tabs[ROOMS_TAB_INDEX].body->items) != PluginResult::OK) { - show_notification("QuickMedia", "Failed to get a list of joined rooms", Urgency::CRITICAL); - current_page = Page::EXIT; - return; - } + bool synced = false; struct RoomBodyData { std::shared_ptr body_item; bool last_message_read; + time_t last_read_message_timestamp; }; std::unordered_map body_items_by_room_id; - for(auto body_item : tabs[ROOMS_TAB_INDEX].body->items) { - // TODO: Set |last_message_read| depending on read markers (either remote matrix read markers or locally saved ones) - body_items_by_room_id[body_item->url] = { body_item, true }; - } - for(auto &[room, messages] : room_sync_messages) { - auto room_body_item_it = body_items_by_room_id.find(room->id); - if(room_body_item_it != body_items_by_room_id.end() && !messages.empty()) { - room_body_item_it->second.body_item->set_description(matrix->message_get_author_displayname(room, messages.back().get()) + ": " + extract_first_line(messages.back()->body)); + auto process_new_room_messages = [matrix, &body_items_by_room_id](RoomSyncMessages &room_sync_messages, bool only_show_mentions) mutable { + for(auto &[room, messages] : room_sync_messages) { + bool was_mentioned = false; + for(auto &message : messages) { + if(message->mentions_me) { + was_mentioned = true; + message->mentions_me = false; + // TODO: What if the message or username begins with "-"? also make the notification image be the avatar of the user + std::string desc = "QuickMedia Matrix\n\n" + message->body; + show_notification(matrix->message_get_author_displayname(room, message.get()), desc.c_str()); + } + } + + auto room_body_item_it = body_items_by_room_id.find(room->id); + if(room_body_item_it == body_items_by_room_id.end()) + continue; + + if(only_show_mentions) { + std::string room_desc; + if(!messages.empty()) + room_desc = matrix->message_get_author_displayname(room, messages.back().get()) + ": " + extract_first_line(messages.back()->body); + if(was_mentioned) { + room_desc += "\n** You were mentioned **"; // TODO: Better notification? + room_body_item_it->second.body_item->title_color = sf::Color(255, 100, 100); + room_body_item_it->second.last_message_read = false; + } + room_body_item_it->second.body_item->set_description(std::move(room_desc)); + } else if(!messages.empty()) { + std::string room_desc = "Unread: " + matrix->message_get_author_displayname(room, messages.back().get()) + ": " + extract_first_line(messages.back()->body); + if(was_mentioned) + room_desc += "\n** You were mentioned **"; // TODO: Better notification? + room_body_item_it->second.body_item->set_description(std::move(room_desc)); + room_body_item_it->second.body_item->title_color = sf::Color(255, 100, 100); + room_body_item_it->second.last_message_read = false; + } } - } + }; // TODO: the initial room to view should be the last viewed room when closing QuickMedia. // The room id should be saved in a file when changing viewed room. std::string current_room_id; - if(!tabs[ROOMS_TAB_INDEX].body->items.empty()) - current_room_id = tabs[ROOMS_TAB_INDEX].body->items[0]->url; - - // TODO: Allow empty initial room (if the user hasn't joined any room yet). - assert(!current_room_id.empty()); - RoomBodyData *current_room_body_data = nullptr; - auto room_body_item_it = body_items_by_room_id.find(current_room_id); - if(room_body_item_it != body_items_by_room_id.end()) - current_room_body_data = &room_body_item_it->second; // get_all_room_messages is not needed here because its done in the loop, where the initial timeout is 0ms @@ -3461,6 +3469,7 @@ namespace QuickMedia { struct SyncFutureResult { BodyItems body_items; + BodyItems rooms_body_items; RoomSyncMessages room_sync_messages; }; @@ -3503,9 +3512,7 @@ namespace QuickMedia { const float tab_vertical_offset = 10.0f; sf::Text room_name_text("", *font, 18); - if(current_room_body_data) - room_name_text.setString(current_room_body_data->body_item->get_title()); - const float room_name_text_height = std::floor(room_name_text.getLocalBounds().height); + const float room_name_text_height = 20.0f; const float room_name_text_padding_y = 10.0f; const float room_name_total_height = room_name_text_height + room_name_text_padding_y * 2.0f; const float room_avatar_height = 32.0f; @@ -3534,6 +3541,9 @@ namespace QuickMedia { Body url_selection_body(this, font.get(), bold_font.get(), cjk_font.get()); + sf::Clock read_marker_timer; + const sf::Int32 read_marker_timeout_ms = 3000; + auto launch_url = [this, &redraw](const std::string &url) mutable { if(url.empty()) return; @@ -3608,6 +3618,7 @@ namespace QuickMedia { } else if(event.key.code == sf::Keyboard::Left) { tabs[selected_tab].body->clear_thumbnails(); selected_tab = std::max(0, selected_tab - 1); + read_marker_timer.restart(); if(typing) { fprintf(stderr, "Stopped typing\n"); typing = false; @@ -3616,6 +3627,7 @@ namespace QuickMedia { } else if(event.key.code == sf::Keyboard::Right) { tabs[selected_tab].body->clear_thumbnails(); selected_tab = std::min((int)tabs.size() - 1, selected_tab + 1); + read_marker_timer.restart(); if(typing) { fprintf(stderr, "Stopped typing\n"); typing = false; @@ -3907,6 +3919,8 @@ namespace QuickMedia { } chat_input_height_full = chat_input.get_height() + chat_input_padding_y * 2.0f; + if(selected_tab != MESSAGES_TAB_INDEX) + chat_input_height_full = 0.0f; chat_input_shade.setSize(sf::Vector2f(window_size.x, chat_input_height_full)); chat_input_shade.setPosition(0.0f, window_size.y - chat_input_shade.getSize().y); @@ -3927,12 +3941,21 @@ namespace QuickMedia { sync_running = true; sync_timer.restart(); sync_future_room_id = current_room_id; - sync_future = std::async(std::launch::async, [this, &sync_future_room_id]() { + sync_future = std::async(std::launch::async, [this, &sync_future_room_id, synced]() { Matrix *matrix = static_cast(current_plugin); SyncFutureResult result; if(matrix->sync(result.room_sync_messages) == PluginResult::OK) { fprintf(stderr, "Synced matrix\n"); + + if(!synced) { + if(matrix->get_joined_rooms(result.rooms_body_items) != PluginResult::OK) { + show_notification("QuickMedia", "Failed to get a list of joined rooms", Urgency::CRITICAL); + current_page = Page::EXIT; + return result; + } + } + if(matrix->get_new_room_messages(sync_future_room_id, result.body_items) != PluginResult::OK) { fprintf(stderr, "Failed to get new matrix messages in room: %s\n", sync_future_room_id.c_str()); } @@ -3954,23 +3977,39 @@ namespace QuickMedia { if(scroll_to_end) tabs[MESSAGES_TAB_INDEX].body->select_last_item(); } - for(auto &[room, messages] : sync_result.room_sync_messages) { - auto room_body_item_it = body_items_by_room_id.find(room->id); - if(room_body_item_it != body_items_by_room_id.end() && !messages.empty()) { - room_body_item_it->second.body_item->set_description("Unread: " + matrix->message_get_author_displayname(room, messages.back().get()) + ": " + extract_first_line(messages.back()->body)); - room_body_item_it->second.body_item->title_color = sf::Color(255, 100, 100); - room_body_item_it->second.last_message_read = false; + + if(!synced) { + tabs[ROOMS_TAB_INDEX].body->items = std::move(sync_result.rooms_body_items); + + for(auto body_item : tabs[ROOMS_TAB_INDEX].body->items) { + // TODO: Set |last_message_read| depending on read markers (either remote matrix read markers or locally saved ones) + body_items_by_room_id[body_item->url] = { body_item, true, 0 }; } + + // TODO: the initial room to view should be the last viewed room when closing QuickMedia. + // The room id should be saved in a file when changing viewed room. + if(!tabs[ROOMS_TAB_INDEX].body->items.empty()) + current_room_id = tabs[ROOMS_TAB_INDEX].body->items[0]->url; + + auto room_body_item_it = body_items_by_room_id.find(current_room_id); + if(room_body_item_it != body_items_by_room_id.end()) { + current_room_body_data = &room_body_item_it->second; + room_name_text.setString(current_room_body_data->body_item->get_title()); + } + redraw = true; } + + process_new_room_messages(sync_result.room_sync_messages, !synced); sync_running = false; + synced = true; } if(fetching_previous_messages_running && previous_messages_future.valid() && previous_messages_future.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { BodyItems new_body_items = previous_messages_future.get(); fprintf(stderr, "Finished fetching older messages, num new messages: %zu\n", new_body_items.size()); // Ignore finished fetch of messages if it happened in another room. When we navigate back to the room we will get the messages again - if(previous_messages_future_room_id == current_room_id) { - size_t num_new_messages = new_body_items.size(); + size_t num_new_messages = new_body_items.size(); + if(previous_messages_future_room_id == current_room_id && num_new_messages > 0) { int selected_item_index = tabs[MESSAGES_TAB_INDEX].body->get_selected_item(); tabs[MESSAGES_TAB_INDEX].body->prepend_items(std::move(new_body_items)); tabs[MESSAGES_TAB_INDEX].body->set_selected_item(selected_item_index + num_new_messages); @@ -4073,8 +4112,12 @@ namespace QuickMedia { if(tabs[selected_tab].type == ChatTabType::MESSAGES) { if(tabs[selected_tab].body->is_last_item_fully_visible()) { if(current_room_body_data && !current_room_body_data->last_message_read) { - if(strncmp(current_room_body_data->body_item->get_description().c_str(), "Unread: ", 8) == 0) - current_room_body_data->body_item->set_description(current_room_body_data->body_item->get_description().c_str() + 8); + std::string room_desc = current_room_body_data->body_item->get_description(); + if(strncmp(room_desc.c_str(), "Unread: ", 8) == 0) + room_desc = room_desc.substr(8); + if(room_desc.size() >= 26 && strncmp(room_desc.c_str() + room_desc.size() - 26, "\n** You were mentioned **", 26) == 0) + room_desc = room_desc.substr(0, room_desc.size() - 26); + current_room_body_data->body_item->set_description(std::move(room_desc)); // TODO: Show a line like nheko instead for unread messages, or something else current_room_body_data->body_item->title_color = sf::Color::White; current_room_body_data->last_message_read = true; @@ -4084,7 +4127,26 @@ namespace QuickMedia { } } + // TODO: Cache /sync, then we wont only see loading text + if(!synced) { + sf::Text loading_text("Loading...", *font, 24); + loading_text.setPosition(body_pos.x + body_size.x * 0.5f - loading_text.getLocalBounds().width * 0.5f, body_pos.y + body_size.y * 0.5f - loading_text.getLocalBounds().height * 0.5f); + window.draw(loading_text); + } + if(tabs[selected_tab].type == ChatTabType::MESSAGES) { + BodyItem *last_visible_item = tabs[selected_tab].body->get_last_fully_visible_item(); + if(chat_state != ChatState::URL_SELECTION && current_room_body_data && last_visible_item && read_marker_timer.getElapsedTime().asMilliseconds() >= read_marker_timeout_ms) { + Message *message = (Message*)last_visible_item->userdata; + if(message->timestamp > current_room_body_data->last_read_message_timestamp) { + current_room_body_data->last_read_message_timestamp = message->timestamp; + read_marker_timer.restart(); + if(matrix->set_read_marker(current_room_id, message) != PluginResult::OK) { + fprintf(stderr, "Warning: failed to set read marker to %s\n", message->event_id.c_str()); + } + } + } + window.draw(chat_input_shade); chat_input.draw(window); //chat_input.draw(window, false); window.draw(logo_sprite); diff --git a/src/plugins/Matrix.cpp b/src/plugins/Matrix.cpp index 6d89ff4..e182c11 100644 --- a/src/plugins/Matrix.cpp +++ b/src/plugins/Matrix.cpp @@ -54,7 +54,7 @@ namespace QuickMedia { char url[512]; if(next_batch.empty()) - snprintf(url, sizeof(url), "%s/_matrix/client/r0/sync?timeout=0&full_state=true", homeserver.c_str()); + snprintf(url, sizeof(url), "%s/_matrix/client/r0/sync?timeout=0", homeserver.c_str()); else snprintf(url, sizeof(url), "%s/_matrix/client/r0/sync?timeout=30000&since=%s", homeserver.c_str(), next_batch.c_str()); @@ -312,9 +312,18 @@ namespace QuickMedia { room_it->second->prev_batch = prev_batch_json.asString(); } + // TODO: Is there no better way to check for notifications? this is not robust... + bool has_unread_notifications = false; + const Json::Value &unread_notification_json = (*it)["unread_notifications"]; + if(unread_notification_json.isObject()) { + const Json::Value &highlight_count_json = unread_notification_json["highlight_count"]; + if(highlight_count_json.isNumeric() && highlight_count_json.asInt64() > 0) + has_unread_notifications = true; + } + const Json::Value &events_json = timeline_json["events"]; events_add_user_info(events_json, room_it->second.get()); - events_add_messages(events_json, room_it->second.get(), MessageDirection::AFTER, &room_messages); + events_add_messages(events_json, room_it->second.get(), MessageDirection::AFTER, &room_messages, has_unread_notifications); events_set_room_name(events_json, room_it->second.get()); } @@ -400,13 +409,65 @@ namespace QuickMedia { return ""; } - void Matrix::events_add_messages(const Json::Value &events_json, RoomData *room_data, MessageDirection message_dir, RoomSyncMessages *room_messages) { + // 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 '\0': + return true; + default: + return false; + } + return false; + } + + // TODO: Do not show notification if mention is a reply to somebody else that replies to me? also dont show notification everytime a mention is edited + static bool message_contains_user_mention(const std::string &msg, const std::string &username) { + if(msg.empty()) + return false; + + size_t index = 0; + while(index < msg.size()) { + size_t found_index = msg.find(username, index); + if(found_index == std::string::npos) + return false; + + 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 += username.size(); + } + + return false; + } + + void Matrix::events_add_messages(const Json::Value &events_json, RoomData *room_data, MessageDirection message_dir, RoomSyncMessages *room_messages, bool has_unread_notifications) { if(!events_json.isArray()) return; std::vector> *room_sync_messages = nullptr; if(room_messages) room_sync_messages = &(*room_messages)[room_data]; + std::vector> new_messages; for(const Json::Value &event_item_json : events_json) { @@ -448,6 +509,11 @@ namespace QuickMedia { if(!body_json.isString()) continue; + time_t timestamp = 0; + const Json::Value &origin_server_ts = event_item_json["origin_server_ts"]; + if(origin_server_ts.isNumeric()) + timestamp = origin_server_ts.asInt64(); + std::string replaces_event_id; const Json::Value &relates_to_json = content_json["m.relates_to"]; if(relates_to_json.isObject()) { @@ -485,6 +551,10 @@ namespace QuickMedia { message->event_id = event_id_str; message->body = body_json.asString(); message->replaces_event_id = std::move(replaces_event_id); + // TODO: Is @room ok? shouldn't we also check if the user has permission to do @room? (only when notifications are limited to @mentions) + if(has_unread_notifications && !username.empty()) + message->mentions_me = message_contains_user_mention(message->body, username) || message_contains_user_mention(message->body, "@room"); + message->timestamp = timestamp; new_messages.push_back(message); room_data->message_by_event_id[event_id_str] = message; if(room_sync_messages) @@ -631,7 +701,7 @@ namespace QuickMedia { events_set_room_name(state_json, room_data); const Json::Value &chunk_json = json_root["chunk"]; - events_add_messages(chunk_json, room_data, MessageDirection::BEFORE, nullptr); + events_add_messages(chunk_json, room_data, MessageDirection::BEFORE, nullptr, false); const Json::Value &end_json = json_root["end"]; if(!end_json.isString()) { @@ -1232,6 +1302,7 @@ namespace QuickMedia { json_root["homeserver"] = homeserver; this->user_id = user_id_json.asString(); + this->username = extract_user_name_from_user_id(this->user_id); this->access_token = access_token_json.asString(); this->homeserver = homeserver; @@ -1266,6 +1337,7 @@ namespace QuickMedia { // Make sure all fields are reset here! room_data_by_id.clear(); user_id.clear(); + username.clear(); access_token.clear(); homeserver.clear(); next_batch.clear(); @@ -1390,6 +1462,7 @@ namespace QuickMedia { } this->user_id = std::move(user_id); + this->username = extract_user_name_from_user_id(this->user_id); this->access_token = std::move(access_token); this->homeserver = std::move(homeserver); return PluginResult::OK; @@ -1440,6 +1513,30 @@ namespace QuickMedia { return PluginResult::OK; } + PluginResult Matrix::set_read_marker(const std::string &room_id, const Message *message) { + Json::Value request_data(Json::objectValue); + request_data["m.fully_read"] = message->event_id; + request_data["m.read"] = message->event_id; + request_data["m.hidden"] = false; // 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 + + Json::StreamWriterBuilder builder; + builder["commentStyle"] = "None"; + builder["indentation"] = ""; + + std::vector additional_args = { + { "-X", "POST" }, + { "-H", "content-type: application/json" }, + { "--data-binary", Json::writeString(builder, std::move(request_data)) }, + { "-H", "Authorization: Bearer " + access_token } + }; + + std::string server_response; + if(download_to_string(homeserver + "/_matrix/client/r0/rooms/" + room_id + "/read_markers", server_response, std::move(additional_args), use_tor, true) != DownloadResult::OK) + return PluginResult::NET_ERR; + + 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()) { -- cgit v1.2.3