From 40e0f8f5d8c3e480f01a2d71b6a493247adcb77f Mon Sep 17 00:00:00 2001 From: dec05eba Date: Mon, 21 Sep 2020 03:49:17 +0200 Subject: Initial matrix support --- src/Body.cpp | 51 +++- src/DownloadUtils.cpp | 4 +- src/ImageUtils.cpp | 2 + src/Notification.cpp | 25 ++ src/Program.c | 6 +- src/QuickMedia.cpp | 442 ++++++++++++++++++++++++++---- src/SearchBar.cpp | 7 +- src/Storage.cpp | 39 ++- src/StringUtils.cpp | 13 + src/Text.cpp | 8 +- src/VideoPlayer.cpp | 4 +- src/plugins/Dmenu.cpp | 2 +- src/plugins/Fourchan.cpp | 8 - src/plugins/Matrix.cpp | 687 +++++++++++++++++++++++++++++++++++++++++++++++ src/plugins/NyaaSi.cpp | 2 +- src/plugins/Plugin.cpp | 37 ++- 16 files changed, 1236 insertions(+), 101 deletions(-) create mode 100644 src/Notification.cpp create mode 100644 src/plugins/Matrix.cpp (limited to 'src') diff --git a/src/Body.cpp b/src/Body.cpp index 73c3932..ab68a61 100644 --- a/src/Body.cpp +++ b/src/Body.cpp @@ -11,7 +11,7 @@ const sf::Color front_color(43, 45, 47); const sf::Color back_color(33, 35, 37); namespace QuickMedia { - BodyItem::BodyItem(std::string _title): visible(true), dirty(true), background_color(front_color) { + BodyItem::BodyItem(std::string _title): visible(true), dirty(false), dirty_description(false), background_color(front_color) { set_title(std::move(_title)); } @@ -24,6 +24,7 @@ namespace QuickMedia { author = other.author; visible = other.visible; dirty = other.dirty; + dirty_description = other.dirty_description; if(other.title_text) title_text = std::make_unique(*other.title_text); else @@ -37,16 +38,16 @@ namespace QuickMedia { } Body::Body(Program *program, sf::Font *font, sf::Font *bold_font) : - program(program), font(font), bold_font(bold_font), progress_text("", *font, 14), author_text("", *bold_font, 16), replies_text("", *font, 14), - selected_item(0), draw_thumbnails(false), + wrap_around(false), + program(program), loading_thumbnail(false), - wrap_around(false) + selected_item(0) { progress_text.setFillColor(sf::Color::White); author_text.setFillColor(sf::Color::White); @@ -134,6 +135,12 @@ namespace QuickMedia { selected_item = 0; } + void Body::append_items(BodyItems new_items) { + for(auto &body_item : new_items) { + items.push_back(std::move(body_item)); + } + } + void Body::clear_thumbnails() { item_thumbnail_textures.clear(); } @@ -230,6 +237,7 @@ namespace QuickMedia { thumbnail_it.second.referenced = false; } + // TODO: Change font size. Currently it doesn't work because it glitches out. Why does that happen?? for(auto &body_item : items) { if(body_item->dirty) { body_item->dirty = false; @@ -240,8 +248,12 @@ namespace QuickMedia { body_item->title_text->updateGeometry(); } - if(!body_item->get_description().empty() && !body_item->description_text) { - body_item->description_text = std::make_unique(body_item->get_description(), font, 14, size.x - 50 - image_padding_x * 2.0f); + if(body_item->dirty_description) { + body_item->dirty_description = true; + if(body_item->description_text) + body_item->description_text->setString(body_item->get_description()); + else + body_item->description_text = std::make_unique(body_item->get_description(), font, 14, size.x - 50 - image_padding_x * 2.0f); body_item->description_text->updateGeometry(); } } @@ -253,7 +265,10 @@ namespace QuickMedia { for(; first_visible_item >= 0; --first_visible_item) { auto &item = items[first_visible_item]; if(item->visible) { - float item_height = item->title_text->getHeight(); + float item_height = 0.0f; + if(!item->get_title().empty()) { + item_height += item->title_text->getHeight(); + } if(!item->author.empty()) { item_height += author_text.getCharacterSize() + 2.0f; } @@ -299,7 +314,10 @@ namespace QuickMedia { item_thumbnail_textures[item->thumbnail_url].referenced = true; auto &item_thumbnail = item_thumbnail_textures[item->thumbnail_url]; - float item_height = item->title_text->getHeight(); + float item_height = 0.0f; + if(!item->get_title().empty()) { + item_height += item->title_text->getHeight(); + } if(!item->author.empty()) { item_height += author_text.getCharacterSize() + 2.0f; } @@ -371,6 +389,7 @@ namespace QuickMedia { } if(!item->author.empty()) { + // TODO: Remove this call, should not be called every frame author_text.setString(item->author); author_text.setPosition(std::floor(item_pos.x + text_offset_x), std::floor(item_pos.y + padding_y)); window.draw(author_text); @@ -390,12 +409,18 @@ namespace QuickMedia { //title_text.setString(item->title); //title_text.setPosition(std::floor(item_pos.x + text_offset_x), std::floor(item_pos.y + padding_y)); //window.draw(title_text); - item->title_text->setPosition(std::floor(item_pos.x + text_offset_x), std::floor(item_pos.y + padding_y - 4.0f)); - item->title_text->setMaxWidth(size.x - text_offset_x - image_padding_x * 2.0f); - item->title_text->draw(window); + if(!item->get_title().empty()) { + item->title_text->setPosition(std::floor(item_pos.x + text_offset_x), std::floor(item_pos.y + padding_y - 4.0f)); + item->title_text->setMaxWidth(size.x - text_offset_x - image_padding_x * 2.0f); + item->title_text->draw(window); + } - if(item->description_text) { - item->description_text->setPosition(std::floor(item_pos.x + text_offset_x), std::floor(item_pos.y + padding_y - 4.0f + item->title_text->getHeight())); + if(!item->get_description().empty()) { + float height_offset = 0.0f; + if(!item->get_title().empty()) { + height_offset = item->title_text->getHeight(); + } + item->description_text->setPosition(std::floor(item_pos.x + text_offset_x), std::floor(item_pos.y + padding_y - 4.0f + height_offset)); item->description_text->setMaxWidth(size.x - text_offset_x - image_padding_x * 2.0f); item->description_text->draw(window); } diff --git a/src/DownloadUtils.cpp b/src/DownloadUtils.cpp index deb5c29..b7636d8 100644 --- a/src/DownloadUtils.cpp +++ b/src/DownloadUtils.cpp @@ -21,7 +21,7 @@ namespace QuickMedia { std::vector args; if(use_tor) args.push_back("torsocks"); - args.insert(args.end(), { "curl", "-f", "-H", "Accept-Language: en-US,en;q=0.5", "--compressed", "-s", "-L" }); + args.insert(args.end(), { "curl", "-f", "-H", "Accept-Language: en-US,en;q=0.5", "-H", "Connection: keep-alive", "--compressed", "-s", "-L" }); for(const CommandArg &arg : additional_args) { args.push_back(arg.option.c_str()); args.push_back(arg.value.c_str()); @@ -49,7 +49,6 @@ namespace QuickMedia { DownloadResult download_to_string_cache(const std::string &url, std::string &result, const std::vector &additional_args, bool use_tor, bool use_browser_useragent) { Path media_dir = get_cache_dir().join("media"); Path media_file_path = Path(media_dir).join(base64_url::encode(url)); - Path media_file_path_tmp(media_file_path.data + ".tmp"); if(get_file_type(media_file_path) == FileType::REGULAR) { if(file_get_content(media_file_path, result) == 0) { fprintf(stderr, "Loaded %s from cache\n", url.c_str()); @@ -61,6 +60,7 @@ namespace QuickMedia { } else { DownloadResult download_result = download_to_string(url, result, additional_args, use_tor, use_browser_useragent); if(download_result == DownloadResult::OK) { + Path media_file_path_tmp(media_file_path.data + ".tmp"); if(create_directory_recursive(media_dir) == 0 && file_overwrite(media_file_path_tmp, result) == 0) { if(rename(media_file_path_tmp.data.c_str(), media_file_path.data.c_str()) != 0) { perror("rename"); diff --git a/src/ImageUtils.cpp b/src/ImageUtils.cpp index 008d8a9..ea1841b 100644 --- a/src/ImageUtils.cpp +++ b/src/ImageUtils.cpp @@ -23,6 +23,7 @@ namespace QuickMedia { return false; } +#if 0 static bool is_cpu_little_endian() { unsigned short i; memcpy(&i, "LE", sizeof(i)); @@ -44,6 +45,7 @@ namespace QuickMedia { result = __builtin_bswap32(result); return result; } +#endif #if 0 static bool tiff_get_size(unsigned char *data, size_t data_size, int *width, int *height) { if(data_size < 8) diff --git a/src/Notification.cpp b/src/Notification.cpp new file mode 100644 index 0000000..1201557 --- /dev/null +++ b/src/Notification.cpp @@ -0,0 +1,25 @@ +#include "../include/Notification.hpp" +#include "../include/Program.h" +#include +#include + +namespace QuickMedia { + const char* urgency_string(Urgency urgency) { + switch(urgency) { + case Urgency::LOW: + return "low"; + case Urgency::NORMAL: + return "normal"; + case Urgency::CRITICAL: + return "critical"; + } + assert(false); + return nullptr; + } + + void show_notification(const std::string &title, const std::string &description, Urgency urgency) { + const char *args[] = { "notify-send", "-u", urgency_string(urgency), "--", title.c_str(), description.c_str(), nullptr }; + exec_program_async(args, nullptr); + fprintf(stderr, "Notification: title: %s, description: %s\n", title.c_str(), description.c_str()); + } +} \ No newline at end of file diff --git a/src/Program.c b/src/Program.c index bb476c4..c6bff50 100644 --- a/src/Program.c +++ b/src/Program.c @@ -44,7 +44,7 @@ int exec_program(const char **args, ProgramOutputCallback output_callback, void close(fd[READ_END]); close(fd[WRITE_END]); - execvp(args[0], args); + execvp(args[0], (char* const*)args); perror("execvp"); _exit(127); } else { /* parent */ @@ -161,7 +161,7 @@ int exec_program_async(const char **args, pid_t *result_process_id) { if(getppid() != parent_pid) _exit(127); - execvp(args[0], args); + execvp(args[0], (char* const*)args); perror("execvp"); _exit(127); } else { @@ -171,7 +171,7 @@ int exec_program_async(const char **args, pid_t *result_process_id) { // Daemonize child to make the parent the init process which will reap the zombie child pid_t second_child = fork(); if(second_child == 0) { // child - execvp(args[0], args); + execvp(args[0], (char* const*)args); perror("execvp"); _exit(127); } else if(second_child != -1) { diff --git a/src/QuickMedia.cpp b/src/QuickMedia.cpp index 956370f..1d0518f 100644 --- a/src/QuickMedia.cpp +++ b/src/QuickMedia.cpp @@ -7,6 +7,7 @@ #include "../plugins/Fourchan.hpp" #include "../plugins/Dmenu.hpp" #include "../plugins/NyaaSi.hpp" +#include "../plugins/Matrix.hpp" #include "../include/Scale.hpp" #include "../include/Program.h" #include "../include/VideoPlayer.hpp" @@ -41,15 +42,15 @@ static const sf::Color tab_selected_color(0, 85, 119); static const sf::Color tab_unselected_color(43, 45, 47); // Prevent writing to broken pipe from exiting the program -static void sigpipe_handler(int unused) { +static void sigpipe_handler(int) { } -static int x_error_handler(Display *display, XErrorEvent *event) { +static int x_error_handler(Display*, XErrorEvent*) { return 0; } -static int x_io_error_handler(Display *display) { +static int x_io_error_handler(Display*) { return 0; } @@ -275,7 +276,7 @@ namespace QuickMedia { current_plugin = nullptr; std::string plugin_logo_path; - std::string search_placeholder = "Search..."; + std::string search_placeholder; for(int i = 1; i < argc; ++i) { if(!current_plugin) { @@ -302,6 +303,9 @@ namespace QuickMedia { plugin_logo_path = resources_root + "images/nyaa_si_logo.png"; } else if(strcmp(argv[i], "dmenu") == 0) { current_plugin = new Dmenu(); + } else if(strcmp(argv[i], "matrix") == 0) { + current_plugin = new Matrix(); + plugin_logo_path = resources_root + "images/matrix_logo.png"; } else { fprintf(stderr, "Invalid plugin %s\n", argv[i]); usage(); @@ -327,6 +331,12 @@ namespace QuickMedia { } } + if(!search_placeholder.empty() && current_plugin->name == "dmenu") { + fprintf(stderr, "Option -p is only valid with dmenu\n"); + usage(); + return -1; + } + if(use_tor && !is_program_executable_by_name("torsocks")) { fprintf(stderr, "torsocks needs to be installed (and accessible from PATH environment variable) when using the --tor option\n"); return -2; @@ -389,6 +399,20 @@ namespace QuickMedia { plugin_logo.setSmooth(true); } + if(current_plugin->name == "matrix") { + Matrix *matrix = static_cast(current_plugin); + if(matrix->load_and_verify_cached_session() == PluginResult::OK) { + current_page = Page::CHAT; + } else { + fprintf(stderr, "Failed to load session cache, redirecting to login page\n"); + current_page = Page::CHAT_LOGIN; + } + search_placeholder = "Send a message..."; + } + + if(search_placeholder.empty()) + search_placeholder = "Search..."; + search_bar = std::make_unique(font, &plugin_logo, search_placeholder); search_bar->text_autosearch_delay = current_plugin->get_search_delay(); @@ -459,10 +483,15 @@ namespace QuickMedia { body->clear_thumbnails(); break; } - default: - fprintf(stderr, "Page not implemented: %d\n", current_page); - window.close(); + case Page::CHAT_LOGIN: { + chat_login_page(); + break; + } + case Page::CHAT: { + body->draw_thumbnails = true; + chat_page(); break; + } } } @@ -490,9 +519,9 @@ namespace QuickMedia { search_bar->clear(); } } - } else if(handle_searchbar && event.type == sf::Event::TextEntered) { - search_bar->onTextEntered(event.text.unicode); } else if(handle_searchbar) { + if(event.type == sf::Event::TextEntered) + search_bar->onTextEntered(event.text.unicode); search_bar->on_event(event); } } @@ -505,41 +534,6 @@ namespace QuickMedia { return base64_url::decode(data); } - static bool read_file_as_json(const Path &filepath, Json::Value &result) { - std::string file_content; - if(file_get_content(filepath, file_content) != 0) { - fprintf(stderr, "Failed to get content of file: %s\n", filepath.data.c_str()); - return false; - } - - Json::CharReaderBuilder json_builder; - std::unique_ptr json_reader(json_builder.newCharReader()); - std::string json_errors; - if(!json_reader->parse(file_content.data(), file_content.data() + file_content.size(), &result, &json_errors)) { - fprintf(stderr, "Failed to read file %s as json, error: %s\n", filepath.data.c_str(), json_errors.c_str()); - return false; - } - - return true; - } - - static bool save_json_to_file_atomic(const Path &path, const Json::Value &json) { - Path tmp_path = path; - tmp_path.append(".tmp"); - - Json::StreamWriterBuilder json_builder; - if(file_overwrite(tmp_path, Json::writeString(json_builder, json)) != 0) - return false; - - // Rename is atomic under posix! - if(rename(tmp_path.data.c_str(), path.data.c_str()) != 0) { - perror("save_json_to_file_atomic rename"); - return false; - } - - return true; - } - enum class SearchSuggestionTab { ALL, HISTORY, @@ -861,7 +855,8 @@ namespace QuickMedia { } else if(next_page == Page::VIDEO_CONTENT) { watched_videos.clear(); if(content_url.empty()) - next_page = Page::SEARCH_RESULT; + //next_page = Page::SEARCH_RESULT; + next_page = Page::SEARCH_SUGGESTION; else { page_stack.push(Page::SEARCH_SUGGESTION); } @@ -902,7 +897,7 @@ namespace QuickMedia { std::vector tabs; int selected_tab = 0; - auto login_submit_callback = [this, &tabs, &selected_tab](const std::string &text) -> bool { + auto login_submit_callback = [this, &tabs, &selected_tab](const std::string&) -> bool { if(!tabs[selected_tab].body) { std::string username = tabs[selected_tab].login_tab->username->get_text(); std::string password = tabs[selected_tab].login_tab->password->get_text(); @@ -970,7 +965,7 @@ namespace QuickMedia { typing = false; }; - search_bar->onTextSubmitCallback = [this, &tabs, &selected_tab, &typing](const std::string &text) -> bool { + search_bar->onTextSubmitCallback = [this, &tabs, &selected_tab, &typing](const std::string&) -> bool { if(current_plugin->name != "dmenu") { if(typing || tabs[selected_tab].body->no_items_visible()) return false; @@ -988,7 +983,11 @@ namespace QuickMedia { }); } else { */ - PluginResult front_page_result = current_plugin->get_front_page(body->items); + if(current_plugin->get_front_page(body->items) != PluginResult::OK) { + show_notification("QuickMedia", "Failed to get front page", Urgency::CRITICAL); + current_page = Page::EXIT; + return; + } body->clamp_selection(); /*}*/ @@ -1556,7 +1555,10 @@ namespace QuickMedia { } if(video_player_window && XCheckTypedWindowEvent(disp, video_player_window, KeyPress, &xev)/* && xev.xkey.subwindow == video_player_window*/) { + #pragma GCC diagnostic push + #pragma GCC diagnostic ignored "-Wdeprecated-declarations" KeySym pressed_keysym = XKeycodeToKeysym(disp, xev.xkey.keycode, 0); + #pragma GCC diagnostic pop bool pressing_ctrl = (CLEANMASK(xev.xkey.state) == ControlMask); if(pressed_keysym == XK_Escape) { current_page = previous_page; @@ -1722,7 +1724,7 @@ namespace QuickMedia { tabs[selected_tab].body->clamp_selection(); }; - search_bar->onTextSubmitCallback = [this, &tabs, &selected_tab, &json_chapters](const std::string &text) -> bool { + search_bar->onTextSubmitCallback = [this, &tabs, &selected_tab, &json_chapters](const std::string&) -> bool { if(tabs[selected_tab].type == EpisodeListTabType::CHAPTERS) { BodyItem *selected_item = body->get_selected(); if(!selected_item) @@ -1741,7 +1743,7 @@ namespace QuickMedia { } }; - auto download_create_page = [manga](std::string url) { + auto download_creator_page = [manga](std::string url) { BodyItems body_items; if(manga->get_creators_manga_list(url, body_items) != PluginResult::OK) show_notification("Manga", "Failed to download authors page", Urgency::CRITICAL); @@ -1762,7 +1764,7 @@ namespace QuickMedia { tab.body = new Body(this, &font, &bold_font); tab.body->draw_thumbnails = true; tab.creator = &creator; - tab.creator_page_download_future = std::async(std::launch::async, download_create_page, creator.url); + tab.creator_page_download_future = std::async(std::launch::async, download_creator_page, creator.url); tab.text = sf::Text(creator.name, font, tab_text_size); tabs.push_back(std::move(tab)); } @@ -2323,7 +2325,7 @@ namespace QuickMedia { } }; - search_bar->onTextSubmitCallback = [this](const std::string &text) -> bool { + search_bar->onTextSubmitCallback = [this](const std::string&) -> bool { BodyItem *selected_item = body->get_selected(); if(!selected_item) return false; @@ -2400,7 +2402,7 @@ namespace QuickMedia { // TODO: Have an option for the search bar to be multi-line. search_bar->onTextUpdateCallback = nullptr; - search_bar->onTextSubmitCallback = [this](const std::string &text) -> bool { + search_bar->onTextSubmitCallback = [this](const std::string&) -> bool { if(current_plugin->name == "nyaa.si") { BodyItem *selected_item = body->get_selected(); if(selected_item && strncmp(selected_item->url.c_str(), "magnet:?", 8) == 0) { @@ -2456,7 +2458,7 @@ namespace QuickMedia { body->select_first_item(); }; - search_bar->onTextSubmitCallback = [this](const std::string &text) -> bool { + search_bar->onTextSubmitCallback = [this](const std::string&) -> bool { BodyItem *selected_item = body->get_selected(); if(!selected_item) return false; @@ -2922,4 +2924,336 @@ namespace QuickMedia { // so you dont have to retype a post that was in the middle of being posted when returning. search_bar->clear(); } + + // TODO: Provide a way to logout + void Program::chat_login_page() { + assert(current_plugin->name == "matrix"); + + SearchBar login_input(font, nullptr, "Username"); + SearchBar password_input(font, nullptr, "Password", true); + SearchBar homeserver_input(font, nullptr, "Homeserver"); + + sf::Text status_text("", font, 18); + + const int num_inputs = 3; + SearchBar *inputs[num_inputs] = { &login_input, &password_input, &homeserver_input }; + int focused_input = 0; + + auto text_submit_callback = [this, inputs, &status_text](const sf::String&) -> bool { + Matrix *matrix = static_cast(current_plugin); + for(int i = 0; i < num_inputs; ++i) { + if(inputs[i]->get_text().empty()) { + status_text.setString("All fields need to be filled in"); + return false; + } + } + + std::string err_msg; + // TODO: Make asynchronous + if(matrix->login(inputs[0]->get_text(), inputs[1]->get_text(), inputs[2]->get_text(), err_msg) == PluginResult::OK) { + current_page = Page::CHAT; + } else { + status_text.setString("Failed to login, error: " + err_msg); + } + return false; + }; + + for(int i = 0; i < num_inputs; ++i) { + inputs[i]->caret_visible = false; + inputs[i]->onTextSubmitCallback = text_submit_callback; + } + inputs[focused_input]->caret_visible = true; + + sf::Vector2f body_pos; + sf::Vector2f body_size; + bool redraw = true; + sf::Event event; + + while (current_page == Page::CHAT_LOGIN) { + while (window.pollEvent(event)) { + base_event_handler(event, Page::EXIT, false, false, false); + if(event.type == sf::Event::Resized || event.type == sf::Event::GainedFocus) { + redraw = true; + } else if(event.type == sf::Event::TextEntered) { + inputs[focused_input]->onTextEntered(event.text.unicode); + } else if(event.type == sf::Event::KeyPressed && event.key.code == sf::Keyboard::Tab) { + for(int i = 0; i < num_inputs; ++i) { + inputs[i]->caret_visible = false; + } + focused_input = (focused_input + 1) % num_inputs; + inputs[focused_input]->caret_visible = true; + } + inputs[focused_input]->on_event(event); + } + + if(redraw) { + redraw = false; + search_bar->onWindowResize(window_size); + get_body_dimensions(window_size, search_bar.get(), body_pos, body_size); + } + + window.clear(back_color); + body->draw(window, body_pos, body_size); + float y = 0.0f; + for(int i = 0; i < num_inputs; ++i) { + inputs[i]->set_vertical_position(y); + inputs[i]->update(); + inputs[i]->draw(window); + y += inputs[i]->getBottomWithoutShadow(); + } + status_text.setPosition(0.0f, y + 10.0f); + window.draw(status_text); + window.display(); + } + } + + enum class ChatTabType { + MESSAGES, + ROOMS + }; + + struct ChatTab { + ChatTabType type; + std::unique_ptr body; + std::future future; + sf::Text text; + }; + + void Program::chat_page() { + assert(current_plugin->name == "matrix"); + Matrix *matrix = static_cast(current_plugin); + + std::vector tabs; + int selected_tab = 0; + size_t room_message_index = 0; + + ChatTab messages_tab; + messages_tab.type = ChatTabType::MESSAGES; + messages_tab.body = std::make_unique(this, &font, &bold_font); + messages_tab.body->draw_thumbnails = true; + messages_tab.text = sf::Text("Messages", font, tab_text_size); + tabs.push_back(std::move(messages_tab)); + + ChatTab rooms_tab; + rooms_tab.type = ChatTabType::ROOMS; + rooms_tab.body = std::make_unique(this, &font, &bold_font); + rooms_tab.body->draw_thumbnails = true; + rooms_tab.text = sf::Text("Rooms", font, tab_text_size); + tabs.push_back(std::move(rooms_tab)); + + const int MESSAGES_TAB_INDEX = 0; + const int ROOMS_TAB_INDEX = 1; + + tabs[MESSAGES_TAB_INDEX].body->clear_items(); + /* + if(matrix->get_cached_sync(tabs[MESSAGES_TAB_INDEX].body->items) != PluginResult::OK) { + fprintf(stderr, "Failed to get matrix cached sync\n"); + } else { + fprintf(stderr, "Loaded matrix sync from cache, num items: %zu\n", tabs[MESSAGES_TAB_INDEX].body->items.size()); + } + */ + if(matrix->sync() != 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; + } + + // 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]->get_title(); + + // TODO: Allow empty initial room (if the user hasn't joined any room yet) + assert(!current_room_id.empty()); + + // TODO: Filer for rooms and settings + search_bar->onTextUpdateCallback = nullptr; + + search_bar->onTextSubmitCallback = [matrix, &tabs, &selected_tab, &room_message_index, ¤t_room_id](const std::string &text) -> bool { + if(tabs[selected_tab].type == ChatTabType::MESSAGES) { + if(text.empty()) + return false; + + // TODO: Make asynchronous + if(matrix->post_message(current_room_id, text) != PluginResult::OK) { + show_notification("QuickMedia", "Failed to post matrix message", Urgency::CRITICAL); + return false; + } + return true; + } else if(tabs[selected_tab].type == ChatTabType::ROOMS) { + BodyItem *selected_item = tabs[selected_tab].body->get_selected(); + if(selected_item) { + // TODO: Change to selected_item->url once rooms have a display name + current_room_id = selected_item->get_title(); + selected_tab = MESSAGES_TAB_INDEX; + room_message_index = 0; + tabs[MESSAGES_TAB_INDEX].body->clear_items(); + + size_t num_new_messages = 0; + BodyItems new_items; + // TODO: Make asynchronous + if(matrix->get_room_messages(current_room_id, 0, new_items, num_new_messages) == PluginResult::OK) { + room_message_index += num_new_messages; + tabs[MESSAGES_TAB_INDEX].body->append_items(std::move(new_items)); + if(!tabs[MESSAGES_TAB_INDEX].body->items.empty() && num_new_messages > 0) + tabs[MESSAGES_TAB_INDEX].body->set_selected_item(tabs[MESSAGES_TAB_INDEX].body->items.size() - 1); + } else { + std::string err_msg = "Failed to get messages in room: " + current_room_id; + show_notification("QuickMedia", err_msg, Urgency::CRITICAL); + } + return true; + } + } + return false; + }; + + struct SyncFutureResult { + BodyItems body_items; + size_t num_new_messages; + }; + + std::future sync_future; + bool sync_running = false; + std::string sync_future_room_id; + sf::Clock sync_timer; + sf::Int32 sync_min_time_ms = 0; // Sync immediately the first time + + const float tab_spacer_height = 0.0f; + sf::Vector2f body_pos; + sf::Vector2f body_size; + bool redraw = true; + sf::Event event; + + sf::RectangleShape tab_drop_shadow; + tab_drop_shadow.setFillColor(sf::Color(23, 25, 27)); + + while (current_page == Page::CHAT) { + while (window.pollEvent(event)) { + base_event_handler(event, Page::EXIT, false, false); + if(event.type == sf::Event::Resized || event.type == sf::Event::GainedFocus) { + redraw = true; + } else if(event.type == sf::Event::KeyPressed) { + if(event.key.code == sf::Keyboard::Up) { + tabs[selected_tab].body->select_previous_item(); + } else if(event.key.code == sf::Keyboard::Down) { + tabs[selected_tab].body->select_next_item(); + } else if(event.key.code == sf::Keyboard::Escape) { + current_page = Page::EXIT; + body->clear_items(); + body->reset_selected(); + search_bar->clear(); + } else if(event.key.code == sf::Keyboard::Left) { + tabs[selected_tab].body->filter_search_fuzzy(""); + tabs[selected_tab].body->clamp_selection(); + selected_tab = std::max(0, selected_tab - 1); + search_bar->clear(); + } else if(event.key.code == sf::Keyboard::Right) { + tabs[selected_tab].body->filter_search_fuzzy(""); + tabs[selected_tab].body->clamp_selection(); + selected_tab = std::min((int)tabs.size() - 1, selected_tab + 1); + search_bar->clear(); + } + } + } + + if(redraw) { + redraw = false; + search_bar->onWindowResize(window_size); + search_bar->set_vertical_position(window_size.y - search_bar->getBottomWithoutShadow()); + + float body_padding_horizontal = 25.0f; + float body_padding_vertical = 25.0f; + float body_width = window_size.x - body_padding_horizontal * 2.0f; + if(body_width <= 480.0f) { + body_width = window_size.x; + body_padding_horizontal = 0.0f; + body_padding_vertical = 10.0f; + } + + float search_bottom = search_bar->getBottomWithoutShadow(); + body_pos = sf::Vector2f(body_padding_horizontal, body_padding_vertical + tab_height); + body_size = sf::Vector2f(body_width, window_size.y - search_bottom - body_padding_vertical - tab_height); + //get_body_dimensions(window_size, search_bar.get(), body_pos, body_size, true); + } + + if(!sync_running && sync_timer.getElapsedTime().asMilliseconds() >= sync_min_time_ms) { + fprintf(stderr, "Time since last sync: %d ms\n", sync_timer.getElapsedTime().asMilliseconds()); + // TODO: Ignore matrix->sync() call the first time, its already called above for the first time + sync_min_time_ms = 3000; + 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, room_message_index]() { + Matrix *matrix = static_cast(current_plugin); + + SyncFutureResult result; + result.num_new_messages = 0; + if(matrix->sync() == PluginResult::OK) { + fprintf(stderr, "Synced matrix\n"); + if(matrix->get_room_messages(sync_future_room_id, room_message_index, result.body_items, result.num_new_messages) != PluginResult::OK) { + fprintf(stderr, "Failed to get new matrix messages in room: %s\n", sync_future_room_id.c_str()); + } + } else { + fprintf(stderr, "Failed to sync matrix\n"); + } + + return result; + }); + } + + if(sync_future.valid() && sync_future.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { + SyncFutureResult sync_future_result = sync_future.get(); + // Ignore finished sync if it happened in another room. When we navigate back to the room we will get the messages again + if(sync_future_room_id == current_room_id) { + room_message_index += sync_future_result.num_new_messages; + tabs[MESSAGES_TAB_INDEX].body->append_items(std::move(sync_future_result.body_items)); + if(!tabs[MESSAGES_TAB_INDEX].body->items.empty() && sync_future_result.num_new_messages > 0) + tabs[MESSAGES_TAB_INDEX].body->set_selected_item(tabs[MESSAGES_TAB_INDEX].body->items.size() - 1); + } + sync_running = false; + } + + search_bar->update(); + + window.clear(back_color); + + const float width_per_tab = window_size.x / tabs.size(); + sf::RectangleShape tab_background(sf::Vector2f(std::floor(width_per_tab), tab_height)); + + float tab_vertical_offset = 0.0f; + tabs[selected_tab].body->draw(window, body_pos, body_size); + const float tab_y = tab_spacer_height + std::floor(tab_vertical_offset + tab_height * 0.5f - (tab_text_size + 5.0f) * 0.5f); + + int i = 0; + for(ChatTab &tab : tabs) { + if(i == selected_tab) + tab_background.setFillColor(tab_selected_color); + else + tab_background.setFillColor(tab_unselected_color); + + tab_background.setPosition(std::floor(i * width_per_tab), tab_spacer_height + std::floor(tab_vertical_offset)); + window.draw(tab_background); + const float center = (i * width_per_tab) + (width_per_tab * 0.5f); + tab.text.setPosition(std::floor(center - tab.text.getLocalBounds().width * 0.5f), tab_y); + window.draw(tab.text); + ++i; + } + + tab_drop_shadow.setSize(sf::Vector2f(window_size.x, 5.0f)); + tab_drop_shadow.setPosition(0.0f, std::floor(tab_vertical_offset + tab_height)); + window.draw(tab_drop_shadow); + + search_bar->draw(window, false); + window.display(); + } + + exit(0); // Ignore futures and quit immediately + } } diff --git a/src/SearchBar.cpp b/src/SearchBar.cpp index 62a0196..f790de8 100644 --- a/src/SearchBar.cpp +++ b/src/SearchBar.cpp @@ -20,6 +20,7 @@ namespace QuickMedia { onAutocompleteRequestCallback(nullptr), text_autosearch_delay(0), autocomplete_search_delay(0), + caret_visible(true), text(placeholder, font, 18), autocomplete_text("", font, 18), placeholder_str(placeholder), @@ -29,7 +30,6 @@ namespace QuickMedia { draw_logo(false), needs_update(true), input_masked(input_masked), - caret_visible(true), vertical_pos(0.0f) { text.setFillColor(text_placeholder_color); @@ -273,11 +273,12 @@ namespace QuickMedia { } float SearchBar::getBottom() const { - return shade.getSize().y + background_shadow.getSize().y; + return getBottomWithoutShadow() + 5.0f;//background_shadow.getSize().y; } float SearchBar::getBottomWithoutShadow() const { - return shade.getSize().y; + float font_height = text.getCharacterSize() + 7.0f; + return std::floor(font_height + background_margin_vertical * 2.0f) + padding_vertical + padding_vertical; } std::string SearchBar::get_text() const { diff --git a/src/Storage.cpp b/src/Storage.cpp index 588b085..0c3479a 100644 --- a/src/Storage.cpp +++ b/src/Storage.cpp @@ -2,6 +2,8 @@ #include "../include/env.hpp" #include #include +#include +#include #if OS_FAMILY == OS_FAMILY_POSIX #include @@ -122,7 +124,7 @@ namespace QuickMedia { int file_overwrite(const Path &path, const std::string &data) { FILE *file = fopen(path.data.c_str(), "wb"); if(!file) - return errno; + return -1; if(fwrite(data.data(), 1, data.size(), file) != data.size()) { fclose(file); @@ -154,4 +156,39 @@ namespace QuickMedia { break; } } + + bool read_file_as_json(const Path &filepath, Json::Value &result) { + std::string file_content; + if(file_get_content(filepath, file_content) != 0) { + fprintf(stderr, "Failed to get content of file: %s\n", filepath.data.c_str()); + return false; + } + + Json::CharReaderBuilder json_builder; + std::unique_ptr json_reader(json_builder.newCharReader()); + std::string json_errors; + if(!json_reader->parse(file_content.data(), file_content.data() + file_content.size(), &result, &json_errors)) { + fprintf(stderr, "Failed to read file %s as json, error: %s\n", filepath.data.c_str(), json_errors.c_str()); + return false; + } + + return true; + } + + bool save_json_to_file_atomic(const Path &path, const Json::Value &json) { + Path tmp_path = path; + tmp_path.append(".tmp"); + + Json::StreamWriterBuilder json_builder; + if(file_overwrite(tmp_path, Json::writeString(json_builder, json)) != 0) + return false; + + // Rename is atomic under posix! + if(rename(tmp_path.data.c_str(), path.data.c_str()) != 0) { + perror("save_json_to_file_atomic rename"); + return false; + } + + return true; + } } \ No newline at end of file diff --git a/src/StringUtils.cpp b/src/StringUtils.cpp index 7668df7..f255971 100644 --- a/src/StringUtils.cpp +++ b/src/StringUtils.cpp @@ -16,6 +16,19 @@ namespace QuickMedia { } } + size_t string_replace_all(std::string &str, char old_char, const std::string &new_str) { + size_t num_replaced_substrings = 0; + size_t index = 0; + while(true) { + index = str.find(old_char, index); + if(index == std::string::npos) + break; + str.replace(index, 1, new_str); + ++num_replaced_substrings; + } + return num_replaced_substrings; + } + size_t string_replace_all(std::string &str, const std::string &old_str, const std::string &new_str) { size_t num_replaced_substrings = 0; size_t index = 0; diff --git a/src/Text.cpp b/src/Text.cpp index 9ec2f68..0517c15 100644 --- a/src/Text.cpp +++ b/src/Text.cpp @@ -70,7 +70,7 @@ namespace QuickMedia this->str = str; dirty = true; dirtyText = true; - if(str.getSize() < caretIndex) + if((int)str.getSize() < caretIndex) { caretIndex = str.getSize(); dirtyCaret = true; @@ -265,7 +265,7 @@ namespace QuickMedia { // If there was a space in the text and text width is too long, then we need to word wrap at space index instead, // which means we need to change the position of all vertices after the space to the current vertex - if(lastSpacingWordWrapIndex != -1) + if(lastSpacingWordWrapIndex != (size_t)-1) { for(size_t j = lastSpacingWordWrapIndex; j < i; ++j) { @@ -384,7 +384,7 @@ namespace QuickMedia bool Text::isCaretAtEnd() const { assert(!dirty && !dirtyText); - return textElements[0].text.size == 0 || caretIndex == textElements[0].text.size; + return textElements[0].text.size == 0 || caretIndex == (int)textElements[0].text.size; } // TODO: This can be optimized by using binary search @@ -510,7 +510,7 @@ namespace QuickMedia { if(!editable) return; - bool caretAtEnd = textElements.size() == 0 || textElements[0].text.size == 0 || caretIndex == textElements[0].text.size; + bool caretAtEnd = textElements.size() == 0 || textElements[0].text.size == 0 || caretIndex == (int)textElements[0].text.size; if(event.type == sf::Event::KeyPressed) { diff --git a/src/VideoPlayer.cpp b/src/VideoPlayer.cpp index e8f8795..0811c74 100644 --- a/src/VideoPlayer.cpp +++ b/src/VideoPlayer.cpp @@ -19,6 +19,7 @@ const int READ_TIMEOUT_MS = 200; namespace QuickMedia { VideoPlayer::VideoPlayer(bool use_tor, bool use_system_mpv_config, EventCallbackFunc _event_callback, VideoPlayerWindowCreateCallback _window_create_callback) : + exit_status(0), use_tor(use_tor), use_system_mpv_config(use_system_mpv_config), video_process_id(-1), @@ -26,7 +27,6 @@ namespace QuickMedia { connected_to_ipc(false), connect_tries(0), find_window_tries(0), - exit_status(0), event_callback(_event_callback), window_create_callback(_window_create_callback), window_handle(0), @@ -58,7 +58,7 @@ namespace QuickMedia { XCloseDisplay(display); } - VideoPlayer::Error VideoPlayer::launch_video_process(const char *path, sf::WindowHandle _parent_window, const std::string &plugin_name) { + VideoPlayer::Error VideoPlayer::launch_video_process(const char *path, sf::WindowHandle _parent_window, const std::string&) { parent_window = _parent_window; if(!tmpnam(ipc_server_path)) { diff --git a/src/plugins/Dmenu.cpp b/src/plugins/Dmenu.cpp index a3b354b..5c46841 100644 --- a/src/plugins/Dmenu.cpp +++ b/src/plugins/Dmenu.cpp @@ -16,7 +16,7 @@ namespace QuickMedia { return PluginResult::OK; } - SearchResult Dmenu::search(const std::string &text, BodyItems &result_items) { + SearchResult Dmenu::search(const std::string &text, BodyItems&) { std::cout << text << std::endl; return SearchResult::OK; } diff --git a/src/plugins/Fourchan.cpp b/src/plugins/Fourchan.cpp index 4f87049..4490f39 100644 --- a/src/plugins/Fourchan.cpp +++ b/src/plugins/Fourchan.cpp @@ -112,14 +112,6 @@ namespace QuickMedia { return PluginResult::OK; } - SearchResult Fourchan::search(const std::string &url, BodyItems &result_items) { - return SearchResult::OK; - } - - SuggestionResult Fourchan::update_search_suggestions(const std::string &text, BodyItems &result_items) { - return SuggestionResult::OK; - } - struct CommentPiece { enum class Type { TEXT, diff --git a/src/plugins/Matrix.cpp b/src/plugins/Matrix.cpp new file mode 100644 index 0000000..77e295e --- /dev/null +++ b/src/plugins/Matrix.cpp @@ -0,0 +1,687 @@ +#include "../../plugins/Matrix.hpp" +#include "../../include/Storage.hpp" +#include +#include +#include +#include + +// TODO: Update avatar/display name when its changed in the room/globally. +// Send read receipt to server and receive notifications in /sync and show the notifications. +// Delete messages. +// Edit messages. +// Show embedded images/videos. +// TODO: Verify if buffer of size 512 is enough for endpoints + +namespace QuickMedia { + Matrix::Matrix() : Plugin("matrix") { + + } + + PluginResult Matrix::get_cached_sync(BodyItems &result_items) { + /* + Path sync_cache_path = get_cache_dir().join(name).join("sync.json"); + Json::Value root; + if(!read_file_as_json(sync_cache_path, root)) + return PluginResult::ERR; + return sync_response_to_body_items(root, result_items); + */ + (void)result_items; + return PluginResult::OK; + } + + PluginResult Matrix::sync() { + std::vector additional_args = { + { "-H", "Authorization: Bearer " + access_token }, + { "-m", "35" } + }; + + std::string server_response; + // timeout=30000, filter=0. First sync should be without filter and timeout=0, then all other sync should be with timeout=30000 and filter=0. + // GET https://glowers.club/_matrix/client/r0/user/%40dec05eba%3Aglowers.club/filter/0 first to check if the filter is available + // and if lazy load members is available and get limit to use with https://glowers.club/_matrix/client/r0/rooms/!oSXkiqBKooDcZsmiGO%3Aglowers.club/ + // when first launching the client. This call to /rooms/ should be called before /sync/, when accessing a room. But only the first time + // (for the session). + + // Note: the first sync call with always exclude since= (next_batch) because we want to receive the latest messages in a room, + // which is important if we for example login to matrix after having not been online for several days and there are many new messages. + // We should be shown the latest messages first and if the user wants to see older messages then they should scroll up. + // Note: missed mentions are received in /sync and they will remain in new /sync unless we send a read receipt that we have read them. + + char url[512]; + if(next_batch.empty()) + 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()); + + if(download_to_string(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 sync response parse error: %s\n", json_errors.c_str()); + return PluginResult::ERR; + } + + PluginResult result = sync_response_to_body_items(json_root); + if(result != PluginResult::OK) + return result; + + const Json::Value &next_batch_json = json_root["next_batch"]; + if(next_batch_json.isString()) { + next_batch = next_batch_json.asString(); + fprintf(stderr, "Matrix: next batch: %s\n", next_batch.c_str()); + } else { + fprintf(stderr, "Matrix: missing next batch\n"); + } + + // TODO: Only create the first time sync is called? + /* + Path sync_cache_path = get_cache_dir().join(name); + if(create_directory_recursive(sync_cache_path) == 0) { + sync_cache_path.join("sync.json"); + if(!save_json_to_file_atomic(sync_cache_path, json_root)) { + fprintf(stderr, "Warning: failed to save sync response to %s\n", sync_cache_path.data.c_str()); + } + } else { + fprintf(stderr, "Warning: failed to create directory: %s\n", sync_cache_path.data.c_str()); + } + */ + + return PluginResult::OK; + } + + PluginResult Matrix::get_joined_rooms(BodyItems &result_items) { + std::vector additional_args = { + { "-H", "Authorization: Bearer " + access_token } + }; + + std::string server_response; + if(download_to_string(homeserver + "/_matrix/client/r0/joined_rooms", 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 joined rooms response parse error: %s\n", json_errors.c_str()); + return PluginResult::ERR; + } + + if(!json_root.isObject()) + return PluginResult::ERR; + + const Json::Value &joined_rooms_json = json_root["joined_rooms"]; + if(!joined_rooms_json.isArray()) + return PluginResult::ERR; + + for(const Json::Value &room_id_json : joined_rooms_json) { + if(!room_id_json.isString()) + continue; + + std::string room_id_str = room_id_json.asString(); + + 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_by_id.insert(std::make_pair(room_id_str, std::move(room_data))); + fprintf(stderr, "Missing room %s from /sync, adding in joined_rooms\n", room_id_str.c_str()); + } + + auto body_item = std::make_unique(std::move(room_id_str)); + //body_item->url = ""; + result_items.push_back(std::move(body_item)); + } + + return PluginResult::OK; + } + + PluginResult Matrix::get_room_messages(const std::string &room_id, size_t start_index, BodyItems &result_items, size_t &num_new_messages) { + num_new_messages = 0; + + 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; + } + + if(!room_it->second->initial_fetch_finished) { + PluginResult result = load_initial_room_data(room_id, room_it->second.get()); + if(result == PluginResult::OK) { + room_it->second->initial_fetch_finished = true; + } else { + fprintf(stderr, "Initial sync failed for room: %s\n", room_id.c_str()); + return result; + } + } + + // This will happen if there are no new messages + if(start_index >= room_it->second->messages.size()) + return PluginResult::OK; + + num_new_messages = room_it->second->messages.size() - start_index; + + size_t prev_user_id = -1; + for(auto it = room_it->second->messages.begin() + start_index, end = room_it->second->messages.end(); it != end; ++it) { + const UserInfo &user_info = room_it->second->user_info[it->user_id]; + if(it->user_id == prev_user_id) { + assert(!result_items.empty()); + result_items.back()->append_description("\n"); + result_items.back()->append_description(it->msg); + } else { + auto body_item = std::make_unique(""); + body_item->author = user_info.display_name; + body_item->set_description(it->msg); + body_item->thumbnail_url = user_info.avatar_url; + result_items.push_back(std::move(body_item)); + prev_user_id = it->user_id; + } + } + + return PluginResult::OK; + } + + PluginResult Matrix::sync_response_to_body_items(const Json::Value &root) { + if(!root.isObject()) + return PluginResult::ERR; + + const Json::Value &rooms_json = root["rooms"]; + if(!rooms_json.isObject()) + return PluginResult::OK; + + const Json::Value &join_json = rooms_json["join"]; + if(!join_json.isObject()) + return PluginResult::OK; + + for(Json::Value::const_iterator it = join_json.begin(); it != join_json.end(); ++it) { + if(!it->isObject()) + continue; + + Json::Value room_id = it.key(); + if(!room_id.isString()) + continue; + + std::string room_id_str = room_id.asString(); + + 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_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 + } + + const Json::Value &state_json = (*it)["state"]; + if(!state_json.isObject()) + continue; + + const Json::Value &events_json = state_json["events"]; + events_add_user_info(events_json, room_it->second.get()); + } + + for(Json::Value::const_iterator it = join_json.begin(); it != join_json.end(); ++it) { + if(!it->isObject()) + continue; + + Json::Value room_id = it.key(); + if(!room_id.isString()) + continue; + + std::string room_id_str = room_id.asString(); + + 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_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 + } + + const Json::Value &timeline_json = (*it)["timeline"]; + if(!timeline_json.isObject()) + continue; + + // This may be non-existent if this is the first event in the room + const Json::Value &prev_batch_json = timeline_json["prev_batch"]; + if(prev_batch_json.isString()) + room_it->second->prev_batch = prev_batch_json.asString(); + + const Json::Value &events_json = timeline_json["events"]; + events_add_messages(events_json, room_it->second.get(), MessageDirection::AFTER); + } + + return PluginResult::OK; + } + + void Matrix::events_add_user_info(const Json::Value &events_json, RoomData *room_data) { + if(!events_json.isArray()) + return; + + for(const Json::Value &event_item_json : events_json) { + if(!event_item_json.isObject()) + continue; + + const Json::Value &type_json = event_item_json["type"]; + if(!type_json.isString() || strcmp(type_json.asCString(), "m.room.member") != 0) + continue; + + const Json::Value &sender_json = event_item_json["sender"]; + if(!sender_json.isString()) + continue; + + const Json::Value &content_json = event_item_json["content"]; + if(!content_json.isObject()) + continue; + + const Json::Value &membership_json = content_json["membership"]; + if(!membership_json.isString() || strcmp(membership_json.asCString(), "join") != 0) + continue; + + const Json::Value &avatar_url_json = content_json["avatar_url"]; + if(!avatar_url_json.isString()) + continue; + + const Json::Value &display_name_json = content_json["displayname"]; + if(!display_name_json.isString()) + continue; + + std::string sender_json_str = sender_json.asString(); + auto user_it = room_data->user_info_by_user_id.find(sender_json_str); + if(user_it != room_data->user_info_by_user_id.end()) + continue; + + UserInfo user_info; + user_info.avatar_url = avatar_url_json.asString(); + if(user_info.avatar_url.size() >= 6) + user_info.avatar_url.erase(user_info.avatar_url.begin(), user_info.avatar_url.begin() + 6); + // TODO: What if the user hasn't selected an avatar? + user_info.avatar_url = homeserver + "/_matrix/media/r0/thumbnail/" + user_info.avatar_url + "?width=32&height=32&method=crop"; + user_info.display_name = display_name_json.asString(); + room_data->user_info.push_back(std::move(user_info)); + room_data->user_info_by_user_id.insert(std::make_pair(sender_json_str, room_data->user_info.size() - 1)); + } + } + + void Matrix::events_add_messages(const Json::Value &events_json, RoomData *room_data, MessageDirection message_dir) { + if(!events_json.isArray()) + return; + + std::vector new_messages; + + for(const Json::Value &event_item_json : events_json) { + if(!event_item_json.isObject()) + continue; + + const Json::Value &type_json = event_item_json["type"]; + if(!type_json.isString() || strcmp(type_json.asCString(), "m.room.message") != 0) + continue; + + const Json::Value &sender_json = event_item_json["sender"]; + if(!sender_json.isString()) + continue; + + std::string sender_json_str = sender_json.asString(); + + const Json::Value &content_json = event_item_json["content"]; + if(!content_json.isObject()) + continue; + + const Json::Value &content_type = content_json["msgtype"]; + if(!content_type.isString() || strcmp(content_type.asCString(), "m.text") != 0) + continue; + + const Json::Value &body_json = content_json["body"]; + if(!body_json.isString()) + continue; + + auto user_it = room_data->user_info_by_user_id.find(sender_json_str); + if(user_it == room_data->user_info_by_user_id.end()) { + fprintf(stderr, "Warning: skipping unknown user: %s\n", sender_json_str.c_str()); + continue; + } + + Message message; + message.user_id = user_it->second; + message.msg = body_json.asString(); + new_messages.push_back(std::move(message)); + } + + if(message_dir == MessageDirection::BEFORE) { + room_data->messages.insert(room_data->messages.begin(), new_messages.rbegin(), new_messages.rend()); + } else if(message_dir == MessageDirection::AFTER) { + room_data->messages.insert(room_data->messages.end(), new_messages.begin(), new_messages.end()); + } + } + + PluginResult Matrix::load_initial_room_data(const std::string &room_id, RoomData *room_data) { + std::string from = room_data->prev_batch; + if(from.empty()) { + fprintf(stderr, "Info: missing previous batch for room: %s, using /sync next batch\n", room_id.c_str()); + from = next_batch; + if(from.empty()) { + fprintf(stderr, "Error: missing next batch!\n"); + return PluginResult::OK; + } + } + + 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, request_data)); + + 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_id.c_str(), from.c_str(), filter.c_str()); + fprintf(stderr, "load initial room data, 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 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 /rooms//messages/ response parse error: %s\n", json_errors.c_str()); + return PluginResult::ERR; + } + + if(!json_root.isObject()) + return PluginResult::ERR; + + const Json::Value &state_json = json_root["state"]; + events_add_user_info(state_json, room_data); + + const Json::Value &chunk_json = json_root["chunk"]; + events_add_messages(chunk_json, room_data, MessageDirection::BEFORE); + + return PluginResult::OK; + } + + SearchResult Matrix::search(const std::string&, BodyItems&) { + return SearchResult::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(); + } + + PluginResult Matrix::post_message(const std::string &room_id, const std::string &text) { + 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; + string_split(text, '\n', [&formatted_body, &contains_formatted_text](const char *str, size_t size){ + 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); + } + return true; + }); + + Json::Value request_data(Json::objectValue); + request_data["msgtype"] = "m.text"; + request_data["body"] = text; + if(contains_formatted_text) { + request_data["format"] = "org.matrix.custom.html"; + request_data["formatted_body"] = std::move(formatted_body); + } + + 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, request_data) } + }; + + char url[512]; + snprintf(url, sizeof(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", url); + + std::string server_response; + if(download_to_string(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 message, response event id: %s\n", event_id_json.asCString()); + return PluginResult::OK; + } + + static std::string parse_login_error_response(std::string json_str) { + if(json_str.empty()) + return "Unknown error"; + + Json::Value json_root; + Json::CharReaderBuilder json_builder; + std::unique_ptr json_reader(json_builder.newCharReader()); + std::string json_errors; + if(!json_reader->parse(&json_str[0], &json_str[json_str.size()], &json_root, &json_errors)) { + fprintf(stderr, "Matrix login response parse error: %s\n", json_errors.c_str()); + return json_str; + } + + if(!json_root.isObject()) + return json_str; + + const Json::Value &errcode_json = json_root["errcode"]; + // Yes, matrix is retarded and returns M_NOT_JSON error code when username/password is incorrect + if(errcode_json.isString() && strcmp(errcode_json.asCString(), "M_NOT_JSON") == 0) + return "Incorrect username or password"; + + return json_str; + } + + // Returns empty string on error + static std::string extract_homeserver_from_user_id(const std::string &user_id) { + size_t index = user_id.find(':'); + if(index == std::string::npos) + return ""; + return user_id.substr(index + 1); + } + + PluginResult Matrix::login(const std::string &username, const std::string &password, const std::string &homeserver, std::string &err_msg) { + // TODO: this is deprecated but not all homeservers have the new version. + // When this is removed from future version then switch to the new login method (identifier object with the username). + Json::Value request_data(Json::objectValue); + request_data["type"] = "m.login.password"; + request_data["user"] = username; + request_data["password"] = password; + + Json::StreamWriterBuilder builder; + builder["commentStyle"] = "None"; + builder["indentation"] = ""; + + std::vector additional_args = { + { "-X", "POST" }, + { "-H", "content-type: application/json" }, + { "--data-binary", Json::writeString(builder, request_data) } + }; + + std::string server_response; + if(download_to_string(homeserver + "/_matrix/client/r0/login", server_response, std::move(additional_args), use_tor, true) != DownloadResult::OK) { + err_msg = parse_login_error_response(std::move(server_response)); + 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)) { + err_msg = "Matrix login response parse error: " + json_errors; + return PluginResult::ERR; + } + + if(!json_root.isObject()) { + err_msg = "Failed to parse matrix login response"; + return PluginResult::ERR; + } + + const Json::Value &user_id_json = json_root["user_id"]; + if(!user_id_json.isString()) { + err_msg = "Failed to parse matrix login response"; + return PluginResult::ERR; + } + + const Json::Value &access_token_json = json_root["access_token"]; + if(!access_token_json.isString()) { + err_msg = "Failed to parse matrix login response"; + return PluginResult::ERR; + } + + std::string user_id = user_id_json.asString(); + + std::string homeserver_response = extract_homeserver_from_user_id(user_id); + if(homeserver_response.empty()) { + err_msg = "Missing homeserver in user id, user id: " + user_id; + return PluginResult::ERR; + } + + this->user_id = std::move(user_id); + this->access_token = access_token_json.asString(); + this->homeserver = "https://" + std::move(homeserver_response); + + // TODO: Handle well_known field. The spec says clients SHOULD handle it if its provided + + Path session_path = get_storage_dir().join(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::load_and_verify_cached_session() { + Path session_path = get_storage_dir().join(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; + } + + Json::Value json_root; + Json::CharReaderBuilder json_builder; + std::unique_ptr json_reader(json_builder.newCharReader()); + std::string json_errors; + if(!json_reader->parse(&session_json_content[0], &session_json_content[session_json_content.size()], &json_root, &json_errors)) { + fprintf(stderr, "Matrix cached session parse error: %s\n", json_errors.c_str()); + return PluginResult::ERR; + } + + const Json::Value &user_id_json = json_root["user_id"]; + if(!user_id_json.isString()) { + fprintf(stderr, "Failed to parse matrix cached session response\n"); + return PluginResult::ERR; + } + + const Json::Value &access_token_json = json_root["access_token"]; + if(!access_token_json.isString()) { + fprintf(stderr, "Failed to parse matrix cached session response\n"); + return PluginResult::ERR; + } + + std::string user_id = user_id_json.asString(); + std::string access_token = access_token_json.asString(); + + std::string homeserver = extract_homeserver_from_user_id(user_id); + if(homeserver.empty()) { + fprintf(stderr, "Missing homeserver in user id, user id: %s\n", user_id.c_str()); + return PluginResult::ERR; + } + + std::vector additional_args = { + { "-H", "Authorization: Bearer " + access_token } + }; + + std::string server_response; + // We want to make any request to the server that can verify that our token is still valid, doesn't matter which call + if(download_to_string("https://" + homeserver + "/_matrix/client/r0/account/whoami", server_response, std::move(additional_args), use_tor, true) != DownloadResult::OK) { + fprintf(stderr, "Matrix whoami response: %s\n", server_response.c_str()); + return PluginResult::NET_ERR; + } + + this->user_id = std::move(user_id); + this->access_token = std::move(access_token); + this->homeserver = "https://" + homeserver; + return PluginResult::OK; + } +} \ No newline at end of file diff --git a/src/plugins/NyaaSi.cpp b/src/plugins/NyaaSi.cpp index 2ecf0d3..862d3d4 100644 --- a/src/plugins/NyaaSi.cpp +++ b/src/plugins/NyaaSi.cpp @@ -162,7 +162,7 @@ namespace QuickMedia { // return url.substr(index); // } - PluginResult NyaaSi::get_content_details(const std::string &list_url, const std::string &url, BodyItems &result_items) { + PluginResult NyaaSi::get_content_details(const std::string&, const std::string &url, BodyItems &result_items) { size_t comments_start_index; // std::string id = view_url_get_id(url); // if(id.empty()) { diff --git a/src/plugins/Plugin.cpp b/src/plugins/Plugin.cpp index 8690964..f23175c 100644 --- a/src/plugins/Plugin.cpp +++ b/src/plugins/Plugin.cpp @@ -29,22 +29,41 @@ namespace QuickMedia { } struct HtmlEscapeSequence { + char unescape_char; std::string escape_sequence; - std::string unescaped_str; }; - void html_unescape_sequences(std::string &str) { + void html_escape_sequences(std::string &str) { const std::array escape_sequences = { - HtmlEscapeSequence { """, "\"" }, - HtmlEscapeSequence { "'", "'" }, - HtmlEscapeSequence { "'", "'" }, - HtmlEscapeSequence { "<", "<" }, - HtmlEscapeSequence { ">", ">" }, - HtmlEscapeSequence { "&", "&" } // This should be last, to not accidentally replace a new sequence caused by replacing this + HtmlEscapeSequence { '"', """ }, + HtmlEscapeSequence { '\'', "'" }, + HtmlEscapeSequence { '<', "<" }, + HtmlEscapeSequence { '>', ">" }, + HtmlEscapeSequence { '&', "&" } // This should be last, to not accidentally replace a new sequence caused by replacing this }; for(const HtmlEscapeSequence &escape_sequence : escape_sequences) { - string_replace_all(str, escape_sequence.escape_sequence, escape_sequence.unescaped_str); + string_replace_all(str, escape_sequence.unescape_char, escape_sequence.escape_sequence); + } + } + + struct HtmlUnescapeSequence { + std::string escape_sequence; + std::string unescaped_str; + }; + + void html_unescape_sequences(std::string &str) { + const std::array unescape_sequences = { + HtmlUnescapeSequence { """, "\"" }, + HtmlUnescapeSequence { "'", "'" }, + HtmlUnescapeSequence { "'", "'" }, + HtmlUnescapeSequence { "<", "<" }, + HtmlUnescapeSequence { ">", ">" }, + HtmlUnescapeSequence { "&", "&" } // This should be last, to not accidentally replace a new sequence caused by replacing this + }; + + for(const HtmlUnescapeSequence &unescape_sequence : unescape_sequences) { + string_replace_all(str, unescape_sequence.escape_sequence, unescape_sequence.unescaped_str); } } -- cgit v1.2.3